Skip to content

Fix typedoc signature heading levels: uniform sibling sections for signature parts#318

Merged
NullVoxPopuli merged 5 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:fix-typedoc-heading-levels
Jul 13, 2026
Merged

Fix typedoc signature heading levels: uniform sibling sections for signature parts#318
NullVoxPopuli merged 5 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:fix-typedoc-heading-levels

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Jul 13, 2026

Copy link
Copy Markdown

Problem

The Element / Arguments / Blocks / Return headings in rendered signatures could violate axe's heading-order audit ("Heading levels should only increase by one") on consuming docs pages — concretely, nvp.ui's /Docs/3-components/theme-toggle fails with axe-core >= 4.12 (pulled in by no-lockfile CI installs): its signature has only an Element, whose heading rendered as an <h1> mid-page, so the markdown's ### State Attributes that follows was a 2-level jump. See NullVoxPopuli/nvp.ui#99 and its review discussion.

Root cause (two layers)

  1. Detection (fixed upstream, pulled in here): which-heading-do-i-need treated a nested heading-less <section> as a dead-end boundary and pinned any heading inside it to <h1>, and its fallback traversal leaked headings out of nested sectioning content. Both fixed in which-heading-do-i-need: nested sections are not heading context; heading-less boundaries are transparent ember-primitives#776, released as ember-primitives 0.60.1 (which-heading-do-i-need 0.4.1) — this PR bumps to ^0.60.1.
  2. Markup (fixed here): the signature parts were structured inconsistently — Element, Blocks, and Return each wrapped in a <section>, but Arguments was not. A heading placed directly in <Load>'s section is level-context for the sibling <section>s after it: per the sectioning rules, a <section> following a sibling <h3> is a subsection of it, so even with fixed detection the parts render Element h3 / Arguments h3 / Blocks h4.

Fix

No level numbers; sectioning rules only. Every signature part (Element, Arguments, Blocks, Return) is now a uniform sibling <section> containing its heading and its content. Sibling sections resolve to equal levels — one below whatever heading precedes the api-docs block:

## API Reference        <- authored in markdown
  Element               <- h3
  Arguments             <- h3
  Blocks                <- h3
### State Attributes    <- authored in markdown

