Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<main id="main-content">` landmark placed after page navigation.

---

Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,7 +18,12 @@ export default function App() {
const [route, setRoute] = useState<RouteState>(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);
}, []);
Expand All @@ -32,6 +38,7 @@ export default function App() {
return (
<TimeProvider>
<ToastProvider>
<SkipLink />
{route.page === "landing" ? (
<LandingPage
onDemo={() => navigate("demo", "auction")}
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/components/SkipLink.tsx
Original file line number Diff line number Diff line change
@@ -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<MouseEvent<HTMLAnchorElement>, "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 (
<a
href={`#${targetId}`}
className="skip-link"
onClick={(event) => {
skipToMainContent(event);
}}
>
{label}
</a>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/components/dashboard/RoundStatusCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

12 changes: 7 additions & 5 deletions apps/web/src/pages/ArchitecturePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { LOGO_SRC } from "../lib/chain";

export function ArchitecturePage({ goHome }: { goHome: () => void }) {
return (
<main className="architecture-page">
<div className="architecture-page">
<nav className="architecture-nav">
<button type="button" className="brand-link" onClick={goHome}>
<img src={LOGO_SRC} alt="" />
Expand All @@ -17,9 +17,11 @@ export function ArchitecturePage({ goHome }: { goHome: () => void }) {
</div>
</nav>

<article className="architecture-doc" aria-label="Sub Rosa architecture">
<pre>{architectureMd}</pre>
</article>
</main>
<main id="main-content" tabIndex={-1}>
<article className="architecture-doc" aria-label="Sub Rosa architecture">
<pre>{architectureMd}</pre>
</article>
</main>
</div>
);
}
6 changes: 4 additions & 2 deletions apps/web/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<main className="dashboard-page">
<div className="dashboard-page">
<motion.nav
className="dashboard-nav"
initial={{ opacity: 0, y: -16 }}
Expand All @@ -84,6 +84,7 @@ export function DashboardPage({ goHome }: { goHome: () => void }) {
</div>
</motion.nav>

<main id="main-content" tabIndex={-1}>
<motion.header
className="dashboard-header"
initial={{ opacity: 0, y: 12 }}
Expand Down Expand Up @@ -119,6 +120,7 @@ export function DashboardPage({ goHome }: { goHome: () => void }) {
</div>
</motion.div>
)}
</main>
</main>
</div>
);
}
48 changes: 25 additions & 23 deletions apps/web/src/pages/DemoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ export function DemoPage({
: { mode: "idle" as const, targetRound: null };

return (
<main className="app-page">
<div className="app-page">
<ConfettiBurst fire={confettiTick} />
<section className="app-shell">
<aside className="case-nav">
Expand Down Expand Up @@ -905,28 +905,30 @@ export function DemoPage({
<DrandCountdownChip mode={sidebarDrand.mode} targetRound={sidebarDrand.targetRound} />
</aside>

<AnimatePresence mode="wait">
<motion.section
key={`${mode}-${active.id}`}
className="case-workspace"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
>
{mode === "live" ? (
<LivePanel
active={active}
session={session}
onCelebrate={() => setConfettiTick((t) => t + 1)}
traceHealthy={traceHealth.ok}
/>
) : (
<EvidencePanel errorCode={traceHealth.ok ? undefined : traceHealth.errorCode} />
)}
</motion.section>
</AnimatePresence>
<main id="main-content" className="case-main" tabIndex={-1}>
<AnimatePresence mode="wait">
<motion.section
key={`${mode}-${active.id}`}
className="case-workspace"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
>
{mode === "live" ? (
<LivePanel
active={active}
session={session}
onCelebrate={() => setConfettiTick((t) => t + 1)}
traceHealthy={traceHealth.ok}
/>
) : (
<EvidencePanel errorCode={traceHealth.ok ? undefined : traceHealth.errorCode} />
)}
</motion.section>
</AnimatePresence>
</main>
</section>
</main>
</div>
);
}
6 changes: 4 additions & 2 deletions apps/web/src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function LandingPage({
: { duration: 0.7, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] };

return (
<main className="landing-page">
<div className="landing-page">
<motion.nav
className="landing-nav"
initial={{ opacity: 0, y: -16 }}
Expand All @@ -60,6 +60,7 @@ export function LandingPage({
</div>
</motion.nav>

<main id="main-content" tabIndex={-1}>
<motion.section
className="landing-hero"
variants={staggerParent}
Expand Down Expand Up @@ -243,6 +244,7 @@ export function LandingPage({
))}
</motion.div>
</section>
</main>
</main>
</div>
);
}
122 changes: 122 additions & 0 deletions apps/web/src/skip-link-landmarks.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SkipLink />);

assert.equal(html, '<a href="#main-content" class="skip-link">Skip to main content</a>');
});

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, /<ToastProvider>\s*<SkipLink\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: /<motion\.nav\b/, outerClass: "landing-page" },
{ file: "pages/DashboardPage.tsx", navPattern: /<motion\.nav\b/, outerClass: "dashboard-page" },
{ file: "pages/ArchitecturePage.tsx", navPattern: /<nav\b/, outerClass: "architecture-page" },
{ file: "pages/DemoPage.tsx", navPattern: /<aside\s+className="case-nav">/, outerClass: "app-page" },
];

for (const { file, navPattern, outerClass } of pages) {
const source = readWebSource(file);
const openMains = source.match(/<main\b/g) ?? [];
const closeMains = source.match(/<\/main>/g) ?? [];
assert.equal(openMains.length, 1, `${file} must have exactly one opening <main> tag`);
assert.equal(closeMains.length, 1, `${file} must have exactly one closing </main> tag`);
assert.match(
source,
new RegExp(`<div className="${outerClass}">`),
`${file} outer wrapper must be a <div> rather than a duplicate <main>`,
);
assert.match(
source,
/<main\s+id="main-content"[^>]*tabIndex=\{-1\}>/,
`${file} must expose <main id="main-content" tabIndex={-1}>`,
);

const navMatch = navPattern.exec(source);
const mainIndex = source.indexOf('<main id="main-content"');
assert.ok(navMatch && mainIndex > navMatch.index, `${file} must place navigation before <main>`);
}
});

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/);
});
37 changes: 37 additions & 0 deletions apps/web/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down