## What this does Consolidates the frontend's colour/theme system into a small, well-defined token layer and reworks the theme picker. The goal was a minimal, scalable set of semantic tokens that the editor **and** the Processor/portal (and Storybook) all share, plus a theme model that's easy to reason about. ## Token architecture (`core/theme/`) A four-file layer, imported once via `index.css`: | File | Role | |---|---| | `primitives.css` | The raw palette — the **only** place literal colours live (neutral ramps `--p-gray-*`/`--p-zinc-*` + status hues). | | `colors.css` | ~21 semantic `--c-*` tokens (surfaces, text, borders, primary, status) mapped from primitives per theme. **Reference these.** | | `compat.css` | Legacy names (`--bg-*`, `--text-*`, `--color-*`) aliased onto `--c-*` via `:root:root` so ~200 existing files keep working. | | `dimensions.css` | All non-colour tokens (spacing, radius, z-index, type, motion) — single source, resolving prior collisions. | A blocking linter (`scripts/lint/theme-lint.mjs`, run in `frontend:lint`) enforces "literals only in `primitives.css`" within `core/theme/`, and has a non-blocking WCAG contrast report. See `core/theme/README.md`. ## Theme model - **Mode** (`light` / `dark` / `system`) and **accent** are independent. Each mode has its own accent (`lightPrimary` / `darkPrimary`). - The editor is always `data-app-theme="custom"`; `ThemeProvider` injects the accent as `--user-primary` and sets `data-accent`. - **Two accent states:** - A **colour** (preset or custom hex) → tints every surface that hue (whole-app theming). - The **`default`** sentinel → neutral surfaces (white/grey light, zinc black/grey dark) with blue buttons, no tint. (`data-accent="default"` opts surfaces out of the tint.) - Accent contrast guardrails (`utils/customPrimary.ts`): lightness clamps so an accent can't collapse into the base, a contrast-picked on-primary foreground, and an accent-as-foreground variant so accent text is never dark-on-dark. ## Theme picker (Settings → General) - 3×5 grid: a distinct **Default** icon chip (not a colour) + 14 curated accents, in a dropdown per mode. - **Custom** colour via the shared `ColorInput`, with a live gamut clamp (`clampValue`) that refuses white/grey/black — the picker handle sticks at the boundary and preserves the working hue at achromatic extremes. - "Restore theme to default" resets both modes. ## Other - Dark mode is a true neutral zinc (no navy "midnight" tint); the Mantine dark ramp and Tailwind dark channels were neutralised to match. - Pre-paint inline script in `index.html` applies theme + accent before first paint (no FOUC); portal and editor now share the same `preferences.theme` source of truth. - High-visibility surfaces migrated to tokens (FAB, landing upload buttons, portal hero banners); scattered per-component colour swaps were intentionally **left for a follow-up** to keep this PR focused. ## Testing - `task frontend:check:all` (typecheck all variants + eslint + prettier + colour-lint) green. - Verified light/dark, default vs tinted accents, and the custom clamp via computed styles in the dev preview. > Note: the `prerender-og` build step failing in the e2e/deploy jobs is unrelated to this diff — it's in `vite.config.ts` (untouched here) and builds cleanly locally.
330 lines
12 KiB
JavaScript
330 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// Theme colour lint — guards the theme SYSTEM (core/theme/). Two modes:
|
|
//
|
|
// node theme-lint.mjs enforce: literal colours live ONLY in
|
|
// primitives.css; colors/compat/dimensions must
|
|
// reference tokens; no duplicate primitives.
|
|
// (blocking)
|
|
// node theme-lint.mjs contrast warn-only WCAG contrast report (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
|
|
// colours" across the whole app (260+ existing sites) is a separate migration.
|
|
//
|
|
// Structural black / white / transparent (shadows, scrims) are always allowed.
|
|
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { relative, resolve, join } from "node:path";
|
|
|
|
const THEME = resolve(process.cwd(), "editor/src/core/theme");
|
|
const PRIMITIVES = "editor/src/core/theme/primitives.css";
|
|
|
|
// Fixed list of theme CSS files to check, so every read takes a constant path
|
|
// (no directory-listing feeding into a file read). readdir is used only to fail
|
|
// if a new .css is added without being registered — coverage can't silently
|
|
// lapse — but its output is never passed to readFileSync.
|
|
const THEME_FILES = [
|
|
"primitives.css",
|
|
"colors.css",
|
|
"compat.css",
|
|
"dimensions.css",
|
|
"index.css",
|
|
];
|
|
|
|
// ── colour helpers ─────────────────────────────────────────────────────────
|
|
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
|
|
const FUNC_RE = /\b(?:rgba?|hsla?)\(\s*[^)]*\)/g;
|
|
const NAMED_RE =
|
|
/\b(?:white|black|red|green|blue|orange|yellow|purple|gray|grey|silver|transparent)\b/g;
|
|
|
|
function expandHex(hex) {
|
|
let h = hex.slice(1).toLowerCase();
|
|
if (h.length === 3) h = [...h].map((c) => c + c).join("");
|
|
if (h.length === 4) h = [...h].map((c) => c + c).join("");
|
|
return "#" + h;
|
|
}
|
|
function normalizeColor(raw) {
|
|
const s = raw.trim().toLowerCase();
|
|
if (s.startsWith("#")) return expandHex(s);
|
|
const nums = s.match(/[\d.]+%?/g);
|
|
if (!nums) return s;
|
|
return `${s.startsWith("hsl") ? "hsl" : "rgb"}(${nums.join(",")})`;
|
|
}
|
|
function isStructuralColor(norm) {
|
|
return (
|
|
norm === "transparent" ||
|
|
/^#000000(00)?$/.test(norm) ||
|
|
/^#ffffff(ff)?$/.test(norm) ||
|
|
/^rgb\(0,0,0[,)]/.test(norm) ||
|
|
/^rgb\(255,255,255[,)]/.test(norm)
|
|
);
|
|
}
|
|
function isStructuralName(name) {
|
|
return /^(?:white|black|transparent)$/i.test(name.trim());
|
|
}
|
|
function stripComments(text) {
|
|
return text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
|
|
}
|
|
|
|
// ── enforce: literals only in primitives.css, no duplicate primitives ────────
|
|
function check() {
|
|
const violations = [];
|
|
const primitiveValues = new Map();
|
|
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
|
|
|
|
// Fail if a theme .css exists that isn't registered above (readdir is only
|
|
// compared here — never used to build a path passed to readFileSync).
|
|
const known = new Set(THEME_FILES);
|
|
for (const name of readdirSync(THEME)) {
|
|
if (name.endsWith(".css") && !known.has(name)) {
|
|
violations.push({
|
|
file: relative(process.cwd(), join(THEME, name)),
|
|
line: 1,
|
|
msg: `unregistered theme CSS — add "${name}" to THEME_FILES in theme-lint.mjs`,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const name of THEME_FILES) {
|
|
const rel = relative(process.cwd(), join(THEME, name));
|
|
const isPrimitives = rel === PRIMITIVES;
|
|
const text = stripComments(readFileSync(join(THEME, name), "utf8"));
|
|
|
|
for (const re of [HEX_RE, FUNC_RE]) {
|
|
re.lastIndex = 0;
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
const norm = normalizeColor(m[0]);
|
|
if (isStructuralColor(norm)) continue;
|
|
if (isPrimitives) {
|
|
if (primitiveValues.has(norm)) {
|
|
violations.push({
|
|
file: rel,
|
|
line: lineOf(text, m.index),
|
|
msg: `duplicate primitive value ${norm} (also ${primitiveValues.get(norm)})`,
|
|
});
|
|
} else {
|
|
primitiveValues.set(norm, m[0]);
|
|
}
|
|
} else {
|
|
violations.push({
|
|
file: rel,
|
|
line: lineOf(text, m.index),
|
|
msg: `raw colour ${m[0]} — define it in primitives.css and use var()`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Named colours, only in value position.
|
|
if (!isPrimitives) {
|
|
text.split("\n").forEach((line, i) => {
|
|
const colon = line.indexOf(":");
|
|
if (colon < 0 || /[{}]/.test(line)) return;
|
|
// Property must be a lone identifier — a custom prop (--x) OR a standard
|
|
// property (color, border) — so `color: red` is checked, not just tokens,
|
|
// while selectors (`.foo:hover`) with a colon are skipped.
|
|
if (!/^\s*(?:--)?[a-z][a-z0-9-]*\s*$/i.test(line.slice(0, colon)))
|
|
return;
|
|
const value = line
|
|
.slice(colon + 1)
|
|
.replace(/--[a-z0-9-]+/gi, " ")
|
|
.replace(/url\([^)]*\)/g, " ")
|
|
.replace(/["'][^"']*["']/g, " ");
|
|
for (const nm of value.match(NAMED_RE) || []) {
|
|
if (isStructuralName(nm)) continue;
|
|
violations.push({
|
|
file: rel,
|
|
line: i + 1,
|
|
msg: `named colour "${nm}" — define it in primitives.css and use var()`,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
// ── contrast report (warn-only): resolve --c-* per theme, check legibility ───
|
|
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 blocks = [];
|
|
for (const m of colorsCss.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
|
const decls = {};
|
|
for (const d of m[2].matchAll(/(--c-[a-z0-9-]+)\s*:\s*([^;]+);/g))
|
|
decls[d[1]] = d[2].trim();
|
|
if (Object.keys(decls).length)
|
|
blocks.push({ selector: m[1].trim(), decls });
|
|
}
|
|
// The editor always renders data-app-theme="custom"; the accent (--user-*) is
|
|
// injected at runtime, so seed the DEFAULT blue to resolve the custom tint
|
|
// statically. Themes = the ones that actually render: editor custom light/dark
|
|
// (data-app-theme="custom") and the portal's neutral dark (data-theme="dark").
|
|
const SEED = {
|
|
"--user-primary": "#3b82f6",
|
|
"--user-primary-on": "#ffffff",
|
|
"--user-accent-fg": "#3b82f6",
|
|
};
|
|
const pick = (re) => blocks.filter((b) => re.test(b.selector));
|
|
const lightBase = pick(/:root/);
|
|
const customBase = blocks.filter(
|
|
(b) =>
|
|
/app-theme="custom"/.test(b.selector) &&
|
|
!/color-scheme="dark"/.test(b.selector),
|
|
);
|
|
const customDark = pick(
|
|
/app-theme="custom"\]\[data-mantine-color-scheme="dark"/,
|
|
);
|
|
const midnight = pick(/data-theme="dark"/);
|
|
const themes = {
|
|
"editor light": [...lightBase, ...customBase],
|
|
"editor dark": [...lightBase, ...customBase, ...customDark],
|
|
"portal dark": [...lightBase, ...midnight],
|
|
};
|
|
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);
|
|
};
|
|
const contrast = (t1, t2, t) => {
|
|
const surface = resolve("--c-surface", t);
|
|
let a = resolve(t1, t);
|
|
let b = resolve(t2, t);
|
|
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);
|
|
};
|
|
const PAIRS = [
|
|
["--c-text", "--c-surface", 4.5],
|
|
["--c-text-muted", "--c-surface", 4.5],
|
|
["--c-text-subtle", "--c-surface", 4.5],
|
|
["--c-text", "--c-bg", 4.5],
|
|
["--c-text-on-primary", "--c-primary", 3.0],
|
|
];
|
|
let warnings = 0;
|
|
console.log("contrast report (warning-only, default accent):\n");
|
|
for (const name of Object.keys(themes)) {
|
|
const t = flatten(themes[name]);
|
|
console.log(` ${name}`);
|
|
for (const [t1, t2, floor] of PAIRS) {
|
|
const r = contrast(t1, t2, t);
|
|
if (r == null) {
|
|
console.log(` ? ${t1} on ${t2} (unresolved)`);
|
|
continue;
|
|
}
|
|
if (r < floor) warnings++;
|
|
console.log(
|
|
` ${r < floor ? "⚠ " : " "}${r.toFixed(2).padStart(5)} (floor ${floor}) ${t1} on ${t2}`,
|
|
);
|
|
}
|
|
console.log("");
|
|
}
|
|
console.log(
|
|
warnings
|
|
? `⚠ ${warnings} pair(s) below floor — review, not blocking.`
|
|
: "✓ all pairs clear their floor.",
|
|
);
|
|
}
|
|
|
|
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
if (process.argv.includes("contrast")) {
|
|
reportContrast();
|
|
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`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log(
|
|
"✓ theme-lint: core/theme colours all route through the primitive palette",
|
|
);
|