Because each part's content lives inside its section, headings authored in a declaration's markdown comments stay contained instead of becoming level-context for the parts that follow (Element's comment moved inside its section for that reason).

Verification

  • rendering test: an <h2> before a ComponentSignature yields three h3.typedoc__headings (Element, Arguments, Blocks) and no h1/h2/h4
  • rendering test: a signature whose Element/Blocks comments contain markdown headings (#### …) renders them contained in their sections — the three signature headings stay h3, and no h5 appears (no drift)
  • full kolay suite green: pnpm turbo test:browser (6/6 tasks, docs-app 30 pass / 5 skip / 0 fail), pnpm turbo test:node (75/75), pnpm lint (17/17 tasks: js, types, hbs, prettier, publint)
  • end-to-end against nvp.ui with floating deps (axe-core 4.12.1): packed this branch, overrode kolay there, restored its original ### State Attributes markdown — full suite 83/83 (previously failing heading-order on theme-toggle)

🤖 Generated with Claude Code

@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the universal-ember Team on Vercel.

A member of the Team first needs to authorize it.

NullVoxPopuli-ai-agent pushed a commit to NullVoxPopuli-ai-agent/nvp.ui that referenced this pull request Jul 13, 2026
- Revert the theme-toggle heading bump: State Attributes / Styling are
  back to h3, under API Reference. The real cause of the heading-order
  violation (kolay rendering the signature's "Element" heading as an
  h1) is fixed upstream in universal-ember/kolay#318, which makes all
  signature headings render one level below the preceding document
  heading (h3 under an "API Reference" h2).
- Update the comment above the (now disabled) typescript@rc scenario:
  "allowed to fail via continue-on-error" no longer applies since the
  scenario was commented out; still tracked in issue NullVoxPopuli#98.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@NullVoxPopuli NullVoxPopuli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use hard-coded level numbers. Use the sectioning rules alone, and the automatic heading will be figured out.

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Render typedoc signature headings one level below the preceding document heading Fix typedoc signature heading levels: don't nest headings in empty sections Jul 13, 2026
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Done in 3bf9acb — all level numbers (HeadingScope / SectionHeading / @level threading, and the ember-element-helper dep) are gone. The actual bug was markup: Element / Blocks / Return each nested their <Heading> inside an inner heading-less <section> within <Load>'s section, and a nested empty section is its own boundary — detection finds nothing between the two boundaries and pins the heading to <h1>. Removing the inner wrappers (Return keeps its styling hook as a div.typedoc__helper__return) lets the sectioning rules resolve everything: ## API Referenceh3 Element on nvp.ui's theme-toggle page, with its ### State Attributes markdown unchanged. Net diff is two removed <section>s and one <section><div>.

One observation from verifying, for your radar rather than this PR: through non-boundary wrapper layers (like the repl demo divs), the detection's fallback subtree traversal resolves subsequent sibling headings one level below the first (timeline renders h3 Timeline → h4 Element, h5 Arguments, h5 Blocks), because the fallback recurses into boundary children that the previous-sibling scan deliberately skips (findHeadingIn's for (const child of node.children) doesn't check BOUNDARY_ELEMENTS, while the docstring says the search should stop at other boundary elements). Every step still increases by at most one, so heading-order passes everywhere — but if which-heading-do-i-need's fallback skipped boundary children like the sibling scan does, siblings would come out at equal levels.

Verification: kolay suite green (browser 29/29 incl. a new regression test — <h2> before a signature yields three h3.typedoc__headings and no h1 — plus node 75/75, lint); nvp.ui floating-deps repro (axe-core 4.12.1, original h3 markdown) 83/83, with theme-toggle and timeline hierarchies checked in-browser for zero >1 increases.

</section>
{{!
No <section> around this (or any sibling) heading:
a nested heading-less <section> is its own sectioning boundary, which

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that sounds like a bug, rather than something to avoid

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — fixed at the source: universal-ember/ember-primitives#776. Two bugs in getSectionHeadingLevel, both contradicting its own docstring ("stopping if we encounter other boundary elements"):

  1. findHeadingIn's fallback child-traversal recursed into nested sectioning content, so headings inside a sibling/nested <section> leaked out as context — that's what made sibling headings resolve one-deeper and union variants cascade h3→h4→h5. The fallback now skips section/article/aside/nav, consistent with the previous-sibling scan. (header/footer/main are still traversed — a <header> contains its section's heading; a blanket boundary skip fails 4 of the existing "wrapped headings" rendering tests.)
  2. levelOf returned 1 when the walk reached a boundary that had no heading of its own — that's what pinned headings inside heading-less wrapper sections to <h1>. Such boundaries are now transparent: the walk keeps searching outward.

With that fix, this PR needs no code change — the headings here just sit in <Load>'s section and all resolve to the same level, one below whatever heading precedes the block. I reworded these comments in cc92e97 so they state intent rather than describe the (now-fixed) quirk.

Verified the full chain: ember-primitives suite green with the fix (388 pass, incl. all existing wrapped-headings/extraneous-sections/shadow cases + 2 new unit tests); kolay suite green with the fixed package injected (regression test unchanged — it never encoded the buggy levels); nvp.ui floating-deps repro with both packed fixes: 83/83 under axe-core 4.12.1, timeline now renders h3 Timeline → h4 Element / h4 Arguments / h4 Blocks with union variants at equal levels, theme-toggle h2 API Reference → h3 Element → h3 State Attributes.

Note: until ember-primitives#776 lands and a release flows through (ember-primitives pins which-heading-do-i-need exactly, currently 0.2.6), published kolay + this PR yields h3/h4/h4-style siblings through the repl wrappers — still heading-order-legal everywhere, so nothing is blocked on the ordering of the two merges.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

The Browser Tests failures on this PR are an unrelated CI race, not the heading change: @embroider/vite 1.5.2's jump-dir race guard only catches EEXIST, but parallel test-app builds renaming onto the same content-addressed /tmp/embroider-vite-jump-<hash> throw ENOTEMPTY on Linux (it hit markdown-only twice and custom-root-url once, racing for the same target hash — it fires on any PR that changes kolay's dist, since all three test apps rebuild in parallel with no turbo cache hit). Embroider already fixed the guard upstream in @embroider/vite 1.7.7+; #319 bumps the test apps' pins accordingly. Once #319 lands, a rebase/re-run here should be green — the suites themselves pass locally on this branch (browser 29/29 incl. the new regression test, node 75/75).

NullVoxPopuli pushed a commit that referenced this pull request Jul 13, 2026
…race

CI's Browser Tests job intermittently fails with

  Error: ENOTEMPTY: directory not empty,
    rename '/tmp/embroider-vite-jump-tmp-XXXXXX' -> '/tmp/embroider-vite-jump-<hash>'

whenever multiple test apps build in parallel (turbo) and resolve the
same workspace package: the jump dir is content-addressed by
pkg.root + pkg.name, so concurrent builds race to rename onto the same
target. @embroider/vite 1.5.2 only caught EEXIST from that race, but on
Linux renaming onto an existing non-empty directory throws ENOTEMPTY,
which escaped the guard and failed the build. This bites every PR that
changes kolay's dist (all three test apps rebuild in parallel; it
killed 3 consecutive CI runs on #318).

@embroider/vite 1.7.7+ handles ENOTEMPTY in the race guard, so bump the
test apps' pins to 1.7.8 (and @embroider/core to 4.6.2 to satisfy its
peer range). All test-app suites and node tests pass locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NullVoxPopuli-ai-agent and others added 3 commits July 13, 2026 11:03
…ent heading

Previously, the Element / Arguments / Blocks / Return headings in
rendered signatures used automatic per-heading level detection
(ember-primitives' <Heading> via which-heading-do-i-need). That
algorithm treats every <section> as a nesting boundary and only looks
for context between the two nearest boundaries, which breaks down for
this markup in two ways:

- <Load>'s heading-less <section> wrapper isolates the headings from
  the surrounding document, so the first heading of a signature
  renders as an <h1> in the middle of a docs page.
- sibling headings/sections influence each other via the fallback
  child-traversal, so union signature variants cascade one level
  deeper per variant (h4, h5, h6, ...) and "Arguments" would key off
  whatever heading the traversal happened to find first.

Both produce axe `heading-order` violations ("Heading levels should
only increase by one") in consuming docs pages, e.g. nvp.ui's
/Docs/3-components/theme-toggle, whose signature has only an Element:
the page's "### State Attributes" <h3> followed the rendered <h1>
directly (flagged by axe-core >= 4.12; picked up by no-lockfile CI
installs).

Instead, compute the level once per rendered signature: one level
below the nearest heading that precedes the api-docs block in document
order (crossing shadow roots). All member headings are siblings at
that same level, matching how the docs around them are authored:

  ## API Reference        <- h2 authored in markdown
    (Element h3) (Arguments h3) (Blocks h3)
  ### State Attributes    <- h3 authored in markdown

- new HeadingScope component: anchors a text node before the signature
  content and yields the computed level to everything within
- new SectionHeading component: renders an h1-h6 at the given @Level
  (adds ember-element-helper, already in the dependency graph via
  ember-primitives)
- ComponentSignature / ModifierSignature / HelperSignature thread the
  level through Element / Args / Blocks / Return

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e levels

Drops HeadingScope / SectionHeading / explicit @Level threading (and the
ember-element-helper dependency) in favor of fixing the markup that was
confusing automatic heading-level detection:

- Element and Blocks no longer wrap their <Heading> in an inner
  <section>: a nested heading-less section is its own sectioning
  boundary, which cuts detection off from the surrounding document and
  pins the first heading of every signature to <h1>. The section these
  headings belong to is the one rendered by <Load>.
- Return keeps its styling hook but as a <div class='typedoc__helper__return'>
  instead of a <section>, for the same reason.

With the headings living directly in <Load>'s section, detection
resolves them against the heading that precedes the api-docs block
(e.g. an "API Reference" <h2> yields an <h3> Element), and axe's
heading-order audit passes on consuming docs pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The behaviors the previous comments described working around are bugs in
which-heading-do-i-need, now fixed in universal-ember/ember-primitives#776
(fallback traversal no longer treats nested sectioning content as heading
context; heading-less boundaries are transparent). The comments now just
state the intent: these headings are siblings within <Load>'s <section>.

With that fix, all signature headings resolve to the same level, one
below the heading that precedes the api-docs block (verified against
nvp.ui with both fixes packed: timeline renders h3 Timeline -> h4
Element / h4 Arguments / h4 Blocks, union variants at equal levels,
83/83 with axe-core 4.12.1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent force-pushed the fix-typedoc-heading-levels branch from 4341ce5 to 44f3fb4 Compare July 13, 2026 15:05
NullVoxPopuli pushed a commit to universal-ember/ember-primitives that referenced this pull request Jul 13, 2026
…xt; empty boundaries are transparent

Fixes two ways getSectionHeadingLevel disagreed with its own documented
algorithm ("We'll need to check the subtrees between these elements,
stopping if we encounter other boundary elements"):

1. findHeadingIn's fallback child-traversal recursed into every child,
   including sectioning content. Headings inside a nested/sibling
   <section> belong to that section and are not context for ours, but
   the fallback would find them anyway. This made sibling headings
   resolve one level deeper than the first through non-boundary
   wrappers, and made sibling <section>s cascade one level per section
   (h3, h4, h5, ...) instead of staying at equal levels.

   The fallback now skips sectioning content (section, article, aside,
   nav) like the previous-sibling traversal already did. header,
   footer, and main are still traversed: a <header> in particular
   typically contains its section's heading (covered by the existing
   "wrapped headings" rendering tests).

2. levelOf returned 1 as soon as the walk reached the next boundary,
   even if that boundary had no heading of its own. A heading-less
   wrapper <section> therefore isolated everything inside it, pinning
   headings to <h1> mid-document. Such a boundary has no heading level
   to nest under, so it is now transparent: the walk keeps searching
   outward for context.

Both fixes come out of universal-ember/kolay#318, where api-docs
headings rendered inside a heading-less <section> came out as <h1>
(failing axe's heading-order audit in consuming docs pages), and
union-signature variants cascaded one level per variant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0.60.1 ships which-heading-do-i-need 0.4.1, which contains the
traversal fixes from universal-ember/ember-primitives#776 (fallback
does not treat nested sectioning content as heading context;
heading-less boundaries are transparent). Together with the markup
change in this PR, all api-docs signature headings now resolve to the
same level, one below the heading that precedes the block
(e.g. "## API Reference" -> h3 Element / h3 Arguments / h3 Blocks).

Bumped in the addon package, docs-app, and both test apps that pin it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

0093f54 bumps ember-primitives to ^0.60.1 (addon package, docs-app, and both test apps that pin it) — 0.60.1 ships which-heading-do-i-need 0.4.1 with the traversal fixes from universal-ember/ember-primitives#776, so this PR now delivers the complete heading fix (kolay markup + primitives detection) in one release.

Re-verified with the real 0.60.1 from npm (not a packed build): browser suites 6/6 tasks — docs-app 29/29 including the heading regression test, which asserts the flattened behavior (an <h2> before a ComponentSignature yields three sibling h3.typedoc__headings: Element / Arguments / Blocks — no more h3/h4/h4 stepping through wrapper layers, no union-variant cascade); node tests 75/75; lint (js, types, hbs, prettier, publint) all green. No test or docs expectation encoded the pre-fix levels.

Downstream effect once this releases: nvp.ui's floating-deps a11y jobs go green with its markdown untouched (## API Reference → h3 signature headings → ### State Attributes), which unblocks NullVoxPopuli/nvp.ui#99.


const Blocks: TOC<{ Args: { info: any } }> = <template>
{{#if @info}}
<section>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do these still need to be moved out of the sections?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's possible that declarations within here could have headers as well

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — they're back in 57ffca5, and you're right on both counts. With the ember-primitives@0.60.1 bump this PR now carries (which-heading-do-i-need 0.4.1), a heading-less boundary is transparent instead of pinning h1, so headings inside these sections resolve correctly again.

One catch, found empirically: restoring the sections exactly as they were renders Element h3 / Arguments h3 / Blocks h4 — the regression test fails with "h3.typedoc__heading exists twice". Arguments never had a <section>, so its heading sits directly in <Load>'s section, and per the sectioning rules a <section> following a sibling h3 is a subsection of it — the detection's previous-sibling scan finds the Arguments h3 and nests Blocks under it. So 57ffca5 makes the structure uniform instead: every part (Element, Arguments, Blocks, Return) is a sibling <section> containing its heading and its content, and uniform sibling sections all resolve to the same level, one below the preceding document heading.

And yes — declarations in here can carry headings, since comments are compiled markdown. That's the strongest reason to keep the sections: content contained in a part's <section> can't become level-context for the parts that follow (both the sibling scan and the fallback traversal skip sectioning content). Element's comment moved inside its section for the same reason — it used to render after </section>, directly in <Load>'s section, where a comment-authored heading would push Arguments/Blocks down a level. Covered by a new sample + test: SignatureWithCommentHeadings has #### Element notes / #### Block usage in its comments; the test asserts the three signature headings stay h3, the comment h4s render contained in their sections, and no h5 appears (no drift).

Verification on 57ffca5: browser suites 6/6 turbo tasks (docs-app 30 pass / 5 skip / 0 fail, incl. both heading tests), test:node 75/75, pnpm lint 17/17 tasks. The rendered result is unchanged from the flattened markup: h2 API Reference → h3 Element / h3 Arguments / h3 Blocks.

…orm sibling <section>

Rather than moving the headings out of the <section>s, restore them and
make the structure uniform: each signature part (Element, Arguments,
Blocks, Return) is a <section> containing its heading and its content.

- Arguments gains the <section> it never had: a heading placed directly
  in <Load>'s section is level-context for the sibling <section>s after
  it, which would nest Blocks' heading one level deeper (h4 next to h3
  siblings). As uniform sibling sections, all parts resolve to the same
  level (verified: pre-PR markup under ember-primitives 0.60.1 renders
  Element h3 / Arguments h3 / Blocks h4).
- Element's comment moves inside its <section>, so headings authored in
  a declaration's markdown comment stay contained instead of becoming
  level-context for the parts that follow.
- New sample (SignatureWithCommentHeadings) + rendering test cover
  exactly that: markdown headings in Element/Blocks comments render
  (contained in their sections) without shifting the three signature
  headings off h3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Fix typedoc signature heading levels: don't nest headings in empty sections Fix typedoc signature heading levels: uniform sibling sections for signature parts Jul 13, 2026
@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
kolay-docs-app Ready Ready Preview, Comment Jul 13, 2026 4:25pm

@NullVoxPopuli NullVoxPopuli merged commit c85de53 into universal-ember:main Jul 13, 2026
8 checks passed
@NullVoxPopuli NullVoxPopuli deleted the fix-typedoc-heading-levels branch July 13, 2026 16:34
@github-actions github-actions Bot mentioned this pull request Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants