Skip to content

fix(a11y): add semantic headings and landmarks to the document structure - #2091

Open
LeonarddeR wants to merge 13 commits into
gethinode:mainfrom
LeonarddeR:fix/1519-semantic-document-structure
Open

fix(a11y): add semantic headings and landmarks to the document structure#2091
LeonarddeR wants to merge 13 commits into
gethinode:mainfrom
LeonarddeR:fix/1519-semantic-document-structure

Conversation

@LeonarddeR

@LeonarddeR LeonarddeR commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What

Phase 1 of #1519document structure and landmarks only. Every page gets exactly one h1, the page header becomes a real <header>, and the table of contents label becomes a heading that can be jumped to.

No new component arguments, no vendored module changes, and no new i18n keys.

Baseline: 15 English pages had no h1 at all, and <header> appeared nowhere in layouts/. After this PR that's 7 pages without an h1, and all 7 are the known follow-up work (see below).

Fixes (one commit each)

Fix File
Wrap the page header block in <header>, ending before the TOC dropdown header.html, docs/header.html, minimal/header.html
Taxonomy title <p class="display-4"><h1> (4 EN pages + FR/NL) tags/list.html
404: <span><div> (a span cannot contain <p>) and the title → <h1> 404.html
Form confirmation: same invalid nesting, title → <h1> form/single.html
TOC label <strong><h6 id="toc-heading">, nav gets aria-labelledby assets/toc-parse-content.html
aria-selected derived from $show instead of the loop index assets/nav.html
404 heading covers both the numeral and the message 404.html

The <header> lands inside <main>, so per spec it is a generic section header, not a second banner landmark.

The TOC change reuses the existing toc translation, already present in all 8 locales.

On the 404 page, both the numeral and pageNotFoundTitle sit inside the h1 as block-level spans, so it announces as "404 Page not found" rather than a bare number.

The nav.html fix is a genuine bug: with nav-show pointing at any tab other than the first, the active class landed on one tab while aria-selected="true" stayed on tab 0. The buttons branch already used $show.

On the nav bullet in the issue

The issue's checklist proposes replacing ul/li with nav/a in assets/nav.html per the Bootstrap base-nav example. That change should not be made. assets/nav.html is not navigation — it is an ARIA tab widget (role="tablist" on the ul, role="presentation" on the li, role="tab" on the button, paired with role="tabpanel" in nav-item.html), and its only caller is the {{< nav >}} shortcode, where every tab-type is a tab variant. Converting it would break aria-required-children — the same audit _shortcodes/button-group.html was already fixed for in #2066 — and would break assets/js/nav.js, which walks parentElement.parentElement to reach ul[data-companion]; without the <li> that resolves to the wrapping <div>, data-companion returns null, and the responsive dropdown sync silently stops working.

Verification

Lighthouse accessibility — blog post, docs page, tags, id-demo, form and minimal layouts all score 100 with zero failures. The 404 page scores 95 on link-in-text-block, which is pre-existing and unrelated: the failing node is the trailing "home page" link (2:1 contrast against surrounding text, no underline), and re-auditing the pre-change template returns the identical 95 with the identical single failure. On that same pair, heading-order went from n/a (the page had no headings) to pass.

Visual parity — the changes are semantics-only, so every affected page was fingerprinted in the browser before and after: each text-bearing element in <main> recorded as text plus x/y/width/height and hashed. All 8 pages hash byte-identical to the pre-change baseline, and the reworked 404 heading was re-checked the same way against the original markup at a matched viewport.

That check earned its keep — it caught four regressions that would otherwise have shipped:

  • headings default to an 8px bottom margin where the promoted paragraphs had 16px → explicit mb-3
  • fs-3 sets only a font size, so the form h1 line-height dropped 39.35px → 31.48px → lh-base
  • the theme adds a 16px top margin to headings, pushing the TOC label down → mt-0
  • .display-1 sets font-weight on the element itself, so the 404 numeral could not inherit fw-bold from the h1 → repeated on the span

Structural assertions — a throwaway script over the built exampleSite (en/fr/nl) checking one h1 per non-stub page, the TOC heading and its named landmark, <header> presence, no flow content inside <span>, and active/aria-selected agreement on every tab. All five red before, all five green after.

Two page types are unreachable in the exampleSite (type: form, type: minimal), so they were verified with temporary local fixtures that are not part of this PR — EXPECTED_PAGES in scripts/check-build-determinism.sh is deliberately untouched.

pnpm lint passes; pnpm build:example is clean with no new i18n warnings and no errorf from toc-headings.

Note: scripts/check-build-determinism.sh fails on this branch and on clean main, with generated img/placeholder-*.webp/png differing between consecutive builds. Pre-existing and unrelated to templates, but worth a separate look.

Follow-up (not in this PR)

The 7 remaining pages without an h1 fall into two groups: docs/blocks and docs/components come from section-title.html with use-title: false, and home plus team are content_blocks pages owned by mod-blocks.

