Skip to content
64 changes: 64 additions & 0 deletions .claude/commands/a11y-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,60 @@ Switch to code-aware audit mode. Focus on issues that require human judgement:

---

## Component Rules (WCAG 2.2 / WAI-ARIA APG)

Guardrails to apply when building or fixing any interactive component. Check every
one during Phase 2; each cites the normative criterion it enforces.

- **Toggle buttons keep a constant accessible name.** A control with `aria-expanded`
must not swap its name between states ("Open menu" / "Close menu"). Name it for
what it controls ("Menu") and let `aria-expanded` carry open/closed; otherwise the
state is announced twice and can contradict itself. (WAI-ARIA APG.)
- **Do not name a landmark redundantly, or invent a name for a region.** A `<nav>`
label must not contain "navigation"; `<header>`/`<main>`/`<footer>` need no label
unless one role appears twice. A `<section>`/`<aside>` becomes a landmark only when
it has a real visible heading — point `aria-labelledby` at that heading. Never add a
synthetic `aria-label` nobody sees; use a plain `<div>` instead. (WCAG 1.3.1.)
- **`title` attribute only on `<iframe>`.** It is not reliably exposed to touch or
keyboard. Use visible text, `aria-label`, or `aria-describedby` instead.
- **Supplementary hints go in `aria-describedby`, not the accessible name.** e.g.
"opens in a new tab" is a description, referenced from one shared hidden node, never
folded into link text or `aria-label`. Padding the name breaks voice control.
(WCAG 2.5.3 Label in Name; ARIA name-vs-description.)
- **Do not render non-navigable URLs as links.** Loopback / localhost / single-label
hosts in prose (`http://localhost:8080/`) must be plain text, not `target="_blank"`
links — on the deployed site they point at the visitor's own machine.
- **Disabled controls: native `disabled` is the default; `aria-disabled` only when the
control must stay discoverable in focus order** (submit button, or inactive-but-
important control). Do not blanket-replace `disabled` with `aria-disabled`. When you
do, suppress the action in JS and style the state explicitly. (WAI-ARIA APG Button
Pattern — this deliberately narrows frontend-a11y's absolute "never disable a button".)
- **Focus vs announce for dynamic changes:** *move focus* to a destination (route
change → new page `<h1>`/`<main>`; submit failure → error summary); *announce* via a
polite live region for ambient updates (filter counts, toasts, consent). Do not both
move focus and announce the same change.

---

## Contrast and Transparency

- **Text contrast (WCAG 1.4.3) is covered by axe** (`color-contrast`), run on every
prerendered route in dark and light mode by `e2e/smoke.spec.ts`. Do not add a
parallel contrast checker — it would duplicate axe. When axe reports `incomplete` on
a translucent or overlapping pair, composite the alpha over its opaque background by
hand and give a definitive ruling (Phase 4).
- **Non-text / UI contrast (WCAG 1.4.11) is a known axe gap** already guarded by
targeted Playwright tests (tag-chip and contributor-pill borders, hover states in
`e2e/smoke.spec.ts`). Add a similar targeted test when introducing a new bordered
control or hover colour rather than relying on axe for it.
- **Translucent surfaces** (frosted/glassy backgrounds via `backdrop-filter`, or
semi-opaque panels layered over content) must be gated behind
`@media (prefers-reduced-transparency: no-preference)`. The site currently has none;
decorative glow shadows and the ~3.5%-opacity dot texture do not impair legibility,
so there is nothing to gate today. Apply the gate if such a surface is introduced.

---

## Phase 3 — Deterministic Input: Axe Results

Run the existing test suite to collect axe output:
Expand Down Expand Up @@ -158,3 +212,13 @@ End the report with a **Cumulative Friction Summary** noting any user journeys w
- Do not report mechanical issues (missing alt, basic contrast) during Phases 1 and 2; leave those to Phase 3.
- Do not give vague remediation ("make the button accessible"). Provide the specific attribute, element, or CSS change.
- Do not mark an axe Incomplete flag as a finding without a manual ruling.

---

## Credits

The guardrails in this skill were informed by openly published community accessibility guidance. Thank you to these authors and contributors for making it freely available:

- [mgifford/ACCESSIBILITY.md](https://github.com/mgifford/ACCESSIBILITY.md), a community resource for accessibility best practices, testing criteria, and issue-severity frameworks. Thank you to Mike Gifford and all contributors.
- [mikemai2awesome/agent-skills](https://github.com/mikemai2awesome/agent-skills), the frontend-a11y skill. Thank you to Mike Mai.
- [Intopia web accessibility skill](https://github.com/Intopia/intopia-web-accessibility-skill), WCAG 2.2 acceptance criteria. Thank you to Intopia.
30 changes: 30 additions & 0 deletions .claude/commands/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,35 @@ one item is in the tab stop at a time and arrow keys move within the group.

See [WAI-ARIA APG: Roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex).

## Serious: Disabled Controls Stay Discoverable Where It Matters

Native `disabled` is the correct default for form controls: it removes the element
from the tab order and the accessibility tree, which is usually what you want.

But a control the user must still be able to *find* and understand as unavailable
should use `aria-disabled="true"` instead, because it keeps the element focusable
and announced. Reach for `aria-disabled` when:

- The control is a **submit button** — keep it active (or `aria-disabled`), never
natively `disabled`, so a keyboard/screen-reader user can trigger validation and
hear what is missing rather than tabbing past a dead, silent button.
- The control is **important to keep in the focus order** while temporarily
inactive (e.g. a carousel arrow at the end of its range).

Do not blanket-replace `disabled` with `aria-disabled`. When you do use it,
suppress the action in JavaScript and style the state explicitly (`aria-disabled`
gets no user-agent dimming), e.g. `[aria-disabled="true"] { cursor: not-allowed; }`.

```html
<!-- Form field: native disabled is right -->
<input type="text" disabled />

<!-- Submit / must-stay-discoverable control: aria-disabled -->
<button type="button" aria-disabled="true" aria-label="Scroll to next">…</button>
```

See [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/).

## Moderate: Touch Targets (WCAG 2.5.8)

```css
Expand All @@ -128,6 +157,7 @@ Never use `user-scalable=no` in the viewport meta tag.
- [ ] Dialog: background content `inert` on open; focus returns to trigger on close
- [ ] Skip link present, first in DOM, visible on focus, target has `tabindex="-1"`
- [ ] Composite widgets use roving tabindex
- [ ] Submit buttons are not natively `disabled`; `aria-disabled` used only where the control must stay discoverable
- [ ] Touch targets meet 24×24px minimum

## Key WCAG Criteria
Expand Down
9 changes: 6 additions & 3 deletions ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ Use the correct keys for each control type:
- Never use a `<div>` or `<span>` as an interactive element.
- Never use `role="menu"` or `role="menuitem"` on site navigation. These roles put screen readers into application mode where arrow keys replace Tab, breaking web navigation expectations. Use native `<a>` elements inside `<nav>` instead.
- One `<h1>` per page. No skipped heading levels.
- Every `<section>` and `<article>` must have an accessible name via `aria-labelledby` pointing to its heading, or `aria-label` if there is no visible heading. An unnamed `<section>` is not exposed as a landmark to screen readers.
- A `<section>` becomes a landmark (`role="region"`) only when it has an accessible name, and it then appears in the screen reader's landmarks menu. Name it only when it is a genuinely distinct region worth a navigation shortcut, and name it with `aria-labelledby` pointing to a real visible heading. Never invent a synthetic `aria-label` string that no one sees — a generically named region is noise in the landmarks menu. If a block has no heading and is not a distinct region, use a plain `<div>`, not a `<section>`. Name an `<article>` by its heading via `aria-labelledby` the same way.
- Never apply overline/label typography (`text-sm uppercase tracking-widest`) to a heading tag. If the text is a genuine section heading, give it heading-appropriate typography. If it is purely decorative, use `<span>` or `<p>`.
- Never use a non-heading tag for text visually styled as a heading. Promote it to the correct heading level.
- Every page's primary content must live inside a single `<main id="main-content">`. Do not split content across multiple `<main>` elements.
Expand All @@ -283,7 +283,8 @@ Use the correct keys for each control type:

### External links

- Every `<a target="_blank">` must include `<span className="sr-only"> (opens in new tab)</span>` as its last child.
- Every `<a target="_blank">` must reference the shared new-tab hint with `aria-describedby="new-tab-hint"`. A single hidden `<span id="new-tab-hint" hidden>opens in a new tab</span>` is rendered once in `Layout.tsx`. Do not fold "opens in a new tab" into the link text or `aria-label`, and do not add a per-link `sr-only` span — the hint is an accessible description, not part of the name (padding the name breaks voice control, WCAG 2.5.3 Label in Name).
- Never render a non-navigable URL as a link. Loopback / localhost / single-label hosts (e.g. `http://localhost:8080/`) must be plain text — on the deployed site a link there points at the visitor's own machine. The adventure generator (`annotateExternalLinks`) unwraps these automatically; in JSX, write them as text, not `<a>`.

### Links

Expand Down Expand Up @@ -322,8 +323,10 @@ Use the correct keys for each control type:
- Never use ARIA to paper over bad markup. Fix the markup first.
- Use `role="status"` (implicit `aria-live="polite"`) for non-urgent updates like form success messages.
- Use `role="alert"` (implicit `aria-live="assertive"`) only for errors requiring immediate attention. Never use `aria-live="assertive"` for informational updates.
- Use `aria-expanded` on toggles that open/close UI.
- Use `aria-expanded` on toggles that open/close UI. Keep the toggle's accessible name **constant** across states: name it for what it controls (e.g. `aria-label="Menu"`) and let `aria-expanded` carry open/closed. Never swap the name between "Open menu" / "Close menu" — that announces the state twice and can contradict `aria-expanded`.
- Always add `aria-label` or `aria-labelledby` to icon-only buttons.
- Never use the `title` attribute to convey information. It is not exposed on touch, is unreliable for keyboard users, and is announced inconsistently across screen readers. The only acceptable use is `title` on `<iframe>`. Use visible text, `aria-label`, or `aria-describedby` instead.
- Disabled controls: use native `disabled` on form fields (`<input>`, `<select>`, `<textarea>`) — it correctly governs whether the value submits. For **buttons**, prefer `aria-disabled="true"` over native `disabled` when the control must stay discoverable in the focus order (a submit button, or an inactive-but-important control), so keyboard and screen reader users can still find it and learn why it is unavailable. Do not blanket-replace `disabled` with `aria-disabled`. When you use `aria-disabled`, suppress the action in JavaScript and style the state explicitly (it gets no user-agent dimming). See the `/keyboard` command for detail.
- Preference toggles (theme, consent): announce state changes to screen readers with a `role="status"` live region. Clear the region then set new text after a 50ms delay so screen readers detect the change as a mutation: `region.textContent = ''` → `setTimeout(() => { region.textContent = 'Theme switched to dark mode'; }, 50)`.

### Tooltips
Expand Down
4 changes: 2 additions & 2 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ test.describe("hydration and interactivity", () => {
await page.getByRole("button", { name: "Accept analytics cookies" }).click();

await expect(banner).not.toBeVisible();
await expect(page.getByRole("button", { name: "Reset cookie preferences" })).toBeVisible();
await expect(page.getByRole("button", { name: "Cookie Preferences" })).toBeVisible();
const stored = await page.evaluate(() => localStorage.getItem("analytics_consent"));
expect(JSON.parse(stored!).value).toBe("granted");
});
Expand All @@ -332,7 +332,7 @@ test.describe("hydration and interactivity", () => {
await page.getByRole("button", { name: "Decline analytics cookies" }).click();

await expect(page.getByRole("region", { name: "This site uses analytics cookies" })).not.toBeVisible();
await expect(page.getByRole("button", { name: "Reset cookie preferences" })).toBeVisible();
await expect(page.getByRole("button", { name: "Cookie Preferences" })).toBeVisible();
const stored = await page.evaluate(() => localStorage.getItem("analytics_consent"));
expect(JSON.parse(stored!).value).toBe("denied");
});
Expand Down
4 changes: 2 additions & 2 deletions e2e/visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ const VISUAL_ROUTES: VisualRoute[] = [
// consent is non-null. We keep the button out of screenshots because it is a
// fixed overlay whose position is unrelated to page content.
// The selector mirrors the button's aria-label, which is also asserted in
// smoke.spec.ts; any rename will surface in both places simultaneously.
const CONSENT_BUTTON_CSS = `[aria-label="Change cookie preferences"] { display: none !important; }`;
// smoke.spec.ts, so a rename surfaces in both places at once.
const CONSENT_BUTTON_CSS = `[aria-label="Cookie Preferences"] { display: none !important; }`;

// Registers a script to run before React mounts on each navigation.
// Pre-denying consent means useConsent restores "denied" from localStorage,
Expand Down
8 changes: 5 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ export default tseslint.config(
"varsIgnorePattern": "^_"
}],
// Safari VoiceOver strips list semantics when list-style is removed (Tailwind list-none).
// Explicit role="list" on <ul>/<ol> restores them. This is intentional throughout the codebase
// (30+ instances), so a global off is more practical than per-site suppression comments.
"jsx-a11y/no-redundant-roles": "off",
// Explicit role="list" on <ul>/<ol> restores them and is intentional throughout the codebase
// (30+ instances). Allow that one redundant role, but keep the rule on so other redundant
// roles (e.g. role="button" on <button>) are caught. Note: the rule does not flag redundant
// landmark roles like role="navigation" on <nav>; those stay covered by ACCESSIBILITY.md.
"jsx-a11y/no-redundant-roles": ["error", { ul: ["list"], ol: ["list"] }],
},
},
);
36 changes: 33 additions & 3 deletions scripts/generate-adventures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,46 @@ const mdProcessor = unified()
.use(expandAbbr)
.use(rehypeStringify);

/** Add target/rel/sr-only to http/https <a> tags. The external link icon is
* rendered via CSS ::after on [target="_blank"] — no inline SVG needed. */
/** A URL is not publicly navigable when its host is loopback, mDNS, or a
* single-label name (no public TLD). remark-gfm autolinks bare URLs written in
* prose (e.g. "runs on http://localhost:8080/"), so without this guard a
* local dev address would become a clickable new-tab link on the deployed
* site, pointing at the visitor's own machine. */
function isNonPublicUrl(href) {
try {
const host = new URL(href).hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
if (host === "localhost" || host === "0.0.0.0" || host === "::1") return true;
if (/^127\./.test(host)) return true; // IPv4 loopback range
if (/^10\./.test(host)) return true; // private class A
if (/^192\.168\./.test(host)) return true; // private class C
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true; // private class B (172.16-31)
if (host.endsWith(".local")) return true; // mDNS
if (!host.includes(".")) return true; // single-label host, no public TLD
return false;
} catch {
return false;
}
}

/** Add target/rel and the shared "opens in a new tab" hint to http/https <a>
* tags. The hint is exposed as an accessible description via
* aria-describedby="new-tab-hint" (a single hidden node rendered once in
* Layout.tsx), not folded into each link's accessible name. The external link
* icon is rendered via CSS ::after on [target="_blank"] — no inline SVG.
* Non-public URLs (localhost, loopback) are unwrapped to plain text: they are
* not navigable on the deployed site and must not open a new tab. */
function annotateExternalLinks(html) {
return html.replace(
/<a href="(https?:\/\/[^"]+)"([^>]*)>([\s\S]*?)<\/a>/gi,
(_, href, restAttrs, content) => {
if (isNonPublicUrl(href)) return content;
const attrs = restAttrs.includes("target=")
? restAttrs
: ` target="_blank" rel="noopener noreferrer"${restAttrs}`;
return `<a href="${href}"${attrs}>${content}<span class="sr-only"> (opens in new tab)</span></a>`;
const described = restAttrs.includes("aria-describedby=")
? attrs
: `${attrs} aria-describedby="new-tab-hint"`;
return `<a href="${href}"${described}>${content}</a>`;
}
);
}
Expand Down
5 changes: 5 additions & 0 deletions src/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ export function Layout(): JSX.Element {
<FocusReset />
<RouteAnnouncer />
<ThemeAnnouncer />
{/* Shared description for every external link. Referenced via
aria-describedby="new-tab-hint" so the "opens in a new tab" hint is
an accessible description, not part of each link's accessible name.
hidden still resolves for aria-describedby. */}
<span id="new-tab-hint" hidden>opens in a new tab</span>
<PageViewTracker />
<ClickTracker />
<ConsentBanner />
Expand Down
4 changes: 2 additions & 2 deletions src/components/AboutSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SectionLabel } from "@/components/SectionLabel";

