feat(a11y): forward a heading level to section titles - #193
Conversation
Every Bookshop wrapper rendered its section title as a plain div, so a page assembled from content blocks had no headings at all — not even an h1. Hinode's `assets/section-title.html` can now render a real heading, but only when a caller asks for a level. Work out which block opens the page and hand it the level the page asked for; every block after it becomes a subsection. A block that sets `heading_level` itself always wins. When the page passes no level, nothing is injected and the blocks keep rendering divs, so a caller that has not been updated is unaffected. The opening block is found by scanning the block list for the first entry with a heading title, rather than by watching which block renders first. Hugo chooses when a page's content is rendered, so render order is not a reliable signal. Forward the level through the twelve wrappers that render a section title, plus the hero and preview partials, and declare it in each component's schema. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review notesVerified against hinode v3.19.0 by building hinode's example site with this branch resolved through a workspace What holds up
|
The lead scan and level injection consult the component schema, so blocks whose component cannot render a leveled title (separator, releases) are passed over instead of failing validation. Blocks that hide themselves when their section is empty or that suppress their own heading no longer take the opening slot. The about, cta and featured wrappers forward the heading level through their hero and contact partials, the contact-form illustration branch does the same, and every wrapper reads an explicit level of 0 by key presence instead of relying on 0 being falsy. The example site builds against hinode v3.19.1, which understands section title heading levels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed in a8b1e3e. A — contact-form with an illustration. Fixed. The illustration branch now forwards B — a lead block that declines to render. Fixed in the scan, per your suggestion. A block that hides itself when its section is empty cannot promise it will render, so the scan passes over any block carrying C — a lead block overriding its own level. Split in two. A block that sets The 0-falls-through lookup. Fixed at every call site. The wrappers now test for the key's presence instead of relying on What the dependency bump surfaced. This module's own example site needed hinode v3.19.1 to build at all, and that build exposed five components the hinode example site never exercises. Three were plain omissions of the same kind as A: Verification. Rebuilt hinode's example site against this branch through a workspace
The minified build of hinode's example site is byte-identical to the previous revision on every page except the three component documentation pages whose argument tables gained the Pinning these scenarios. Nothing in this module can host such a test today, as you note. The example-site fixture discussed on gethinode/hinode#2116 is the viable home; deferred to land alongside the dependency bump. |
|
Rebuilt hinode's example site against Re-ran the four review scenarios plus three more. Six of seven match your description:
A, B and the zero case are genuinely fixed. The
|
|
Correction on the
What does still hold is narrower: The hoist-the-lookup nit is unaffected and independent of which API ends up being used. Everything else in my previous comment stands, in particular the |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed in e6d6058. The over-broad
The double sidecar resolution. The components map is hoisted above both loops; each loop now resolves only the per-block sidecar from it. The silent schema gate. Recorded as a constraint in the repo's authoring notes (CLAUDE.md): a component that renders a section title must declare Verification. Three throwaway pages in this module's example site, built against hinode v3.19.1:
With the fixtures removed, the minified example-site build is byte-identical to a8b1e3e, and the sweep reports every demo page with exactly one |
|
Rebuilt against
The shape I reported is closed. One residue, same class, narrowerThe ranking resolves Output is I don't think this should block. Picking the right one statically means knowing which sections are empty at scan time, which is the wrapper-logic duplication you already ruled out, and I agree with that call. It reads as one more line in the tradeoff note you added — the fallback picks the first For a genuinely empty sole block ( Everything else from my earlier comments is resolved: the hoisted components map, the schema-gate note in the authoring docs, and |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Documented and tracked, per both routes. The sentence. 39dac89 adds the two- The follow-up. gethinode/hinode#2121 (mod-blocks has issues disabled). It records one direction that avoids the wrapper-logic duplication we both ruled out: all three The commit is comment-only; the build is unchanged. |
Resolves the exampleSite module conflict by taking main's hinode v3.19.2 (v3.19.1 on this branch); the patch is sidebar-only and does not touch section titles or blocks. Both files are now byte-identical to main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Stepping back from the behaviour, which is now in good shape, to where How the three layers resolve
then per key So Two conventions worth noting, both checked rather than assumed:
Where this lands today
It also put schema where there was none. On A block renders a heading or a On keeping the field out of the CMSYour reason for skipping the blueprints was to avoid an empty numeric field on every component in the visual editor. That is solvable in the consuming site rather than by omitting the declaration: Prep work, already open
Verified end to end through a workspace Suggested sequencing
Happy to do step 3 as a patch against your branch once the first two land, so it is not extra work for you — nothing here needs re-doing, it is the same design declared one layer down. The CMS-field question is the one place your judgement should override mine if you think |
heading-level was defined inline in seventeen component sidecars and three partial structures. Common arguments belong in mod-utils _arguments.yml, where use-title -- the boolean it replaces -- has always lived; consumers reference them by bare key. - sidecars drop the definition; several return to comment + example, which is all they carried before this branch - the three partial structures keep a release-only override, the shape 98 arguments across hinode's structures already use - each consuming component declares heading_level in its blueprint, which is what states a component accepts an argument - the lead scan and the injection read the blueprint instead of the sidecar, so rendering no longer depends on the docs layer mod-llm reads - bump mod-utils to v6.9.0 for the definitions, hinode to v3.19.3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both prerequisites are released — mod-utils v6.9.0 and hinode v3.19.3 — so I pushed the declaration change to this branch as What changed
Net: 43 insertions, 188 deletions. On the CMS fieldThis is the part where your judgement should win if you disagree. Declaring Verification
|
|
🎉 This PR is included in version 2.4.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
|
Thanks @markdumay, this is a great accessibility win! |
Companion to gethinode/hinode — "give content blocks a heading level", which is phase 2 of
gethinode/hinode#1519. That pull request teaches the shared section title partial to render a real
heading; this one makes the blocks ask for it. Neither changes what a page looks like on its own.
What is wrong today
A page assembled from content blocks has no headings at all — not even an
h1. This module emitsno
<h1>through<h6>anywhere. Twelve Bookshop wrappers render a section title, plus the heroand preview partials, and every one of them produces a
<div>.The hero partial did forward hinode's old
use-titleboolean, but the hero wrapper never passedone in, so it was always off. And a boolean could only ever have produced an
h1, which is notuseful on a page that stacks a dozen titled blocks.
The change
page/blocks.htmltakes aheading-levelargument from the page template that renders it. Whenthe page supplies one, the block that opens the page receives that level and every block after it
becomes a subsection. A block that sets
heading_levelin its own front matter always wins.When the page passes no level — which is what happens with a version of hinode that predates the
companion change — nothing is injected and every block keeps rendering a
<div>, exactly asbefore.
The opening block is found by scanning the block list, rather than by watching which block
renders first. Hugo chooses when a page's content is rendered, so render order is not a reliable
signal. The scan takes the first entry that has a heading title and can be relied on to show it.
A block that suppresses its own heading with a level of
0is passed over, and so is one whosecomponent does not declare a
heading-levelargument in its schema. A block that hides itselfwhen its section is empty (
hide_empty) ranks last: a later titled block wins over it, and itopens the page only when no other candidate exists. When such a block does render ahead of the
chosen opener, its title shows as a subsection above the page's
h1— knowing at scan timewhether it will render would mean duplicating each wrapper's page-selection logic. For the same
reason, when every titled block hides itself the first one is chosen even if it turns out empty,
and the page has no
h1when a later one renders; gethinode/hinode#2121 tracks a render-awarescan for that case. A block that
pins a non-zero level in its front matter keeps it, and takes the opening slot with it.
Each wrapper forwards the level to the section title partial, and
heading-levelis declared inevery affected component's schema so it validates and shows up in the generated argument tables.
Two components stay out:
separatorrenders no heading at all, andreleasesrenders its titlethrough hinode's timeline partial, which does not take a heading level yet. The schema check
keeps the injected level away from both.
Files
layouts/partials/page/blocks.html— works out the opening block and injects the levelabout,approach,articles,cards,contact-form,cta,faq,featured,heading,list,menu,panels,team,testimonials,video-messagelayouts/partials/assets/hero.html,assets/preview.htmlandassets/contact.html, plustheir wrappers and the three matching structure files
CLAUDE.md— records the sidecar schema contract for heading injectionVerification
Built against the companion hinode branch through a Hugo workspace, using hinode's example site,
which imports this module.
Before the two changes, 7 of 111 pages had no
h1— the home page in three languages, the teampage in two, and the two documentation landing pages. After, every page has exactly one
h1, nopage has two, and no page skips a level. Comparing heading outlines page by page, exactly those 7
changed and the other 104 are identical.
Rendered geometry was measured through Claude in Chrome at a matched 1280×900 viewport, recording
position, size, font size, weight, line height and margins for every text-bearing element inside
<main>. Across the nine pages that could change, zero elements differ and every page reports anidentical scroll height.
The example site builds with no errors and no argument warnings.
After the review fixes, the same comparison holds: the minified build of hinode's example site is
byte-identical on every page except the three component documentation pages whose argument tables
gained a row, plus the search index that carries their text. Throwaway pages covered the review
scenarios: a contact-form with an illustration opening a page renders its hero title as
h1; anempty hidden articles block passes the
h1to the next titled block; a block that suppresses itsheading does the same while keeping its styled
div; a block that pins its own level keeps it,leaving the page without an
h1by the author's choice. This module's own example site nowbuilds cleanly as well: every demo page carries exactly one
h1, except the releases demo, whosetitle stays a
divuntil the timeline partial learns heading levels.The second review round changed the lead scan from skipping
hide_emptyblocks to ranking themlast. Three throwaway pages verified the ranking: a page whose only block is a
hide_emptyarticle list renders its title as
h1; an emptyhide_emptyblock ahead of a titled blockrenders nothing while the
h1falls through to the next block; ahide_emptyblock with contentrenders as a subsection above the next block's
h1. With the fixtures removed, the example sitebuilds byte-identical to the previous revision.
Note on the schemas
heading-levelis declared in each component sidecar rather than once in a shared place, becausethe shared argument definitions live in mod-utils. Moving it there and reducing these to bare keys
would be a worthwhile follow-up.
It is deliberately not added to the Bookshop blueprints, so the visual editor does not gain an
empty numeric field on every component for an option most authors never need to set. Pages that do
need it can set
heading_levelin front matter.