Files
Reece Browne c2e8c3fa71 a11y: empty the grandfathered Storybook baseline (1,058 → 0) (#7309)
## What

Empties the light Storybook accessibility baseline — **1,058
grandfathered violations across 846 stories → 0** — so a new violation
fails the gate instead of being silently absorbed. Also burns the dark
baseline **812 → 56**; every entry left is one `main` already
grandfathers.

## The defect, repeated everywhere

A colour picked as a **fill**, chosen to carry a white label at 3:1,
reused as **text**, where the floor is 4.5:1. It recurred through status
accents, filled buttons, form labels, Mantine's light and outline
variants, CSS declarations, inline styles and the generated accent ramp.

Three systemic causes account for most of it:

- **Mantine's semantic slots were never bound.** `-text`, `-outline`,
`-light-color`, `-filled` and `-dimmed` all default to the hue's solid
fill. Both resolvers now pin them to the accessible ink for the active
scheme.
- **The tint ladder was compressed.** `--color-<hue>-50/100/200` pointed
at saturated 400-level primitives, so every "tint" background rendered
as a fill.
- **Text was faded with `opacity`**, pushing already-muted copy below
the floor. Each site now recedes via ink or surface, which is what
conveyed the state anyway.

## Dark mode

The colour resolver's dark half was empty, so dark fell through to
Mantine's stock palette — and fixing the naming violations unmasked the
contrast sitting underneath them. Both schemes now share one slot map,
since most slots are written in tokens that already flip.

The dark-only fixes: `--c-text-subtle` (3.0:1, used in 478 places), the
error and section-label inks, and the accent ramp's text step — which
light reaches by mixing toward black and dark has to reach by mixing
toward white.

## Also

- New `--c-*-solid` tokens for fills that must carry a white label,
distinct from the `--c-<tone>` values used for surfaces, borders and
icons.
- A `data-user-content-preview` opt-out for nodes rendering a facsimile
of the user's own document — WCAG governs the interface, not content
authored through it.

## Verification

- `task frontend:check:all` — green.
- Changed-set gate, both schemes, after the final rebase: **366 stories,
0 regressions**.
- Full sweep at the prior base — light **1,447 stories / 0 violations**,
dark **1,448 / 0 regressions**. The dark re-record was confirmed
key-by-key to be a strict subset of `main`'s, so nothing new is
grandfathered.

Roughly 28% of what this clears is naming and structure (`button-name`,
`label`, `aria-*`) and has no visual signature; the rest is contrast.
2026-08-12 09:06:05 +00:00

74 lines
2.7 KiB
JavaScript

// Prints the story files a branch affects, one per line — the scan set for the
// pull-request a11y gate. A story is affected if its file changed against the
// base ref (or is uncommitted/untracked), or if a same-named sibling source
// file changed: stories render the live component, so editing Button.tsx or
// Button.css changes what Button.stories.tsx shows without touching it.
//
// node a11y-changed.mjs [base-ref] (default origin/main)
//
// Node rather than shell so the task works no matter what invokes it — Task's
// embedded interpreter runs on Windows, but sed/grep/sort do not exist for
// developers calling tasks from PowerShell.
import { execFileSync } from "node:child_process";
import { readdirSync } from "node:fs";
import { basename, dirname, join } from "node:path";
const base = process.argv[2] || "origin/main";
const git = (...args) =>
execFileSync("git", args, { encoding: "utf8" })
.split("\n")
.map((l) => l.trim().replace(/^frontend\//, ""))
.filter(Boolean);
const STORY = /\.stories\.tsx?$/;
const TEST = /\.test\.tsx?$/;
const SOURCE = /\.(ts|tsx|css)$/;
// Committed changes vs the base, plus working-tree changes, plus untracked
// files — so the gate covers exactly what the branch would merge and what a
// developer is about to commit.
const changed = [
...git("diff", "--name-only", "--diff-filter=d", `${base}...HEAD`),
...git("diff", "--name-only", "--diff-filter=d"),
...git("ls-files", "--others", "--exclude-standard"),
];
const stories = new Set();
for (const f of changed) {
if (STORY.test(f)) {
stories.add(f);
continue;
}
if (TEST.test(f) || !SOURCE.test(f)) continue;
// A story file does not have to match its source's case — tokens.css sits
// beside Tokens.stories.tsx. Deriving the name from the source and trusting
// existsSync silently skips those on a case-sensitive filesystem, and on a
// case-insensitive one feeds the scan a path no result will ever match. Read
// the directory instead and compare case-insensitively, then use the name as
// it is actually spelled on disk.
const dir = dirname(f) || ".";
const stem = basename(f).replace(SOURCE, "").toLowerCase();
let entries;
try {
entries = readdirSync(dir);
} catch {
continue;
}
for (const entry of entries) {
if (!STORY.test(entry)) continue;
if (entry.replace(STORY, "").toLowerCase() !== stem) continue;
stories.add(join(dir, entry).split("\\").join("/"));
}
}
// One line, each path quoted: the output is interpolated into a task command,
// where a newline would end the command after the first story and an unquoted
// space would split a path into two arguments.
process.stdout.write(
[...stories]
.sort()
.map((s) => `"${s}"`)
.join(" "),
);