export const AboutSection = (): JSX.Element => {
return (
<section id="approach" aria-label="Our foundation" className="pb-16">
<div id="approach" className="pb-16">
<div>
<SectionLabel>our foundation</SectionLabel>

Expand Down Expand Up @@ -81,6 +81,6 @@ export const AboutSection = (): JSX.Element => {
</div>
</div>
</div>
</section>
</div>
);
};
8 changes: 4 additions & 4 deletions src/components/BottomCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ export const BottomCTA = (): JSX.Element => {
<a
href={COMMUNITY_URL}
target="_blank"
rel="noopener noreferrer"
rel="noopener noreferrer" aria-describedby="new-tab-hint"
className="btn-inverse"
>
Join the Community <ExternalLink size={14} aria-hidden="true" /><span className="sr-only"> (opens in new tab)</span>
Join the Community <ExternalLink size={14} aria-hidden="true" />
</a>
<a
href="https://github.com/off-on-dev/open-source-challenges"
target="_blank"
rel="noopener noreferrer"
rel="noopener noreferrer" aria-describedby="new-tab-hint"
className="btn-ghost-inverse"
>
View Challenges on GitHub <ExternalLink size={14} aria-hidden="true" /><span className="sr-only"> (opens in new tab)</span>
View Challenges on GitHub <ExternalLink size={14} aria-hidden="true" />
</a>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/ChallengeHighlights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const highlights: Highlight[] = [

export const ChallengeHighlights = (): JSX.Element => {
return (
<section aria-label="Challenge highlights" className="bg-card py-16 px-6 md:px-16 border-y border-border">
<div className="bg-card py-16 px-6 md:px-16 border-y border-border">
<div className="mx-auto max-w-6xl grid gap-8 sm:grid-cols-3">
{highlights.map((h) => (
<div key={h.title} className="flex gap-4">
Expand All @@ -39,6 +39,6 @@ export const ChallengeHighlights = (): JSX.Element => {
</div>
))}
</div>
</section>
</div>
);
};
Loading
Loading