mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb6ca6c2de | ||
|
|
a6a54db2af | ||
|
|
413005490c | ||
|
|
6ee2e3b232 | ||
|
|
bbf34088e6 | ||
|
|
50d6b049cf |
@@ -195,6 +195,9 @@ const preview: Preview = {
|
||||
options: {},
|
||||
test: "todo",
|
||||
},
|
||||
options: {
|
||||
storySort: { order: ["*", "Tools"] },
|
||||
},
|
||||
},
|
||||
globalTypes: {
|
||||
tier: {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 couldn’t start — the Storybook preview didn’t 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])` (0–1); rgb()/rgba() 0–255.
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user