From 0896a53a1f4aff8e5d0da52d39be301294cfa64d Mon Sep 17 00:00:00 2001 From: umpireboulianne-design Date: Sat, 24 Oct 2026 02:34:43 -0400 Subject: [PATCH] feat(web): add skip link and single main landmark across pages Fixes #548 --- ARCHITECTURE.md | 2 +- apps/web/src/App.tsx | 9 +- apps/web/src/components/SkipLink.tsx | 47 +++++++ .../dashboard/RoundStatusCard.test.tsx | 3 + apps/web/src/pages/ArchitecturePage.tsx | 12 +- apps/web/src/pages/DashboardPage.tsx | 6 +- apps/web/src/pages/DemoPage.tsx | 48 +++---- apps/web/src/pages/LandingPage.tsx | 6 +- apps/web/src/skip-link-landmarks.test.tsx | 122 ++++++++++++++++++ apps/web/src/styles/base.css | 37 ++++++ 10 files changed, 258 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/SkipLink.tsx create mode 100644 apps/web/src/skip-link-landmarks.test.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 352fce40..173cbba3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -234,7 +234,7 @@ pnpm agents:e2e Other proofs: `pnpm lifecycle:e2e`, `pnpm appraisal:e2e`, `pnpm mainnet:verify`. See README **Proof at a glance**. -The web UI is **read-mostly**: embedded trace + optional live RPC poll. No wallet connect in the jury demo. +The web UI is **read-mostly**: embedded trace + optional live RPC poll. No wallet connect in the jury demo. Each route renders a focus-visible skip link (`a.skip-link`) targeting a single `
` landmark placed after page navigation. --- diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 988caa4c..c93d7dc1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import type { UseCaseId } from "./config/useCases"; import { hashFor, routeFromHash, type RouteState } from "./config/routing"; import { ArchitecturePage } from "./pages/ArchitecturePage"; import { ConfigBanner } from "./components/ConfigBanner"; +import { SkipLink } from "./components/SkipLink"; import { gateDemoActions } from "./lib/config"; import { PUBLIC_ENV, SDK_CLIENT_IDENTITY } from "./lib/chain"; import { DashboardPage } from "./pages/DashboardPage"; @@ -17,7 +18,12 @@ export default function App() { const [route, setRoute] = useState(routeFromHash); useEffect(() => { - const onHash = () => setRoute(routeFromHash()); + const onHash = () => { + if (window.location.hash === "#main-content") { + return; + } + setRoute(routeFromHash()); + }; window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); @@ -32,6 +38,7 @@ export default function App() { return ( + {route.page === "landing" ? ( navigate("demo", "auction")} diff --git a/apps/web/src/components/SkipLink.tsx b/apps/web/src/components/SkipLink.tsx new file mode 100644 index 00000000..5dc3f150 --- /dev/null +++ b/apps/web/src/components/SkipLink.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Sub Rosa contributors +import type { MouseEvent } from "react"; + +export interface FocusableLandmark { + focus(): void; + scrollIntoView?(options?: ScrollIntoViewOptions): void; +} + +export interface LandmarkDocument { + getElementById(id: string): FocusableLandmark | null; +} + +export function skipToMainContent( + event: Pick, "preventDefault">, + doc: LandmarkDocument | undefined = typeof document !== "undefined" ? document : undefined, +): boolean { + event.preventDefault(); + const main = doc?.getElementById("main-content") ?? null; + if (!main) { + return false; + } + main.focus(); + if (typeof main.scrollIntoView === "function") { + main.scrollIntoView({ block: "start" }); + } + return true; +} + +export function SkipLink({ + targetId = "main-content", + label = "Skip to main content", +}: { + targetId?: string; + label?: string; +}) { + return ( + { + skipToMainContent(event); + }} + > + {label} + + ); +} diff --git a/apps/web/src/components/dashboard/RoundStatusCard.test.tsx b/apps/web/src/components/dashboard/RoundStatusCard.test.tsx index e10709b4..27282049 100644 --- a/apps/web/src/components/dashboard/RoundStatusCard.test.tsx +++ b/apps/web/src/components/dashboard/RoundStatusCard.test.tsx @@ -83,3 +83,6 @@ test("round status card shows the snapshot round id", () => { const html = renderCard(data); assert.match(html, /42/); }); + +import "../../skip-link-landmarks.test"; + diff --git a/apps/web/src/pages/ArchitecturePage.tsx b/apps/web/src/pages/ArchitecturePage.tsx index f9ea7270..948186c5 100644 --- a/apps/web/src/pages/ArchitecturePage.tsx +++ b/apps/web/src/pages/ArchitecturePage.tsx @@ -4,7 +4,7 @@ import { LOGO_SRC } from "../lib/chain"; export function ArchitecturePage({ goHome }: { goHome: () => void }) { return ( -
+
-
-
{architectureMd}
-
-
+
+
+
{architectureMd}
+
+
+ ); } diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 9aab42a4..31ffd71b 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -58,7 +58,7 @@ export function DashboardPage({ goHome }: { goHome: () => void }) { : { duration: 0.5, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] }; return ( -
+
void }) {
+
void }) { )} -
+
+ ); } diff --git a/apps/web/src/pages/DemoPage.tsx b/apps/web/src/pages/DemoPage.tsx index 2fc05ea9..426fe846 100644 --- a/apps/web/src/pages/DemoPage.tsx +++ b/apps/web/src/pages/DemoPage.tsx @@ -851,7 +851,7 @@ export function DemoPage({ : { mode: "idle" as const, targetRound: null }; return ( -
+
- - - {mode === "live" ? ( - setConfettiTick((t) => t + 1)} - traceHealthy={traceHealth.ok} - /> - ) : ( - - )} - - +
+ + + {mode === "live" ? ( + setConfettiTick((t) => t + 1)} + traceHealthy={traceHealth.ok} + /> + ) : ( + + )} + + +
-
+ ); } diff --git a/apps/web/src/pages/LandingPage.tsx b/apps/web/src/pages/LandingPage.tsx index 7bce571e..d014b248 100644 --- a/apps/web/src/pages/LandingPage.tsx +++ b/apps/web/src/pages/LandingPage.tsx @@ -34,7 +34,7 @@ export function LandingPage({ : { duration: 0.7, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] }; return ( -
+
+
-
+
+ ); } diff --git a/apps/web/src/skip-link-landmarks.test.tsx b/apps/web/src/skip-link-landmarks.test.tsx new file mode 100644 index 00000000..39fd964f --- /dev/null +++ b/apps/web/src/skip-link-landmarks.test.tsx @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { SkipLink, skipToMainContent } from "./components/SkipLink"; + +const SRC_DIR = dirname(fileURLToPath(import.meta.url)); + +function readWebSource(relPath: string): string { + return readFileSync(resolve(SRC_DIR, relPath), "utf8"); +} + +test("SkipLink renders an anchor targeting #main-content with class skip-link", () => { + const html = renderToStaticMarkup(); + + assert.equal(html, ''); +}); + +test("skipToMainContent prevents default navigation and moves focus/scroll to #main-content", () => { + let defaultPrevented = false; + let focused = false; + let scrollOptions: ScrollIntoViewOptions | undefined; + + const moved = skipToMainContent( + { + preventDefault() { + defaultPrevented = true; + }, + }, + { + getElementById(id: string) { + if (id !== "main-content") return null; + return { + focus() { + focused = true; + }, + scrollIntoView(options?: ScrollIntoViewOptions) { + scrollOptions = options; + }, + }; + }, + }, + ); + + assert.equal(moved, true); + assert.equal(defaultPrevented, true); + assert.equal(focused, true); + assert.deepEqual(scrollOptions, { block: "start" }); +}); + +test("skipToMainContent returns false safely when #main-content is absent", () => { + let defaultPrevented = false; + const moved = skipToMainContent( + { + preventDefault() { + defaultPrevented = true; + }, + }, + { + getElementById() { + return null; + }, + }, + ); + + assert.equal(moved, false); + assert.equal(defaultPrevented, true); +}); + +test("App shell renders SkipLink first inside the shell and guards hash routing", () => { + const appSource = readWebSource("App.tsx"); + + assert.match(appSource, /\s*/); + assert.match( + appSource, + /window\.location\.hash === "#main-content"/, + "App.tsx must ignore #main-content in the hash router listener", + ); +}); + +test("every page component exposes exactly one unambiguous main#main-content landmark after navigation", () => { + const pages: Array<{ file: string; navPattern: RegExp; outerClass: string }> = [ + { file: "pages/LandingPage.tsx", navPattern: //, outerClass: "app-page" }, + ]; + + for (const { file, navPattern, outerClass } of pages) { + const source = readWebSource(file); + const openMains = source.match(//g) ?? []; + assert.equal(openMains.length, 1, `${file} must have exactly one opening
tag`); + assert.equal(closeMains.length, 1, `${file} must have exactly one closing
tag`); + assert.match( + source, + new RegExp(`
`), + `${file} outer wrapper must be a
rather than a duplicate
`, + ); + assert.match( + source, + /]*tabIndex=\{-1\}>/, + `${file} must expose
`, + ); + + const navMatch = navPattern.exec(source); + const mainIndex = source.indexOf('
navMatch.index, `${file} must place navigation before
`); + } +}); + +test("base.css hides .skip-link until keyboard focus and reveals it on :focus/:focus-visible", () => { + const css = readWebSource("styles/base.css"); + + assert.match(css, /\.skip-link\s*\{[\s\S]*?transform:\s*translateY\(-200%\)/); + assert.match(css, /\.skip-link:focus,\s*\.skip-link:focus-visible\s*\{[\s\S]*?transform:\s*translateY\(0\)/); + assert.match(css, /\.skip-link:focus,\s*\.skip-link:focus-visible\s*\{[\s\S]*?opacity:\s*1/); +}); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 98267881..83ff1539 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -99,6 +99,43 @@ code { border-radius: 4px; } +.skip-link { + position: fixed; + top: 12px; + left: 12px; + z-index: 1000; + padding: 10px 16px; + border: 2px solid var(--green); + border-radius: 999px; + background: var(--bg-0, #050605); + color: var(--ink); + font-family: var(--display); + font-size: 0.9rem; + font-weight: 600; + box-shadow: var(--shadow-md, 0 12px 32px rgba(0, 0, 0, 0.55)); + transform: translateY(-200%); + opacity: 0; + pointer-events: none; + transition: transform 160ms ease, opacity 160ms ease; +} + +.skip-link:focus, +.skip-link:focus-visible { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + outline: 2px solid var(--green); + outline-offset: 2px; +} + +main#main-content:focus { + outline: none; +} + +.case-main { + min-width: 0; +} + @media (prefers-reduced-motion: reduce) { *, *::before,