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:
@@ -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)"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user