feat(lint): enforce zero hardcoded colour across all source CSS

Add a blocking 'css-colors' mode to theme-lint that flags any hardcoded
colour in source .css under editor/src (primitives.css + generated
output.css exempt). File list is sourced from 'git ls-files' rather than a
directory walk, so there's no readdir→readFile path; comments and structural
black/white/transparent are ignored. Wired into 'task frontend:lint:colors'.

Update theme README + script header for the expanded palette and the now
app-wide enforcement.
This commit is contained in:
EthanHealy01
2026-07-13 15:08:03 +01:00
parent 6800db8029
commit 544a1f02eb
3 changed files with 91 additions and 16 deletions
+2 -1
View File
@@ -206,10 +206,11 @@ tasks:
- task: lint:colors
lint:colors:
desc: "Enforce theme tokens — colours in core/theme route through the palette"
desc: "Enforce theme tokens — all CSS colours route through the palette"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs
- node editor/scripts/lint/theme-lint.mjs css-colors
contrast:
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
+78 -5
View File
@@ -4,16 +4,16 @@
// node theme-lint.mjs enforce: literal colours live ONLY in
// primitives.css; colors/dimensions must
// reference tokens; no duplicate primitives.
// (blocking)
// Scope: core/theme/. (blocking)
// node theme-lint.mjs css-colors enforce: NO hardcoded colour in any source
// .css (primitives.css + generated output.css
// exempt). Scope: all editor/src. (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 { execSync } from "node:child_process";
import { relative, resolve, join } from "node:path";
const THEME = resolve(process.cwd(), "editor/src/core/theme");
@@ -306,12 +306,85 @@ function reportContrast() {
);
}
// ── css-colors (blocking): no hardcoded colour in ANY source .css ────────────
// App-wide guard that source CSS routes every colour through the palette. The
// file list comes from `git ls-files` (a VCS query over tracked, in-repo paths)
// — never a directory walk feeding a read — so there's no readdir→readFile path.
// primitives.css (the literal home) and generated output.css are exempt.
function checkAppCss() {
const EXEMPT = /(?:^|\/)(?:primitives\.css|output\.css)$/;
const listed = execSync("git ls-files -- editor/src", {
encoding: "utf8",
})
.split("\n")
.map((l) => l.trim())
.filter((l) => l && l.endsWith(".css") && !EXEMPT.test(l));
const violations = [];
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
for (const rel of listed) {
const text = stripComments(readFileSync(rel, "utf8"));
for (const re of [HEX_RE, FUNC_RE]) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (/var\(/i.test(m[0])) continue; // rgb()/color-mix wrapping a token
const norm = normalizeColor(m[0]);
if (isStructuralColor(norm)) continue;
violations.push({
file: rel,
line: lineOf(text, m.index),
msg: `raw colour ${m[0]} — define it in primitives.css and use var(--p-…)`,
});
}
}
text.split("\n").forEach((line, i) => {
const colon = line.indexOf(":");
if (colon < 0 || /[{}]/.test(line)) return;
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(--p-…)`,
});
}
});
}
return violations;
}
// ── CLI ──────────────────────────────────────────────────────────────────────
if (process.argv.includes("contrast")) {
reportContrast();
process.exit(0); // never blocks
}
if (process.argv.includes("css-colors")) {
const violations = checkAppCss();
if (violations.length) {
console.error(
`\n✖ theme-lint css-colors: ${violations.length} hardcoded colour(s) in source CSS:\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 css-colors: source CSS is free of hardcoded colour",
);
process.exit(0);
}
const violations = check();
if (violations.length) {
console.error(
+11 -10
View File
@@ -10,17 +10,17 @@ Read this before touching colours or theming anywhere in the frontend.
2. **The only file allowed to contain literal colours is `primitives.css`.** If you need a new hue, add it there first, then reference it.
3. Structural `black` / `white` / `transparent` (shadows, scrims, overlays) are allowed anywhere.
4. Colours must adapt to light/dark automatically. If you're reaching for a hardcoded colour "just for dark mode", you're doing it wrong — pick the right `--c-*` token.
5. `task frontend:lint:colors` enforces rules 1–3 **inside `core/theme/`** (see [Linter](#linter)).
5. `task frontend:lint:colors` enforces rules 1–3 across **all source CSS** (`primitives.css` + generated `output.css` exempt), and additionally guards the `core/theme/` token layer (see [Linter](#linter)).
## Files
| File | Role |
|---|---|
| `primitives.css` | **The palette.** 41 literal colours (`--p-*`) — one neutral ramp (`--p-gray-*` light, `--p-zinc-*` dark) + status hues (blue/green/amber/red). The ONLY place literals live. |
| `colors.css` | **Semantic tokens** (`--c-*`), mapped from primitives per theme. This is what you should reference — always. |
| `dimensions.css` | Non-colour tokens: spacing, radius, z-index, type, motion. |
| `index.css` | Barrel that `@import`s the above. Imported by `ThemeProvider` (and Storybook). |
| `mantineTheme.ts` | Mantine theme object wiring. |
| File | Role |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primitives.css` | **The palette.** All literal colours (`--p-*`): the core neutral ramp (`--p-gray-*` light, `--p-zinc-*` dark) + status hues (blue/green/amber/red), plus an **extended palette** section of fixed brand / data-viz / illustration / vendor-OS colours (azure, Stirling red, violet/indigo, cyan, pink, emerald, slate, navy, Notion paper, art fills, …). The ONLY place literals live. |
| `colors.css` | **Semantic tokens** (`--c-*`), mapped from primitives per theme. This is what you should reference — always. |
| `dimensions.css` | Non-colour tokens: spacing, radius, z-index, type, motion. |
| `index.css` | Barrel that `@import`s the above. Imported by `ThemeProvider` (and Storybook). |
| `mantineTheme.ts` | Mantine theme object wiring. |
The old `compat.css` legacy-alias layer has been **removed** — every component references `--c-*` directly now. Two **legacy** definition files still exist outside this folder and are being phased out — prefer `--c-*` over their tokens, and don't add to them:
`core/styles/theme.css` (editor `--bg-*`/`--text-*` vocab) and `core/tokens/tokens.css` (SUI `--color-*` vocab, gradients, code palette).
@@ -60,10 +60,11 @@ The FAB / logo mark is a deliberate exception: it's pinned to white (`--p-white`
One file: `editor/scripts/lint/theme-lint.mjs` (no baseline). Run via `task frontend:lint:colors` (part of `task frontend:lint`).
- **Default (blocking):** enforces "literals only in `primitives.css`; everything else in `core/theme/` references tokens; no duplicate primitives." Scope is deliberately just `core/theme/` — the layer this owns, which is clean.
- **Default (blocking):** enforces "literals only in `primitives.css`; everything else in `core/theme/` references tokens; no duplicate primitives." Scope: `core/theme/`.
- **`css-colors` (blocking):** enforces **no hardcoded colour in any source `.css`** across `editor/src` — `primitives.css` (the literal home) and generated `output.css` are the only exemptions. The file list comes from `git ls-files` (not a directory walk), and comments/structural black-white-transparent are ignored.
- **`node theme-lint.mjs contrast`** (task `frontend:contrast`, non-blocking): WCAG contrast report for text-on-surface / on-primary pairs per theme.
App-wide "no hardcoded colours in components" is **not** enforced yet (there are 260+ legacy sites); that's a separate migration. Don't add new hardcoded colours regardless.
Both blocking modes run under `task frontend:lint:colors`. Source CSS is at **zero** hardcoded colour; keep it there — add the literal to `primitives.css` and reference `var(--p-…)`. (Colour literals in TS/TSX that drive canvas/PDF/pdfium rendering are out of scope — those APIs take numbers, not CSS vars.)
## Gotchas