Repository navigation
feat(navigation): a11y route announcement, View Transitions, scroll restoration and shallow routing - #53
Merged
Conversation
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>
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>
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
marked this pull request as ready for review
July 31, 2026 20:01
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.document.activeElementBODYH1aria-live="assertive"with the new<title>What changed —
client/route-a11y.ts. Focus moves to the new page's<h1>(falling back tomain/[role="main"]), made focusable withtabindex="-1"and focused withpreventScroll. A visually hiddenaria-live="assertive" aria-atomic="true"region carries the new<title>, clipped out of the flow so it is neither visible nor takes space, markeddata-janux-keepso 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
persistisland 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:
screencaptureis blocked (no Screen Recording permission) and scripting VoiceOver'slast phraseneeds an Automation prompt that cannot be dismissed non-interactively. What I did verify is the layer VoiceOver reads — Chrome's AX tree (getFullAXTree): announcerlive=assertive, atomic=true, ignored=falsewith childStaticText "Quick start — Janux docs", headingfocused=true. 30-second manual check: build and startapps/docs, ⌘F5, click Quick start — expect "Quick start — Janux docs" and the VO cursor on the heading.2 — Opt-in View Transitions
navigation.viewTransitionswraps navigations in a single transition, pairing elements that share aview-transition-name. No new API:CSSProperties extendscsstype, sostyle={{ 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
requestAnimationFrameis tied to rendering, so rAF never fires inside one.diff-dom-streamingsettled 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: trueavoids 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.prefers-reduced-motion: reduceapps/docs(not configured)Tests assert
readyresolved, 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-newsis 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:
What changed —
client/scroll.ts. The router takesscroll: '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,#anchorlinks 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.
The URL and the history entry move; nothing is fetched and nothing re-renders — measured as 0
janux:navigateevents with a sentinel onwindowsurviving, against the same change withoutdata-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
urlStatewas missing:history.pushStatefires 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.
@slotand(.)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.mdgains 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 onmain— I verified it by checking out cleanorigin/mainin a scratch worktree and reproducing it there. It is a@mcp-b/webmcp-polyfill× happy-domEventincompatibility that only bites locally on macOS.No
examples/workarounds; the four design invariants are untouched.🤖 Generated with Claude Code