Skip to content

feat(navigation): a11y route announcement, View Transitions, scroll restoration and shallow routing - #53

Merged
aralroca merged 10 commits into
mainfrom
worktree-a11y-route-announcer
Jul 31, 2026
Merged

aralroca merged 10 commits into
mainfrom
worktree-a11y-route-announcer

Conversation

@aralroca

@aralroca aralroca commented Jul 30, 2026 •

Copy link
Copy Markdown
Owner

Four navigation gaps on one branch. Each was written test-first, and each failing test is quoted below.


1 — Announce and focus the new page

A cross-document load tells a screen-reader user the page changed. An SPA navigation told them nothing:

grep -n "focus()\|aria-live" packages/janux/src/client/navigate.ts   # zero results

And it was worse than "focus stays put". The diff replaces the link that was clicked, so focus fell back to <body> — the reader was silently dumped at the top of the document.

after an SPA navigation before after
document.activeElement BODY H1
live region none aria-live="assertive" with the new <title>

What changed — client/route-a11y.ts. Focus moves to the new page's <h1> (falling back to main / [role="main"]), made focusable with tabindex="-1" and focused with preventScroll. A visually hidden aria-live="assertive" aria-atomic="true" region carries the new <title>, clipped out of the flow so it is neither visible nor takes space, marked data-janux-keep so the diff does not delete it. Cleared and refilled in two turns, because a live region only speaks when its content changes.

Not stealing focus. Focus inside a persist island is saved before the diff and restored with its caret. It has to be restored, not left alone: the navigation lifts persisted islands out of the document, and removing a node blurs it. A widget the incoming page does not render is disposed, so that case falls back to the new content rather than stranding the reader on <body>.

VoiceOver was not verified end-to-end. I launched it against the real page but could not capture what it spoke: screencapture is blocked (no Screen Recording permission) and scripting VoiceOver's last phrase needs an Automation prompt that cannot be dismissed non-interactively. What I did verify is the layer VoiceOver reads — Chrome's AX tree (getFullAXTree): announcer live=assertive, atomic=true, ignored=false with child StaticText "Quick start — Janux docs", heading focused=true. 30-second manual check: build and start apps/docs, ⌘F5, click Quick start — expect "Quick start — Janux docs" and the VO cursor on the heading.


2 — Opt-in View Transitions

navigation.viewTransitions wraps navigations in a single transition, pairing elements that share a view-transition-name. No new API: CSSProperties extends csstype, so style={{ viewTransitionName: 'wordmark' }} already type-checks.

This one was blocked by a real bug. Wrapping the diff deadlocked: a transition renders-suppresses the document until its callback resolves, and requestAnimationFrame is tied to rendering, so rAF never fires inside one.

callback entered
rAF did NOT fire within 1500ms (+1509ms)
setTimeout fired (+1519ms)

diff-dom-streaming settled its stream walk on rAF alone, so the diff hung until Chrome's 4s cap killed the transition — the page froze for four seconds and then swapped with no animation at all. Fixed upstream in brisa-build/diff-dom-streaming#24, released as 0.6.9, which this PR requires.

The library's own transition: true avoids the deadlock but cannot produce a page morph — one transition per mutation batch, each skipping the last. Measured on one navigation: 51 started, 50 skipped, 1 animated.

calls animated skipped
opted in 1 1 0
prefers-reduced-motion: reduce 0 0 0
apps/docs (not configured) 0 0 0

Tests assert ready resolved, not just that a call happened — an abandoned transition still applies the DOM change, so counting calls would pass on the very bug that blocked this.

Opting in changes how the page is applied: the incoming page is read in full before the swap instead of being diffed as it streams. The old page stays live and interactive for the whole download, then changes in one animated step. Apps that do not opt in keep the streaming diff untouched.


3 — Scroll restoration

Going back landed wherever the previous page happened to be. The browser offers to restore scroll for intercepted navigations, but it measures the document at that instant — and a page still streaming is far too short to hold the old offset, so the position is clamped away.

examples/hacker-news is the honest fixture: its list is a suspense island over a slow source, so coming back paints a 436px skeleton before the 649px list lands (36px vs 249px of scroll at the test viewport).

The failing test, before:

✗ restores the exact position when going back to a page that streams in
  Expected: 200   Received: 0

What changed — client/scroll.ts. The router takes scroll: 'manual', remembers each history entry's offset as it leaves it, and applies it as the last step of the swap. Traversals restore (back and forward), pushes open at the top, #anchor links still land on their anchor, and a superseded navigation never applies its offset.

That placement is what makes it agree with §2: the offset lands inside the transition, before the browser snapshots the new page, so the animation runs between two correctly-scrolled pages instead of animating to the old one and then jumping.