Promoting in-content component titles (cards, section titles, timeline, accordion) needs a heading-level argument, and has a blocker to solve first: toc-headings.html regex-scrapes .Content for <h2><h3> to build the mobile TOC while the desktop TOC reads .Page.Fragments, so new in-content headings would make the two disagree — and toc-headings.html calls errorf on any heading without an id, which would fail the build.

🤖 Generated with Claude Code

LeonarddeR and others added 6 commits July 29, 2026 15:46
The page header block emitted a flat sequence of siblings with no landmark, so assistive
technology had no way to identify the title, metadata and description as introductory
content. Wrap breadcrumb through description in `<header>` across the three header
templates. The TOC dropdown stays outside, as it belongs with the content region.

The element lands inside `<main>`, so per spec it is a generic section header rather than
a second `banner` landmark. Verified layout-neutral: element geometry across the affected
pages is byte-identical before and after.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Taxonomy and tag pages rendered their title as `<p class="display-4">`, leaving every one
of them without an `h1`. Promote it to `<h1>` and add `mb-3` to keep the paragraph's 1rem
bottom margin, which a heading would otherwise reduce to 0.5rem.

Fixes four English pages plus their French and Dutch equivalents. Verified layout-neutral.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 404 page had no `h1`, and its two paragraphs were nested inside a `<span>` — invalid
markup, since a span is phrasing content and cannot contain flow content.

Change the wrapper to `<div>` and promote the numeral to `<h1>`, keeping `mb-3` so the
heading retains the paragraph's 1rem bottom margin. The translated message stays a
paragraph. Verified layout-neutral.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same defect as the 404 page: no `h1`, and a paragraph nested inside a `<span>`.

Change the wrapper to `<div>` and promote the title to `<h1>`. Because `fs-3` only sets a
font size, the heading also needs `lh-base` to keep the paragraph's 1.5 line height and
`fw-bold` to keep the weight it previously inherited from the wrapper. Verified
layout-neutral.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "On this page" label was a `<strong>`, so it never appeared in a screen reader's
heading list and could not be jumped to. Its `<nav>` was also unnamed, leaving it competing
with the navbar and the two breadcrumb landmarks.

Promote the label to `<h6>` and point the nav at it with `aria-labelledby`. Reuses the
existing `toc` translation, so no new i18n keys. The utility classes reproduce the previous
appearance exactly: `lh-base` restores the 1.5 line height a heading would drop to 1.2, and
`mt-0` cancels the top margin the theme applies to headings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The standard nav branch derived `aria-selected` from the loop index instead of `$show`, the
same variable that already drives the `active` class. With `nav-show` pointing at any tab
other than the first, two tabs disagreed: one looked selected and a different one reported
itself as selected. The buttons branch already used `$show`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 29, 2026

Copy link
Copy Markdown

👷 Deploy request for gethinode-demo pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 0b39409

An h1 containing only "404" announces as a bare number. Move both the numeral and the
translated message into the heading so it reads "404 Page not found", and carry the visual
styling on block-level spans.

Three classes keep the rendering identical: `d-block` restores the block layout the
paragraphs had, `lh-base` keeps the message at line height 1.5 rather than the 1.2 a
heading would impose, and `fw-bold` is repeated on the numeral because `display-1` sets its
own font weight on the element, which an inherited value cannot override.

Verified against the previous markup at the same viewport: element geometry is identical,
and Lighthouse still reports 95 with only the pre-existing `link-in-text-block` finding on
the unrelated trailing link.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@markdumay

Copy link
Copy Markdown
Collaborator

Reviewed by building the branch and inspecting the rendered exampleSite rather than reading the diff alone. One blocking item, two verification gaps, everything else checks out.

Verified

  • <header> really is inside <main> — confirmed by byte offset (main@9317, header@32468 on en/privacy), so it's a generic section header rather than a competing banner landmark, as you said. Across 200 built pages: all tags balanced, never more than one per page, zero empty <header></header>.
  • The nav.html fix is a genuine bug fix. $show is initialised to eq $index 0 then overridden by navShow, and the active class already used $show while aria-selected re-derived from the index. Right variable, right fix.
  • No h1 duplication on taxonomy pagestags/list.html never calls .Render "header", so the promoted title is the only h1. Verified as exactly 1 on every built tag page.
  • The 404 nesting fix is legitimate<span> wrapping <p> is genuinely invalid, and the rebuilt page has exactly one h1.
  • Your "don't convert nav.html" argument holds. assets/js/nav.js:40 does exactly the parentElement.parentElement.getAttribute('data-companion') walk you describe (button → li → ul), and the role="tablist" / presentation / tab triple confirms it's a tab widget, not navigation. Agreed — that checklist item in Add headings (h1, h2, h3 and other semantic headers) #1519 should not be actioned.

The four visual-parity catches (heading mb-3, lh-base on fs-3, mt-0, fw-bold on .display-1) are exactly the kind of silent regression that otherwise ships. That check earned its keep.

Blocking: the TOC <h6> introduces a heading-order violation

Rendered heading sequence on every page that has a TOC:

nl/privacy           h1 h2 h2 h2 h2 h2 h6
en/docs/blocks/cta   h5 h1 h2 h3 h3 h3 h2 h2 h3 h3 h3 h3 h6

The h6 always lands after an h2 or h3 — a jump of 3–4 levels. axe-core's heading-order rule, which Lighthouse runs, flags any increase greater than one. 10 of 10 sampled pages show it.

Why the 100 scores didn't catch it: the TOC renders into <div class="col col-md-3 col-lg-2 d-none d-md-block">. The default breakpoint is md (768px) and Lighthouse's default emulation is mobile at 412px, so that column is display:none and axe skips it entirely. At ≥768px it is visible and auditable. The audits weren't wrong — they just never saw the element.

Cheapest fix that keeps your stated goal of a heading you can jump to:

<h2 class="h6 fw-bold lh-base mt-0 mb-2" id="toc-heading">{{ T "toc" }}</h2>

Bootstrap's .h6 preserves the visual size, and h3 → h2 is a decrease, which the rule permits. Alternatives: drop the heading and put aria-label on the <nav>, or keep the original <strong> and point aria-labelledby at it — aria-labelledby doesn't require the target to be a heading. Whichever you pick, worth re-running Lighthouse in desktop mode before merge.

Verification gaps

form/single.html has no build coverage. The exampleSite renders zero pages through it, which matches your note that the fixtures were temporary and excluded — so that file ships untested by CI. Worth adding a fixture page.

The aria-selected assertion couldn't have been red before. The exampleSite has no nav-show usage anywhere, and its only nav-item show="true" sits on a single-item nav where index 0 is the shown tab — so eq $index 0 and $show agree. The built output confirms 77 tabs, 28 active, 0 mismatches, but that would hold on main too. The fix itself is correct; the "all five red before" claim just doesn't cover that fifth assertion. A fixture with nav-show pointing at a later tab would turn it into a real regression guard.

Aside, out of scope

Many pages open with <h5 class="offcanvas-title" id="offcanvas-label"></h5> — an empty heading. Pre-existing and unrelated here; it's hidden until the offcanvas opens so audits skip it, but it's adjacent if there's a phase 2.

Verdict

Approve once the TOC heading level is resolved. Everything else is solid, and the write-up is unusually rigorous — the one gap is that the headline verification method has a viewport blind spot that happens to sit over the riskiest change in the PR.

🤖 Generated with Claude Code

markdumay and others added 5 commits July 30, 2026 19:31
The `h6` followed an `h2` or `h3` in document order, a jump of three to four
levels that axe-core's heading-order rule fails. It went unnoticed because the
TOC column is `d-none` below the `md` breakpoint, so Lighthouse's mobile
emulation never audited it.

Bootstrap's `.h6` class keeps the 1rem font size, while the `h2` element makes
the step a decrease, which the rule permits. Verified across the exampleSite:
66 violating pages before, 0 after.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`layouts/form/single.html` rendered zero pages in the exampleSite, so its
heading and nesting fix shipped without build coverage. Add a page routing
through it.

Every existing nav fixture put `show="true"` on the first item, where
`eq $index 0` and `$show` agree, so the aria-selected fix had no regression
guard. A three-tab nav whose third item is shown produces two
active/aria-selected mismatches on the previous markup and none on the current.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeonarddeR

Copy link
Copy Markdown
Contributor Author

Both commits are up: 9d1b86c (TOC heading level) and 6c134ae (fixtures).

TOC heading — reproduced the violation before changing anything: 66 of 66 pages carrying the TOC heading failed heading-order, all →6 jumps, zero violations from any other source. Applied the <h2 class="h6"> fix; rebuilt and rescanned: 0 violations across 103 pages with headings. Parity confirmed from the compiled cascade: .h6,h6{font-size:1rem} beats .h2,h2{font-size:calc(1.325rem + .9vw)} on specificity, the reboot rule matches both elements identically, .mt-0 overrides Hinode's h2{margin-top:2rem} exactly as it did h6{margin-top:1rem}, and scroll-margin-top carries over with the same value. .h6 survives PurgeCSS in the built main.min.css.

form/single.html coverage — added exampleSite/content/en/form-demo.md, the first page routing through that template. Output has exactly one h1 and no invalid nesting.

aria-selected guard — the point about the fifth assertion stands: with no nav-show usage and show="true" only ever on index 0, it couldn't have been red on main. Added a three-tab nav to id-demo.md with the third item shown, then verified it's a real guard by reverting nav.html to main's version and rebuilding: 2 active/aria-selected mismatches on the old markup, 0 on the fix.

Full sweep after both commits: 0 heading-order violations, 80 role=tab buttons with 0 mismatches, lint clean.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants