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
21 changes: 14 additions & 7 deletions apps/web/src/components/AttackDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import type { AttackStep } from "../lib/demoTypes";
import { useToast } from "../ui/Toast";

export function StepList({ steps, variant }: { steps: AttackStep[]; variant: "bad" | "good" }) {
const listLabel = variant === "bad" ? "Seal-off attack steps" : "Seal-on protection steps";
return (
<ul className={`attack-steps ${variant}`}>
<ul className={`attack-steps ${variant}`} aria-label={listLabel}>
{steps.map((s) => (
<li key={s.label} className={s.ok ? "ok" : "fail"}>
<span className="attack-mark">{s.ok ? "✓" : "✗"}</span>
<span className="attack-mark" aria-hidden="true">{s.ok ? "✓" : "✗"}</span>
<div>
<strong>{s.label}</strong>
<p>{s.detail}</p>
Expand Down Expand Up @@ -81,7 +82,7 @@ export function AttackDemo({ phase = "Reveal" }: { phase?: "Open" | "Reveal" | "
const revealOpen = phase !== "Open";

return (
<section className="panel attack-panel">
<section className="panel attack-panel" aria-label="Seal-off vs seal-on attack">
<header className="panel-head attack-head">
<div>
<p className="eyebrow">Main stage</p>
Expand All @@ -94,24 +95,30 @@ export function AttackDemo({ phase = "Reveal" }: { phase?: "Open" | "Reveal" | "
</header>

<div className="attack-control">
<button type="button" className="btn primary" onClick={run} disabled={loading}>
<button
type="button"
className="btn primary"
onClick={run}
disabled={loading}
aria-busy={loading}
>
{loading ? "Running live tlock demo..." : "Run live attack comparison"}
</button>
{round != null && (
<p className="muted">Sealed to future Drand round R={round.toLocaleString()}</p>
)}
{err && <p className="error">{err}</p>}
{err && <p className="error" role="alert">{err}</p>}
</div>

<div className="attack-columns">
<div className="attack-col bad">
<div className="attack-col bad" role="region" aria-label="Seal OFF — broken commit-reveal">
<h3>Seal OFF — broken commit-reveal</h3>
<p className="attack-sub">
Bid stored in reversible form. Observer reads early. Losing bidder can abort.
</p>
<SealOffColumn steps={sealOff} revealOpen={revealOpen} />
</div>
<div className="attack-col good">
<div className="attack-col good" role="region" aria-label="Seal ON — Sub Rosa + Drand tlock">
<h3>Seal ON — Sub Rosa + Drand tlock</h3>
<p className="attack-sub">
Undecryptable until R. Permissionless mass reveal. Commitment binding on-chain.
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/RoundStatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ export function RoundStatusBadge({
onRetry,
}: RoundStatusBadgeProps) {
return (
<span className={`round-status-badge ${state}`} title={error ?? message ?? undefined}>
<span
className={`round-status-badge ${state}`}
role={state === "error" ? "alert" : "status"}
aria-busy={state === "loading"}
title={error ?? message ?? undefined}
>
{state === "loading" ? (
<span className="round-status-spinner" aria-hidden="true" />
) : null}
Expand Down
111 changes: 111 additions & 0 deletions apps/web/src/demo/demo-a11y-smoke.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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 { createFakeTime } from "@sub-rosa/time";

import { AttackDemo, SealOffColumn, StepList } from "../components/AttackDemo";
import { RoundStatusBadge } from "../components/RoundStatusBadge";
import type { AttackStep } from "../lib/demoTypes";
import { TimeProvider } from "../lib/time";
import { ToastProvider } from "../ui/Toast";

const DEMO_DIR = dirname(fileURLToPath(import.meta.url));

function readWebSource(relPath: string): string {
return readFileSync(resolve(DEMO_DIR, "..", relPath), "utf8");
}

const SAMPLE_STEPS: AttackStep[] = [
{ label: "Bidder A commits", detail: "Commitment hash H recorded on-chain", ok: true },
{ label: "Observer inspects before R", detail: "Plaintext bid leaked early", ok: false },
];

test("demo default state exposes landmarks, region roles, headings, and focusable controls", () => {
const html = renderToStaticMarkup(
<TimeProvider value={createFakeTime(1_700_000_000_000)}>
<ToastProvider>
<RoundStatusBadge state="empty" message="Create or join a round" />
<AttackDemo phase="Reveal" />
</ToastProvider>
</TimeProvider>,
);

assert.match(html, /role="status"[^>]*aria-busy="false"[^>]*>.*<span class="round-status-label">Empty<\/span>/);
assert.match(html, /<section class="panel attack-panel" aria-label="Seal-off vs seal-on attack">/);
assert.match(html, /<h2>Seal-off vs seal-on attack<\/h2>/);
assert.match(html, /<div class="attack-col bad" role="region" aria-label="Seal OFF — broken commit-reveal">/);
assert.match(html, /<div class="attack-col good" role="region" aria-label="Seal ON — Sub Rosa \+ Drand tlock">/);
assert.match(
html,
/<button type="button" class="btn primary" aria-busy="false">Run live attack comparison<\/button>/,
);
});

test("demo loading state sets aria-busy, status role, and decorative spinner attributes", () => {
const html = renderToStaticMarkup(
<RoundStatusBadge state="loading" message="Fetching round state" />,
);

assert.match(html, /role="status"/);
assert.match(html, /aria-busy="true"/);
assert.match(html, /<span class="round-status-spinner" aria-hidden="true"><\/span>/);
assert.match(html, /<span class="round-status-label">Loading<\/span>/);
});

test("demo error state exposes alert role, error copy, and focusable retry control", () => {
const html = renderToStaticMarkup(
<RoundStatusBadge
state="error"
error="RPC connection timed out"
onRetry={() => {}}
/>,
);

assert.match(html, /role="alert"/);
assert.match(html, /<span class="round-status-icon" aria-hidden="true">!<\/span>/);
assert.match(html, /<span class="round-status-label">Error<\/span>/);
assert.match(html, /<span class="round-status-err">RPC connection timed out<\/span>/);
assert.match(html, /<button type="button" class="round-status-retry">Retry<\/button>/);
});

test("demo seal-on state exposes named list and hides decorative status marks from assistive tech", () => {
const html = renderToStaticMarkup(
<StepList steps={SAMPLE_STEPS} variant="good" />,
);

assert.match(html, /<ul class="attack-steps good" aria-label="Seal-on protection steps">/);
assert.match(html, /<span class="attack-mark" aria-hidden="true">✓<\/span>/);
assert.match(html, /<strong>Bidder A commits<\/strong>/);
});

test("demo seal-off state withholds values while round is Open and exposes named list once Reveal opens", () => {
const closedHtml = renderToStaticMarkup(
<SealOffColumn steps={SAMPLE_STEPS} revealOpen={false} />,
);
assert.match(closedHtml, /Bids still sealed — values hidden until Drand R opens the reveal round\./);
assert.doesNotMatch(closedHtml, /Plaintext bid leaked early/);

const openHtml = renderToStaticMarkup(
<SealOffColumn steps={SAMPLE_STEPS} revealOpen={true} />,
);
assert.match(openHtml, /<ul class="attack-steps bad" aria-label="Seal-off attack steps">/);
assert.match(openHtml, /<span class="attack-mark" aria-hidden="true">✗<\/span>/);
assert.match(openHtml, /Plaintext bid leaked early/);
});

test("DemoPage source defines navigation landmark, tablist roles, error alert, and sealed vs leaky list labels", () => {
const demoPageSource = readWebSource("pages/DemoPage.tsx");

assert.match(demoPageSource, /<aside className="case-nav" aria-label="Demo navigation">/);
assert.match(demoPageSource, /<div className="mode-switch" role="tablist" aria-label="Demo mode">/);
assert.match(demoPageSource, /role="tab"/);
assert.match(demoPageSource, /aria-selected=\{mode === "live"\}/);
assert.match(demoPageSource, /aria-selected=\{mode === "evidence"\}/);
assert.match(demoPageSource, /<ul className="example-list" aria-label="Visible to everyone">/);
assert.match(demoPageSource, /<ul className="example-list sealed" aria-label="Sealed until reveal">/);
assert.match(demoPageSource, /className="evidence-intro evidence-intro--error" role="alert"/);
});
3 changes: 3 additions & 0 deletions apps/web/src/demo/trace-health-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,6 @@ test("health check rejects a demo trace containing an injected secret seed", ()
);
});

import "./demo-a11y-smoke.test";


4 changes: 2 additions & 2 deletions apps/web/src/pages/DemoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -854,13 +854,13 @@ export function DemoPage({
<main className="app-page">
<ConfettiBurst fire={confettiTick} />
<section className="app-shell">
<aside className="case-nav">
<aside className="case-nav" aria-label="Demo navigation">
<button type="button" className="brand-link" onClick={goHome}>
<img src={LOGO_SRC} alt="" />
<span>Sub Rosa</span>
</button>

<div className="mode-switch" role="tablist">
<div className="mode-switch" role="tablist" aria-label="Demo mode">
<button
type="button"
role="tab"
Expand Down
13 changes: 13 additions & 0 deletions docs/DEMO_SCRIPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,19 @@ pnpm mainnet:verify
pnpm keeper:watch
```

## Accessibility Smoke Checks

`apps/web/src/demo/demo-a11y-smoke.test.tsx` verifies accessible names, roles,
landmarks, and focusable controls across the demo page's default, loading
(`aria-busy="true"` / `role="status"`), error (`role="alert"` + retry button),
seal-on (`aria-label="Seal-on protection steps"` / `aria-label="Sealed until reveal"`),
and seal-off (`aria-label="Seal-off attack steps"` / withheld pre-reveal copy)
states:

```bash
pnpm --filter @sub-rosa/web exec node --import tsx --test src/demo/demo-a11y-smoke.test.tsx
```

## Docs For Reviewers

- [FUNDING_STRATEGY.md](./FUNDING_STRATEGY.md)
Expand Down