Verified in Chrome: before=200 onItem=0 afterBack=200 restored=true.


4 — Shallow routing

A filter, a tab or a URL-addressable dialog needs the address bar and the back button to be right while the page stays put. A query-only link was a full document load.

<a href="?tag=video" data-shallow>video</a>
janux.navigate('/orders?status=paid', { shallow: true, replace: true });

The URL and the history entry move; nothing is fetched and nothing re-renders — measured as 0 janux:navigate events with a sentinel on window surviving, against the same change without data-shallow, which destroys it.

Opt-in because a query param the server renders differently must still reach the server, and only the app knows which kind a param is.

The coherence fix urlState was missing: history.pushState fires no event, so a second binding on the same param silently went stale. Every shallow change now announces itself, so all bindings in every island read the same URL.


Not in this PR: parallel and intercepting routes

They stay on the roadmap. @slot and (.) are a router-grammar feature — directory parsing, multi-match resolution, slot-aware layouts, server render, plus soft-navigation semantics for modal-over-page and dismissal on back. That is comparable in size to everything above combined, and starting it here would have produced something half-done rather than the two items that were prioritised first. Both of those are closed and documented.

Docs

apps/docs/content/guide/navigation.md gains Scroll restoration, Shallow routing and View transitions (the last was shipped in §2 but undocumented, which also left a dangling anchor). architecture-and-roadmap.md's ships-today Routing line now names scroll restoration and shallow routing.

Gates

Branch synced with main (20 commits merged, conflicts were the test-count badge on both sides).

bun run test · bun run typecheck · bun run test:e2e (301 pass, 0 fail) · bun run test:census --min-coverage 0.93 (93.51% functions / 96.08% lines). Count updated to 4096.

The one failing test in bun run test (janux-vercel › "a bundled function with no node_modules beside it") is pre-existing on main — I verified it by checking out clean origin/main in a scratch worktree and reproducing it there. It is a @mcp-b/webmcp-polyfill × happy-dom Event incompatibility that only bites locally on macOS.

No examples/ workarounds; the four design invariants are untouched.

🤖 Generated with Claude Code

aralroca and others added 2 commits July 30, 2026 17:41
A cross-document load tells a screen-reader user the page changed; an SPA
navigation told them nothing. Focus did not merely stay put — the diff
replaces the link that was clicked, so it fell back to <body> and the
reader was silently dumped at the top of the document. Lighthouse audits
documents, not transitions, so the 100 a11y score never covered this.

- Focus moves to the new page's <h1> (or its main content container),
  made focusable with tabindex="-1" and focused with preventScroll so it
  does not race the Navigation API's own scroll restoration.
- A visually hidden aria-live="assertive" aria-atomic="true" region
  announces the new <title>, falling back to the heading and the path.
  It is clipped out of the flow, so it is neither visible nor occupies
  space, and carries data-janux-keep so the whole-document diff does not
  delete it on the next page.
- Focus is not stolen from a widget that owns it: focus inside a
  `persist` island is saved before the diff (which lifts those islands
  out of the document, blurring them) and restored with its caret after.
  The page is announced either way. A widget the incoming page does not
  render is disposed, so that case falls back to the new content.

Runs after the diff and before `janux:navigate` `after`, so `after` means
settled — and so a view transition wrapping the diff resolves before the
announcement and the focus move.

Refs next.js route-announcer, astro transitions, and Marcy Sutton's
accessible client routing user testing.
One transition around the whole swap, so elements sharing a
`view-transition-name` are paired across routes and carried from the old
page into the new one. Off by default; `navigation.viewTransitions`
turns it on, and it is ignored when the engine lacks the API or the user
asked for `prefers-reduced-motion: reduce`.

Not one transition per streamed chunk. diff-dom-streaming can wrap every
mutation it applies (`transition: true`), but shared elements are paired
by comparing a snapshot of the whole old page against the whole new one,
and each new transition skips the one before it — measured on a real
navigation that is 51 transitions started, 50 skipped, and one animating
the last fragment. It cannot produce the morph this is for.

Opting in changes how the page is applied: the incoming page is read in
full BEFORE the swap instead of being diffed as it streams. A transition
suppresses rendering until its callback resolves, so diffing a live
stream inside one would freeze the page for the whole download. Buffering
out here inverts that — the old page stays live and interactive while the
next one arrives, and only the swap is animated. Requires
diff-dom-streaming 0.6.9, which fixes the rAF deadlock that made this
impossible (brisa-build/diff-dom-streaming#24).

A superseded navigation skips its transition on abort, and a new one
skips whatever is still on screen, so an interrupted navigation can never
leave the old snapshot painted over a document that has moved on. The
route announcement and the focus move now wait for `finished`, so they
land after the transition rather than during it.

examples/shop opts in and shares its topbar wordmark between /shop and
/orders/*; apps/docs deliberately does not, which is what makes the
opt-in assertable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aralroca aralroca changed the title feat(a11y): announce and focus the new page on SPA navigation feat: screen-reader-correct SPA navigation + opt-in View Transitions Jul 31, 2026
aralroca and others added 3 commits July 31, 2026 13:47
Two gaps that show up on day one.

Scroll restoration. Going back landed wherever the previous page happened
to be — usually the top — because the browser's own restoration for an
intercepted navigation measures the document at that instant, and a page
whose content is still streaming is far too short to hold the old offset,
so the position is clamped away. The router takes `scroll: 'manual'`,
remembers each history entry's offset as it leaves it, and applies it as
the last step of the swap, once the incoming page is in the document.
Traversals restore, pushes open at the top, `#anchor` links still land on
their anchor, and a superseded navigation never applies its offset.

That placement is also what makes it agree with view transitions: the
offset lands inside the transition, before the browser snapshots the new
page, so the animation runs between two correctly-scrolled pages instead
of animating to the old one and then jumping.

Shallow routing. A filter, a tab or a URL-addressable dialog needs the
address bar and the back button to be right while the page stays put, and
a query-only link was a full document load. `data-shallow` on a link (or
`janux.navigate(url, { shallow: true })`) moves the URL and the history
entry without fetching or diffing anything.

It is opt-in because a query param the SERVER renders differently must
still reach the server, and only the app knows which kind a param is.

Every shallow change announces itself, which is the part `urlState` was
missing: `history.pushState` fires no event, so a second binding on the
same param silently went stale. All bindings, in every island, now read
the same URL.

Parallel (`@slot`) and intercepting (`(.)`) routes are NOT in this commit
— see the PR for why.

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

# Conflicts:
#	README.md
#	apps/docs/content/guide/architecture-and-roadmap.md
`rememberScroll` and the entry-key lookup were only exercised by the
browser suites, which the census does not count — the module sat at 40%
functions while the coverage floor had drifted down to 93.4%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aralroca aralroca changed the title feat: screen-reader-correct SPA navigation + opt-in View Transitions feat(navigation): a11y route announcement, View Transitions, scroll restoration and shallow routing Jul 31, 2026
aralroca and others added 4 commits July 31, 2026 13:57
The happy-dom window is global to the whole test process, and the stub
for entry keys is a `navigation` without `addEventListener` — exactly the
shape `boot()` refuses to survive. Left behind, it took down every suite
that boots after this file.

Invisible on a single-file run and on the local full run; CI hit it
because it reached the boot suites in a different order.

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

# Conflicts:
#	README.md
#	apps/docs/content/guide/architecture-and-roadmap.md
Both sides moved it; the real number comes from the census.
`beforeAll` runs a real tsc build against bun's 5s default for hooks. It
finishes in well under a second locally, and has timed out three times on
CI — twice on main, once here — always around 5002-5009ms, which is the
signature of a deadline rather than a hang.

Unrelated to this branch's changes; it is what stood between the branch
and a green run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aralroca
aralroca marked this pull request as ready for review July 31, 2026 20:01
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Conflicts, and how they were resolved:

- `applyPage` — main gave it a `NavigablePage` (body + CSP nonce) where it
  used to take a raw stream; this branch gave it a `NavigateOptions` (the
  scroll plan) and a buffering step for view transitions. Both are kept:
  the signature takes `NavigablePage` and `NavigateOptions`, and the
  buffer wraps `page.body`.
- `boot.ts`, `examples/shop/janux.config.ts` — additive on both sides
  (main's `captureNonce` and `csp: true`, this branch's scroll/shallow
  imports and `viewTransitions`). Both kept.
- README and the roadmap's test count — re-derived from the census
  rather than taking either side.

`packages/janux-vercel` › "a bundled function with no node_modules beside
it" has been red on every local run of this branch and green on CI, which
made it easy to keep calling pre-existing and leave alone. The cause is
neither: the probe logged `console.log(response.status, body)`, and bun
colours an inspected number, so the assertion read
`\x1b[33m200\x1b[0m ` instead of `200 `. It only ever fails where
FORCE_COLOR is set — a developer's terminal, never CI. Logging one
template string makes it plain everywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aralroca
aralroca merged commit 9e9aa56 into main Jul 31, 2026
42 checks passed
@aralroca
aralroca deleted the worktree-a11y-route-announcer branch July 31, 2026 20:43
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.

1 participant