Compare commits

...
Author SHA1 Message Date
EthanHealy01 eb6ca6c2de Merge branch 'main' into UI/color-contrast-quick-fixes 2026-07-16 11:55:09 +01:00
EthanHealy01 a6a54db2af chore(contrast-audit): trim verbose comments to concise explanations 2026-07-16 11:11:21 +01:00
EthanHealy01 413005490c fix(contrast-audit): skip SVG by namespace, not win.SVGElement
win.SVGElement isn't on the Window type (tsc TS2339), and cross-realm
instanceof against a nested iframe's constructor is fragile anyway. Match
the SVG namespace on the element instead — typed, realm-safe, same intent.
2026-07-16 00:30:22 +01:00
EthanHealy01 6ee2e3b232 frontend fix with task 2026-07-16 00:19:21 +01:00
EthanHealy01 bbf34088e6 fix(contrast-audit): build scan iframe URL via URLSearchParams
CodeQL flagged the templated iframe.src as DOM-text-reinterpreted-as-HTML.
Build the query with URLSearchParams (encodes every value) and clamp the
theme to a known token. Functionally identical (globals decodes back to
theme:<light|dark>), no more untrusted-looking interpolation in the URL.
2026-07-16 00:16:18 +01:00
EthanHealy01 50d6b049cf pushing quick fixes to some pre-existing and or newly introduced (in #7009) color contrast issues 2026-07-15 23:12:36 +01:00
12 changed files with 1055 additions and 111 deletions
+3
View File
@@ -195,6 +195,9 @@ const preview: Preview = {
options: {},
test: "todo",
},
options: {
storySort: { order: ["*", "Tools"] },
},
},
globalTypes: {
tier: {
+209 -92
View File
@@ -5,7 +5,9 @@
// primitives.css; colors/compat/dimensions must
// reference tokens; no duplicate primitives.
// (blocking)
// node theme-lint.mjs contrast warn-only WCAG contrast report (never blocks)
// node theme-lint.mjs contrast warn-only WCAG contrast report — the fixed
// --c-* pairs plus the status-tone text/fill
// pairs from tokens.css (never blocks)
//
// Scope is deliberately just core/theme/ — the palette + token layer this PR
// owns, which is clean, so no baseline file is needed. Enforcing "no hardcoded
@@ -66,6 +68,96 @@ function stripComments(text) {
return text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
}
// ── shared colour math + token resolution (used by contrast report + tone guard)
function readPrimitives(css) {
const primitives = {};
for (const m of css.matchAll(
/(--p-[a-z0-9-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g,
))
primitives[m[1]] = m[2];
return primitives;
}
function hexToRgb(h) {
h = h.replace("#", "");
if (h.length === 3)
h = h
.split("")
.map((c) => c + c)
.join("");
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: 1,
};
}
const over = (f, b) => ({
r: f.r * f.a + b.r * (1 - f.a),
g: f.g * f.a + b.g * (1 - f.a),
b: f.b * f.a + b.b * (1 - f.a),
a: 1,
});
const mix = (a, b, p) => ({
r: (a.r * p + b.r * (100 - p)) / 100,
g: (a.g * p + b.g * (100 - p)) / 100,
b: (a.b * p + b.b * (100 - p)) / 100,
a: 1,
});
// Resolve any token value: hex, rgb(a), var(--x[, fallback]) (--p-* → palette,
// else the theme map), or color-mix(in srgb, A n%, B|transparent).
function resolveColorValue(v, t, primitives, seen) {
if (v == null) return null;
v = v.trim();
let m;
if (v.startsWith("#")) return hexToRgb(v);
if ((m = v.match(/^rgba?\(([^)]+)\)$/))) {
const n = m[1]
.split(/[,/\s]+/)
.map(Number)
.filter((x) => !Number.isNaN(x));
return { r: n[0], g: n[1], b: n[2], a: n[3] ?? 1 };
}
if ((m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([\s\S]+))?\)$/))) {
return resolveColorVar(m[1], m[2], t, primitives, seen);
}
if ((m = v.match(/^color-mix\(in srgb,\s*(.+?)\s+(\d+)%\s*,\s*(.+)\)$/))) {
const a = resolveColorValue(m[1], t, primitives, seen);
if (!a) return null;
if (m[3].trim() === "transparent") return { ...a, a: +m[2] / 100 };
const b = resolveColorValue(m[3].trim(), t, primitives, seen);
return b ? mix(a, b, +m[2]) : null;
}
return null;
}
function resolveColorVar(name, fallback, t, primitives, seen) {
if (name.startsWith("--p-")) {
return primitives[name]
? hexToRgb(primitives[name])
: fallback
? resolveColorValue(fallback, t, primitives, seen)
: null;
}
if (!seen.has(name) && t[name] !== undefined) {
const next = new Set(seen).add(name);
const r = resolveColorValue(t[name], t, primitives, next);
if (r) return r;
}
return fallback ? resolveColorValue(fallback, t, primitives, seen) : null;
}
const relativeLuminance = ({ r, g, b }) => {
const f = (v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const contrastRatio = (a, b) => {
const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort(
(x, y) => y - x,
);
return (hi + 0.05) / (lo + 0.05);
};
// ── enforce: literals only in primitives.css, no duplicate primitives ────────
function check() {
const violations = [];
@@ -149,11 +241,7 @@ function check() {
function reportContrast() {
const primitivesCss = readFileSync(join(THEME, "primitives.css"), "utf8");
const colorsCss = readFileSync(join(THEME, "colors.css"), "utf8");
const primitives = {};
for (const m of primitivesCss.matchAll(
/(--p-[a-z0-9-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g,
))
primitives[m[1]] = m[2];
const primitives = readPrimitives(primitivesCss);
const blocks = [];
for (const m of colorsCss.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
const decls = {};
@@ -189,82 +277,8 @@ function reportContrast() {
};
const flatten = (list) =>
Object.assign({ ...SEED }, ...list.map((b) => b.decls));
const hexToRgb = (h) => {
h = h.replace("#", "");
if (h.length === 3)
h = h
.split("")
.map((c) => c + c)
.join("");
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: 1,
};
};
const over = (f, b) => ({
r: f.r * f.a + b.r * (1 - f.a),
g: f.g * f.a + b.g * (1 - f.a),
b: f.b * f.a + b.b * (1 - f.a),
a: 1,
});
const mix = (a, b, p) => ({
r: (a.r * p + b.r * (100 - p)) / 100,
g: (a.g * p + b.g * (100 - p)) / 100,
b: (a.b * p + b.b * (100 - p)) / 100,
a: 1,
});
// Resolve any token value: hex, rgb(a), var(--x[, fallback]) (--p-* → palette,
// else the theme map/seed), or color-mix(in srgb, A n%, B|transparent).
function resolveValue(v, t, seen) {
if (v == null) return null;
v = v.trim();
let m;
if (v.startsWith("#")) return hexToRgb(v);
if ((m = v.match(/^rgba?\(([^)]+)\)$/))) {
const n = m[1]
.split(/[,/\s]+/)
.map(Number)
.filter((x) => !Number.isNaN(x));
return { r: n[0], g: n[1], b: n[2], a: n[3] ?? 1 };
}
if ((m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([\s\S]+))?\)$/))) {
return resolveVar(m[1], m[2], t, seen);
}
if ((m = v.match(/^color-mix\(in srgb,\s*(.+?)\s+(\d+)%\s*,\s*(.+)\)$/))) {
const a = resolveValue(m[1], t, seen);
if (!a) return null;
if (m[3].trim() === "transparent") return { ...a, a: +m[2] / 100 };
const b = resolveValue(m[3].trim(), t, seen);
return b ? mix(a, b, +m[2]) : null;
}
return null;
}
function resolveVar(name, fallback, t, seen) {
if (name.startsWith("--p-")) {
return primitives[name]
? hexToRgb(primitives[name])
: fallback
? resolveValue(fallback, t, seen)
: null;
}
if (!seen.has(name) && t[name] !== undefined) {
const next = new Set(seen).add(name);
const r = resolveValue(t[name], t, next);
if (r) return r;
}
return fallback ? resolveValue(fallback, t, seen) : null;
}
const resolve = (token, t) =>
resolveValue(t[token] ?? null, t, new Set([token]));
const lum = ({ r, g, b }) => {
const f = (v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
resolveColorValue(t[token] ?? null, t, primitives, new Set([token]));
const contrast = (t1, t2, t) => {
const surface = resolve("--c-surface", t);
let a = resolve(t1, t);
@@ -272,8 +286,7 @@ function reportContrast() {
if (!a || !b || !surface) return null;
if (a.a < 1) a = over(a, surface);
if (b.a < 1) b = over(b, surface);
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
return contrastRatio(a, b);
};
const PAIRS = [
["--c-text", "--c-surface", 4.5],
@@ -307,23 +320,127 @@ function reportContrast() {
);
}
// ── status-tone text on its own fill ─────────────────────────────────────────
// Checks --color-{hue} text against its --color-{hue}-light fill, per theme.
// Below TONE_INVISIBLE blocks the build; TONE_FLOOR (WCAG AA) is advisory.
const TOKENS_CSS = resolve(process.cwd(), "editor/src/core/tokens/tokens.css");
const TONE_FLOOR = 3.0;
const TONE_INVISIBLE = 1.6;
// Compute the contrast of every --color-{hue} text on its own --color-{hue}-light
// fill, per theme. Returns [{ theme, base, fill, ratio|null }] — no printing, so
// both the warn-only report and the blocking guard can share it.
function toneContrastResults() {
const primitives = readPrimitives(
readFileSync(join(THEME, "primitives.css"), "utf8"),
);
// Strip comments first: a selector's captured prefix can otherwise include a
// preceding comment that mentions data-theme="dark", misclassifying the block.
const css = stripComments(readFileSync(TOKENS_CSS, "utf8"));
const isDark = (sel) => /data-theme="dark"|color-scheme="dark"/.test(sel);
const lightTones = {};
const darkTones = {};
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
const target = isDark(m[1]) ? darkTones : lightTones;
for (const d of m[2].matchAll(/(--color-[a-z0-9-]+)\s*:\s*([^;]+);/g))
target[d[1]] = d[2].trim();
}
// Dark only overrides the tones it redefines; unspecified ones inherit light.
const themes = { light: lightTones, dark: { ...lightTones, ...darkTones } };
const white = { r: 255, g: 255, b: 255, a: 1 };
const results = [];
for (const [theme, t] of Object.entries(themes)) {
const bases = Object.keys(t)
.map((k) => /^(--color-[a-z]+)-light$/.exec(k)?.[1])
.filter((base) => base && t[base] !== undefined);
for (const base of bases) {
const fill = `${base}-light`;
const fg = resolveColorValue(t[base], t, primitives, new Set([base]));
const bg = resolveColorValue(t[fill], t, primitives, new Set([fill]));
const ratio =
fg && bg
? contrastRatio(
fg.a < 1 ? over(fg, bg) : fg,
bg.a < 1 ? over(bg, white) : bg,
)
: null;
results.push({ theme, base, fill, ratio });
}
}
return results;
}
function reportToneContrast() {
const results = toneContrastResults();
let warnings = 0;
let invisible = 0;
console.log("tone-contrast report: text on its own -light fill\n");
let theme = "";
for (const { theme: th, base, fill, ratio } of results) {
if (th !== theme) {
theme = th;
console.log(` ${theme}`);
}
if (ratio == null) {
console.log(` ? ${base} on ${fill} (unresolved)`);
continue;
}
// ✖ = near-invisible (blocks CI), ⚠ = below the WCAG floor (warn only).
const mark =
ratio < TONE_INVISIBLE ? "✖ " : ratio < TONE_FLOOR ? "⚠ " : " ";
if (ratio < TONE_INVISIBLE) invisible++;
else if (ratio < TONE_FLOOR) warnings++;
console.log(
` ${mark}${ratio.toFixed(2).padStart(5)} (floor ${TONE_FLOOR}, blocks <${TONE_INVISIBLE}) ${base} on ${fill}`,
);
}
const parts = [];
if (invisible)
parts.push(`${invisible} near-invisible (<${TONE_INVISIBLE}:1)`);
if (warnings) parts.push(`${warnings} below the ${TONE_FLOOR} floor`);
console.log(
parts.length
? `\n${parts.join(", ")}. ✖ blocks the build; ⚠ is advisory.\n`
: "\n✓ all tones clear the floor.\n",
);
}
// ── CLI ──────────────────────────────────────────────────────────────────────
if (process.argv.includes("contrast")) {
reportContrast();
reportToneContrast();
process.exit(0); // never blocks
}
const violations = check();
if (violations.length) {
console.error(
`\n✖ theme-lint: ${violations.length} raw/duplicate colour(s) in core/theme/:\n`,
);
for (const v of violations) console.error(` ${v.file}:${v.line} ${v.msg}`);
console.error(
`\nDefine every colour once in core/theme/primitives.css and reference it with var(--p-…).\n`,
);
// Block near-invisible status tones (< TONE_INVISIBLE) in either theme.
const toneViolations = toneContrastResults().filter(
(r) => r.ratio != null && r.ratio < TONE_INVISIBLE,
);
if (violations.length || toneViolations.length) {
if (violations.length) {
console.error(
`\n✖ theme-lint: ${violations.length} raw/duplicate colour(s) in core/theme/:\n`,
);
for (const v of violations)
console.error(` ${v.file}:${v.line} ${v.msg}`);
console.error(
`\nDefine every colour once in core/theme/primitives.css and reference it with var(--p-…).\n`,
);
}
if (toneViolations.length) {
console.error(
`\n✖ theme-lint: ${toneViolations.length} status tone(s) below the ${TONE_INVISIBLE}:1 legibility floor (text nearly invisible on its own fill):\n`,
);
for (const v of toneViolations)
console.error(
` ${v.base} on ${v.fill}${v.ratio.toFixed(2)}:1 in ${v.theme} theme`,
);
console.error(
`\nGive --color-{hue}-light a paler/darker tint in core/tokens/tokens.css so the label is legible.\n`,
);
}
process.exit(1);
}
console.log(
"✓ theme-lint: core/theme colours all route through the primitive palette",
"✓ theme-lint: core/theme colours route through the primitive palette; status tones clear the legibility floor",
);
+57 -13
View File
@@ -26,24 +26,56 @@
/* Brand / status */
--color-purple: var(--p-blue-400);
--color-purple-light: var(--p-blue-400);
--color-purple-border: var(--p-blue-400);
--color-purple-light: color-mix(
in srgb,
var(--p-blue-400) 13%,
var(--p-white)
);
--color-purple-border: color-mix(
in srgb,
var(--p-blue-400) 40%,
var(--p-white)
);
--color-purple-dark: var(--p-blue-600);
--color-green: var(--p-green-500);
--color-green-light: var(--p-green-500);
--color-green-border: var(--p-green-500);
--color-green-light: color-mix(
in srgb,
var(--p-green-500) 13%,
var(--p-white)
);
--color-green-border: color-mix(
in srgb,
var(--p-green-500) 40%,
var(--p-white)
);
--color-green-dark: var(--p-green-600);
--color-red: var(--p-red-500);
--color-red-light: var(--p-red-400);
--color-red-border: var(--p-red-400);
--color-red-light: color-mix(in srgb, var(--p-red-500) 13%, var(--p-white));
--color-red-border: color-mix(in srgb, var(--p-red-500) 40%, var(--p-white));
--color-red-dark: var(--p-red-600);
--color-amber: var(--p-amber-500);
--color-amber-light: var(--p-amber-400);
--color-amber-border: var(--p-amber-400);
--color-amber-light: color-mix(
in srgb,
var(--p-amber-500) 15%,
var(--p-white)
);
--color-amber-border: color-mix(
in srgb,
var(--p-amber-500) 42%,
var(--p-white)
);
--color-amber-dark: var(--p-amber-600);
--color-orange: var(--p-amber-600);
--color-orange-light: var(--p-amber-400);
--color-orange-border: var(--p-amber-400);
--color-orange-light: color-mix(
in srgb,
var(--p-amber-600) 15%,
var(--p-white)
);
--color-orange-border: color-mix(
in srgb,
var(--p-amber-600) 42%,
var(--p-white)
);
--color-orange-dark: var(--p-red-600);
/* Category accents (theme-stable) */
@@ -132,7 +164,11 @@
--color-purple-border: var(--p-zinc-600);
--color-purple-dark: var(--p-blue-400);
--color-green: var(--p-green-500);
--color-green-light: var(--p-green-700);
--color-green-light: color-mix(
in srgb,
var(--p-green-500) 20%,
var(--p-zinc-900)
);
--color-green-border: var(--p-green-700);
--color-green-dark: var(--p-green-500);
--color-red: var(--p-red-400);
@@ -140,7 +176,11 @@
--color-red-border: var(--p-red-600);
--color-red-dark: var(--p-red-500);
--color-amber: var(--p-amber-400);
--color-amber-light: var(--p-amber-600);
--color-amber-light: color-mix(
in srgb,
var(--p-amber-500) 20%,
var(--p-zinc-900)
);
--color-amber-border: var(--p-amber-600);
/* Deeper than the base so it stays distinct (hover shade + amber text on
dark surfaces). ~7.4:1 on --color-amber-light, passes WCAG AA. Was
@@ -148,7 +188,11 @@
no-op in dark mode. */
--color-amber-dark: var(--p-amber-500);
--color-orange: var(--p-amber-500);
--color-orange-light: var(--p-amber-600);
--color-orange-light: color-mix(
in srgb,
var(--p-amber-600) 30%,
var(--p-zinc-900)
);
--color-orange-border: var(--p-red-600);
--color-orange-dark: var(--p-amber-400);
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ContrastAuditPanel } from "@app/ui/contrastAudit/ContrastAuditPanel";
// Dev/QA tool — scans every Storybook story for text-on-fill contrast. Pinned to
// the bottom of the sidebar via storySort in .storybook/preview.tsx. The panel
// and its scanning engine live in ./contrastAudit/*.
const meta: Meta<typeof ContrastAuditPanel> = {
title: "Tools/Contrast Audit",
component: ContrastAuditPanel,
parameters: { layout: "padded", a11y: { test: "off" } },
};
export default meta;
type Story = StoryObj<typeof ContrastAuditPanel>;
export const Audit: Story = {};
+3 -3
View File
@@ -10,7 +10,7 @@
line-height: 1.4;
}
.sui-method--get {
color: var(--color-green);
color: var(--color-green-dark);
background: var(--color-green-light);
border-color: var(--color-green-border);
}
@@ -25,12 +25,12 @@
border-color: var(--color-amber-border);
}
.sui-method--patch {
color: var(--color-purple);
color: var(--color-purple-dark);
background: var(--color-purple-light);
border-color: var(--color-purple-border);
}
.sui-method--delete {
color: var(--color-red);
color: var(--color-red-dark);
background: var(--color-red-light);
border-color: var(--color-red-border);
}
+3 -3
View File
@@ -44,7 +44,7 @@
border-color: var(--color-border-light);
}
.sui-status--success {
color: var(--color-green);
color: var(--color-green-dark);
background: var(--color-green-light);
border-color: var(--color-green-border);
}
@@ -54,7 +54,7 @@
border-color: var(--color-amber-border);
}
.sui-status--danger {
color: var(--color-red);
color: var(--color-red-dark);
background: var(--color-red-light);
border-color: var(--color-red-border);
}
@@ -64,7 +64,7 @@
border-color: var(--color-blue-border);
}
.sui-status--purple {
color: var(--color-purple);
color: var(--color-purple-dark);
background: var(--color-purple-light);
border-color: var(--color-purple-border);
}
@@ -0,0 +1,263 @@
// UI shell for the contrast audit (dev/QA tool). Scanning lives in ./scan;
// coverage is whatever has a Storybook story, not the live app.
import { useCallback, useEffect, useRef, useState } from "react";
import {
type Finding,
type Progress,
MAX_ROWS,
runScan,
} from "@app/ui/contrastAudit/scan";
import { copyToClipboard } from "@app/ui/contrastAudit/copy";
import { FindingsTable } from "@app/ui/contrastAudit/FindingsTable";
import {
btnDanger,
btnGhost,
btnPrimary,
controlGroup,
} from "@app/ui/contrastAudit/styles";
const DEFAULT_THRESHOLD = 2.5;
type Status = "idle" | "scanning" | "done" | "stopped" | "failed";
export function ContrastAuditPanel() {
const frameRef = useRef<HTMLIFrameElement>(null);
const stopRef = useRef(false);
const [theme, setTheme] = useState<"light" | "dark">("light");
const [oncePerComponent, setOncePerComponent] = useState(true);
const [threshold, setThreshold] = useState(DEFAULT_THRESHOLD);
const [status, setStatus] = useState<Status>("idle");
const [progress, setProgress] = useState<Progress>({
done: 0,
total: 0,
current: "",
});
const [findings, setFindings] = useState<Finding[]>([]);
const [copied, setCopied] = useState(false);
useEffect(() => {
const t = document.documentElement.getAttribute("data-theme");
if (t === "dark" || t === "light") setTheme(t);
}, []);
const scan = useCallback(async () => {
const iframe = frameRef.current;
if (!iframe) return;
stopRef.current = false;
setStatus("scanning");
setFindings([]);
setProgress({ done: 0, total: 0, current: "" });
const outcome = await runScan(iframe, {
theme,
oncePerComponent,
shouldStop: () => stopRef.current,
onProgress: setProgress,
onFindings: setFindings,
});
setStatus(outcome);
}, [theme, oncePerComponent]);
const shown = findings.filter((f) => f.ratio <= threshold);
const copyList = () => {
const header = "ratio\tfg\tbg\tcount\tcomponent";
const lines = shown.map(
(f) =>
`${f.ratio.toFixed(2)}\t${f.fg}\t${f.bg}\t${f.count}\t${f.storyTitle}`,
);
copyToClipboard([header, ...lines].join("\n"), () => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
};
const pct = progress.total
? Math.round((progress.done / progress.total) * 100)
: 0;
return (
// Own background — Storybook's canvas isn't theme-aware here.
<div
style={{
maxWidth: 1080,
color: "var(--c-text, #111)",
background: "var(--c-bg, #fff)",
padding: 16,
borderRadius: 8,
}}
>
<div>
<h3 style={{ margin: "0 0 2px" }}>Storybook contrast audit</h3>
<span style={{ opacity: 0.6, fontSize: 13 }}>
text colour vs. the colour it overlays, across every Storybook story
coverage is whatever has a story, not the live app
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
margin: "14px 0 6px",
}}
>
{status === "scanning" ? (
<button
type="button"
style={btnDanger}
onClick={() => (stopRef.current = true)}
>
Stop
</button>
) : (
<button type="button" style={btnPrimary} onClick={scan}>
Scan stories
</button>
)}
<label style={controlGroup}>
<input
type="checkbox"
checked={oncePerComponent}
disabled={status === "scanning"}
onChange={(e) => setOncePerComponent(e.target.checked)}
/>
one variant / component
</label>
<label style={controlGroup}>
<span style={{ opacity: 0.75 }}>theme</span>
<select
value={theme}
disabled={status === "scanning"}
onChange={(e) => setTheme(e.target.value as "light" | "dark")}
style={{ fontSize: 13 }}
>
<option value="light">light</option>
<option value="dark">dark</option>
</select>
</label>
</div>
{/* Threshold slider + copy — filter the list to the worst offenders. */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
margin: "0 0 12px",
fontSize: 13,
flexWrap: "wrap",
}}
>
<label style={controlGroup}>
<span style={{ opacity: 0.75 }}>
show ratio {" "}
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
{threshold.toFixed(1)}:1
</strong>
</span>
<input
type="range"
min={1}
max={4.5}
step={0.1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
style={{ width: 180 }}
/>
</label>
<span style={{ opacity: 0.6 }}>
<strong style={{ color: "var(--c-text, #111)" }}>
{shown.length}
</strong>{" "}
shown · {findings.length} found
</span>
<button
type="button"
onClick={copyList}
disabled={shown.length === 0}
style={{ ...btnGhost(shown.length > 0), marginLeft: "auto" }}
>
{copied ? "Copied ✓" : `⧉ Copy ${shown.length} rows`}
</button>
</div>
<div style={{ margin: "6px 0 12px", fontSize: 13 }}>
{status === "idle" && (
<span style={{ opacity: 0.7 }}>
Click Scan stories it steps through each story in the frame
below and lists every text element whose colour is too close to its
fill, deduped per component + colour pair.
</span>
)}
{status === "failed" && (
<span style={{ color: "var(--color-red, #dc2626)" }}>
Scan couldnt start the Storybook preview didnt boot in time.
Check the console and try again.
</span>
)}
{(status === "scanning" ||
status === "done" ||
status === "stopped") && (
<div>
<div
style={{
height: 6,
borderRadius: 3,
background: "var(--c-border, #e5e7eb)",
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
background: "var(--color-blue, #3b82f6)",
transition: "width .2s",
}}
/>
</div>
<div style={{ marginTop: 6, opacity: 0.75 }}>
{status === "scanning" ? "Scanning" : "Scanned"} {progress.done}/
{progress.total}
{progress.current ? ` · ${progress.current}` : ""}
{status === "done" && " · done"}
{status === "stopped" && " · stopped"}
</div>
</div>
)}
</div>
{/* Visible scan frame — must be laid out for getBoundingClientRect. */}
<iframe
ref={frameRef}
title="contrast scan frame"
style={{
width: "100%",
height: 220,
border: "1px dashed var(--c-border, #ccc)",
borderRadius: 6,
marginBottom: 16,
background: "var(--c-bg, #fff)",
}}
/>
{shown.length > 0 && <FindingsTable rows={shown} />}
{findings.length > 0 && shown.length === 0 && (
<p style={{ fontSize: 12.5, opacity: 0.65 }}>
No findings at or below {threshold.toFixed(1)}:1 raise the slider to
see more.
</p>
)}
{findings.length >= MAX_ROWS && (
<p style={{ fontSize: 12.5, opacity: 0.65 }}>
Capped at the worst {MAX_ROWS} distinct component/colour pairs.
</p>
)}
</div>
);
}
@@ -0,0 +1,71 @@
// Audit results table. The Text cell renders in the finding's real colours.
import { type Finding } from "@app/ui/contrastAudit/scan";
import { cell, swatch } from "@app/ui/contrastAudit/styles";
export function FindingsTable({ rows }: { rows: Finding[] }) {
return (
<table style={{ borderCollapse: "collapse", width: "100%" }}>
<thead>
<tr>
<th style={cell}>Ratio</th>
<th style={cell}>Colors</th>
<th style={cell}>Text</th>
<th style={cell}>Component</th>
</tr>
</thead>
<tbody>
{rows.map((f, i) => (
<tr key={`${f.storyTitle}-${f.fg}-${f.bg}-${i}`}>
<td
style={{
...cell,
fontWeight: 700,
color:
f.ratio < 3
? "var(--color-red, #dc2626)"
: "var(--color-amber, #d97706)",
fontVariantNumeric: "tabular-nums",
}}
>
{f.ratio.toFixed(2)}
{f.count > 1 && (
<span style={{ opacity: 0.6, fontWeight: 400 }}>
{" "}
×{f.count}
</span>
)}
</td>
<td style={{ ...cell, whiteSpace: "nowrap" }}>
<span style={swatch(f.fg)} />
<code style={{ fontSize: 11 }}>{f.fg}</code> on{" "}
<span style={swatch(f.bg)} />
<code style={{ fontSize: 11 }}>{f.bg}</code>
</td>
<td style={cell}>
<span
style={{
color: f.fg,
background: f.bg,
padding: "1px 6px",
borderRadius: 4,
}}
>
{f.text || `<${f.tag}>`}
</span>
</td>
<td style={{ ...cell, fontSize: 12 }}>
<a
href={`?path=/story/${f.storyId}`}
target="_top"
style={{ color: "var(--color-blue, #2563eb)" }}
>
{f.storyTitle}
</a>
</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,47 @@
// Pure colour maths — no DOM, no React.
export interface Rgb {
r: number;
g: number;
b: number;
a: number;
}
export function parseColor(value: string): Rgb {
const n = value.match(/[\d.]+/g)?.map(Number) ?? [];
// color-mix() resolves to `color(srgb r g b [/ a])` (01); rgb()/rgba() 0255.
const scale = value.trimStart().startsWith("color(") ? 255 : 1;
return {
r: (n[0] ?? 0) * scale,
g: (n[1] ?? 0) * scale,
b: (n[2] ?? 0) * scale,
a: n[3] ?? 1,
};
}
// Composite a (possibly translucent) foreground over an opaque background.
export function over(fg: Rgb, bg: Rgb): Rgb {
return {
r: fg.r * fg.a + bg.r * (1 - fg.a),
g: fg.g * fg.a + bg.g * (1 - fg.a),
b: fg.b * fg.a + bg.b * (1 - fg.a),
a: 1,
};
}
export function luminance({ r, g, b }: Rgb): number {
const f = (v: number) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
export function contrastRatio(a: Rgb, b: Rgb): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
export const hex = ({ r, g, b }: Rgb) =>
"#" +
[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
@@ -0,0 +1,27 @@
// Clipboard helper: prefer the async Clipboard API, fall back to a hidden
// textarea + execCommand where it's unavailable or blocked.
function fallbackCopy(text: string, onDone: () => void): void {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
onDone();
} finally {
ta.remove();
}
}
export function copyToClipboard(text: string, onDone: () => void): void {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(text)
.then(onDone, () => fallbackCopy(text, onDone));
} else {
fallbackCopy(text, onDone);
}
}
@@ -0,0 +1,291 @@
// Scans each rendered story's DOM (text colour vs. the colour it overlays) and
// drives Storybook to switch stories in place. The panel calls runScan().
import {
type Rgb,
parseColor,
over,
contrastRatio,
hex,
} from "@app/ui/contrastAudit/contrast";
export interface Finding {
ratio: number;
floor: number;
fg: string;
bg: string;
text: string;
tag: string;
storyId: string;
storyTitle: string;
count: number;
}
export interface StoryEntry {
id: string;
title: string;
name: string;
type: string;
}
export interface Progress {
done: number;
total: number;
current: string;
}
export const LOAD_TIMEOUT_MS = 30000; // cold Storybook boot can be slow
export const MAX_ROWS = 600;
const RENDER_TIMEOUT_MS = 3000; // cap on waiting for a story's render event
const RENDER_GRACE_MS = 150; // let async content + layout settle after render
// Nearest ancestor background the element overlays, compositing translucent
// layers. Null when a gradient/image sits behind the text (can't judge it).
function effectiveBg(el: Element, win: Window): Rgb | null {
let node: Element | null = el;
let acc: Rgb | null = null;
while (node) {
const cs = win.getComputedStyle(node);
if (cs.backgroundImage && cs.backgroundImage !== "none") return null;
const bg = parseColor(cs.backgroundColor);
if (bg.a > 0) {
acc = acc ? over(acc, bg) : bg;
if (acc.a >= 1) return acc;
}
node = node.parentElement;
}
const bodyCs = win.getComputedStyle(win.document.body);
if (bodyCs.backgroundImage && bodyCs.backgroundImage !== "none") return null;
const body = parseColor(bodyCs.backgroundColor);
const base = body.a >= 1 ? body : { r: 255, g: 255, b: 255, a: 1 };
return acc ? over(acc, base) : base;
}
function isVisible(el: Element, cs: CSSStyleDeclaration): boolean {
if (cs.display === "none" || cs.visibility === "hidden") return false;
if (Number(cs.opacity) === 0) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
function hasDirectText(el: Element): boolean {
for (const n of el.childNodes)
if (n.nodeType === Node.TEXT_NODE && (n.textContent ?? "").trim())
return true;
return false;
}
function scanDoc(
win: Window,
story: StoryEntry,
push: (f: Omit<Finding, "count">) => void,
): void {
const els = win.document.body.querySelectorAll("*");
for (const el of els) {
// svg uses `fill`, not `color`; match by namespace (realm-safe).
if (el.namespaceURI === "http://www.w3.org/2000/svg") continue;
if (!hasDirectText(el)) continue;
const cs = win.getComputedStyle(el);
if (!isVisible(el, cs)) continue;
const bg = effectiveBg(el, win);
if (!bg) continue;
let fg = parseColor(cs.color);
if (fg.a < 1) fg = over(fg, bg);
const ratio = contrastRatio(fg, bg);
const fs = parseFloat(cs.fontSize) || 16;
const bold = cs.fontWeight === "bold" || Number(cs.fontWeight) >= 700;
const large = fs >= 24 || (fs >= 18.66 && bold);
const floor = large ? 3.0 : 4.5;
if (ratio >= floor) continue;
const text = (el.textContent ?? "")
.trim()
.replace(/\s+/g, " ")
.slice(0, 60);
push({
ratio,
floor,
fg: hex(fg),
bg: hex(bg),
text,
tag: el.tagName.toLowerCase(),
storyId: story.id,
storyTitle: story.title,
});
}
}
function loadStory(
iframe: HTMLIFrameElement,
id: string,
theme: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const to = window.setTimeout(
() => reject(new Error("timeout")),
LOAD_TIMEOUT_MS,
);
const onload = () => {
window.clearTimeout(to);
iframe.removeEventListener("load", onload);
resolve();
};
iframe.addEventListener("load", onload);
// URLSearchParams encodes each value; theme clamped to a known token.
const safeTheme = theme === "dark" ? "dark" : "light";
const qs = new URLSearchParams({
id,
globals: `theme:${safeTheme}`,
viewMode: "story",
});
iframe.src = `iframe.html?${qs.toString()}`;
});
}
// Preview internals used to switch stories without reloading. onSetCurrentStory
// re-renders locally without broadcasting (so the parent preview isn't
// navigated); the channel is only listened to, never emitted on.
interface SbPreview {
onSetCurrentStory(o: { storyId: string; viewMode: string }): void;
}
interface SbChannel {
on(event: string, listener: () => void): void;
off(event: string, listener: () => void): void;
}
type SbWindow = Window & {
__STORYBOOK_PREVIEW__?: SbPreview;
__STORYBOOK_ADDONS_CHANNEL__?: SbChannel;
};
const RENDER_EVENTS = ["storyRendered", "storyMissing", "storyErrored"];
// Re-assert the selected theme before measuring, in case a story left a
// different scheme on <html> (its theme decorator runs a frame late).
function applyTheme(win: Window, theme: string): void {
const root = win.document.documentElement;
root.setAttribute("data-app-theme", "custom");
root.setAttribute("data-accent", "default");
root.setAttribute("data-theme", theme);
root.setAttribute("data-mantine-color-scheme", theme);
}
// The runtime boots asynchronously after the iframe's load event.
function waitForPreview(win: SbWindow, timeoutMs = 8000): Promise<boolean> {
return new Promise((resolve) => {
const start = performance.now();
const tick = () => {
if (win.__STORYBOOK_PREVIEW__) return resolve(true);
if (performance.now() - start > timeoutMs) return resolve(false);
window.setTimeout(tick, 50);
};
tick();
});
}
// Switch to `id` and resolve on the render event (or cap) so data-backed
// stories are measured with content, not mid-spinner.
function renderStory(
preview: SbPreview,
channel: SbChannel,
id: string,
): Promise<void> {
return new Promise((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
for (const ev of RENDER_EVENTS) channel.off(ev, finish);
window.setTimeout(resolve, RENDER_GRACE_MS);
};
for (const ev of RENDER_EVENTS) channel.on(ev, finish);
try {
preview.onSetCurrentStory({ storyId: id, viewMode: "story" });
} catch {
finish();
return;
}
window.setTimeout(finish, RENDER_TIMEOUT_MS);
});
}
export interface ScanOptions {
theme: string;
oncePerComponent: boolean;
shouldStop: () => boolean;
onProgress: (p: Progress) => void;
onFindings: (findings: Finding[]) => void;
}
export type ScanOutcome = "done" | "stopped" | "failed";
// Fetch the story index, boot the preview once, then scan each story and stream
// deduped findings.
export async function runScan(
iframe: HTMLIFrameElement,
opts: ScanOptions,
): Promise<ScanOutcome> {
let stories: StoryEntry[];
try {
const index = (await fetch("index.json").then((r) => r.json())) as {
entries: Record<string, StoryEntry>;
};
stories = Object.values(index.entries).filter((e) => e.type === "story");
} catch {
return "failed";
}
if (opts.oncePerComponent) {
const seen = new Set<string>();
stories = stories.filter((s) =>
seen.has(s.title) ? false : (seen.add(s.title), true),
);
}
if (stories.length === 0) return "done";
opts.onProgress({
done: 0,
total: stories.length,
current: "booting preview…",
});
// Boot once, then switch stories in place (the reboot is the expensive part).
try {
await loadStory(iframe, stories[0].id, opts.theme);
} catch {
return "failed";
}
const win = iframe.contentWindow as SbWindow | null;
if (!win || !(await waitForPreview(win))) return "failed";
const preview = win.__STORYBOOK_PREVIEW__;
const channel = win.__STORYBOOK_ADDONS_CHANNEL__;
if (!preview || !channel) return "failed";
// Dedupe per component + colour pair; count = occurrences.
const bySig = new Map<string, Finding>();
for (let i = 0; i < stories.length; i++) {
if (opts.shouldStop()) return "stopped";
const story = stories[i];
opts.onProgress({ done: i, total: stories.length, current: story.title });
try {
await renderStory(preview, channel, story.id);
if (!win.document?.body) continue;
applyTheme(win, opts.theme);
scanDoc(win, story, (f) => {
const sig = `${f.storyTitle}|${f.fg}|${f.bg}`;
const existing = bySig.get(sig);
if (existing) existing.count += 1;
else bySig.set(sig, { ...f, count: 1 });
});
} catch {
// story failed to render — skip
}
opts.onFindings(
[...bySig.values()]
.sort(
(a, b) =>
a.ratio - b.ratio || a.storyTitle.localeCompare(b.storyTitle),
)
.slice(0, MAX_ROWS),
);
}
opts.onProgress({ done: stories.length, total: stories.length, current: "" });
return "done";
}
@@ -0,0 +1,65 @@
// Inline styles for the audit panel (dev-only, no shipped stylesheet).
import { type CSSProperties } from "react";
export const cell: CSSProperties = {
padding: "8px 10px",
borderBottom: "1px solid var(--c-border, #e5e7eb)",
textAlign: "left",
verticalAlign: "top",
fontSize: 13,
};
export const swatch = (c: string): CSSProperties => ({
display: "inline-block",
width: 12,
height: 12,
borderRadius: 3,
background: c,
border: "1px solid var(--c-border, #ccc)",
marginRight: 6,
verticalAlign: "middle",
});
const btnBase: CSSProperties = {
padding: "8px 16px",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
border: "1px solid transparent",
lineHeight: 1,
};
export const btnPrimary: CSSProperties = {
...btnBase,
background: "var(--color-blue, #3b82f6)",
color: "#fff",
};
export const btnDanger: CSSProperties = {
...btnBase,
background: "var(--color-red, #dc2626)",
color: "#fff",
};
export const btnGhost = (enabled: boolean): CSSProperties => ({
...btnBase,
fontWeight: 500,
background: "var(--c-surface, #fff)",
color: "inherit",
border: "1px solid var(--c-border, #d4d4d8)",
cursor: enabled ? "pointer" : "not-allowed",
opacity: enabled ? 1 : 0.5,
});
export const controlGroup: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
borderRadius: 8,
background: "var(--c-surface-sunken, var(--color-bg-subtle, #f3f4f6))",
border: "1px solid var(--c-border, #e5e7eb)",
fontSize: 13,
};