diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index a6851ab9f0..1b887f42e5 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -211,6 +211,7 @@ tasks: cmds: - node editor/scripts/lint/theme-lint.mjs - node editor/scripts/lint/theme-lint.mjs css-colors + - node editor/scripts/lint/theme-lint.mjs code-colors contrast: desc: "Report low-contrast theme token pairs (warning only, never blocks)" diff --git a/frontend/color-migration-audit.md b/frontend/color-migration-audit.md deleted file mode 100644 index a3babacf04..0000000000 --- a/frontend/color-migration-audit.md +++ /dev/null @@ -1,38 +0,0 @@ -# Colour migration audit (temporary — remove before final merge) - -Tracks colours that were **not** tokenized during the consolidation, and the semantic tokens still needed to finish the job. Generated from the agent consolidation pass. - -## Needed new `--c-*` tokens (would let the flagged colours below migrate) - -| Token | Value / role | Wanted by | -|---|---|---| -| `--c-warning` | amber status (mid) | Payg status chips, Procurement pending-step/projection, billing spend-projection | -| `--c-warning-subtle` / `--c-success-subtle` / `--c-danger-subtle` (+ `-border`) | pale status alert bg/border (3-step chip ramp: bg / text / dot) | auth error/success boxes, AuthCallback, Payg/billing status chips | -| `--c-neutral-*` accent family (fill / hover / text / border / tint) | low-emphasis grey *fill* button (the neutrals are surface/text/border only, no fill) | `core/ui/accents.css` `.sui-acc-neutral` | - -## Genuinely-unique / fixed colours KEPT (with reasons) - -**Brand** -- `#0a8bff` azure + rgba tints — cloud billing (`Payg`/`SpendCapControl`/`UpgradeModal`/`billing.css .scc`). Distinct from `--c-primary`. -- `#af3434` / `#9a2e2e` / `#9c2f30` Stirling red — `auth.css`, `saas-auth.css`. -- `#8e3131`… red family + AI multi-hue gradient (`#8b5cf6`/`#6366f1`/`#22d3ee`…) — `core/ui/accents.css`. -- `#16213e` navy — portal hero CTA text (fixed dark on white CTA). -- OAuth provider treatments (`#0f172a`, `#eef2f7`, provider button chrome) — `auth.css`. - -**Data-viz / status (fixed by design)** -- Usage-bar / segment / dot gradients: `#0a8bff→#38bdf8`, `#8b5cf6`, `#06b6d4` — `billing.css`, `Payg.css`. -- Status-chip palette `#dcfce7`/`#fef3c7`/`#fee2e2` + dot rings — `billing.css`, `Payg.css` (marked "semantic, not theme-bound" in-file). -- Emerald `#10b981`/`#4ade80` (free tier), purple `#6c5ce7`, pink `#ec4899` — cloud billing. -- "viewed" teal/green `#10b981` / `--p-green-500` — `FilesPage.css`, `FileSidebarFileItem.css`. - -**Illustration / bespoke** -- File-card navy header banner `#3b4b6e` (light) / `#0d1020` (dark) — `FileEditor.module.css`; adaptive but no "banner surface" token — decision needed (dedicated token vs fixed brand navy). -- Onboarding hero tiles/gradients (`#0f1626`, `linear-gradient(#eef1fb…)`) + pin gold `#ffc107` — `FileEditor`/`InitialOnboardingModal`. -- `.portal-qb*` Notion light-only paper palette (`#37352f`, `#e3e1dc`, `#2383e2`…) — `Procurement.css`. -- QR/logo whites, Calendly white — various (must stay). - -**Per-theme, no token (OS-media / rgb-triplet contexts)** -- `AuthCallback.module.css` `@media (prefers-color-scheme: dark)` block — `--c-*` follow the *app* theme not the OS query, so can't use them here. -- `auth-theme.css` `--text-divider-*-rgb` navy triplets (consumed as `rgb(var(--x)/.4)`). - -**Structural** (allowed anywhere): `rgba(0,0,0,…)` / `rgba(255,255,255,…)` scrims/shadows/hovers; pure black/white; the deliberate flash-yellow animation. diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 42531f36ce..e7adabceec 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Theme colour lint — guards the theme SYSTEM (core/theme/). Two modes: +// Theme colour lint. Modes: // // node theme-lint.mjs enforce: literal colours live ONLY in // primitives.css; colors/dimensions must @@ -8,6 +8,12 @@ // 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 code-colors enforce: no hardcoded colour in TS/TSX DOM +// code. Default-deny with layered exemptions +// (structural; var()/readColor/canvas/pdf-lib +// contexts; `// theme-allow-color` opt-out; +// exempt PATHS for rendering/vendor/config). +// Scope: all editor/src. (blocking) // node theme-lint.mjs contrast warn-only WCAG contrast report (never blocks) // // Structural black / white / transparent (shadows, scrims) are always allowed. @@ -360,12 +366,114 @@ function checkAppCss() { return violations; } +// ── code-colors (blocking): no NEW hardcoded colour in TS/TSX DOM code ─────── +// TS/TSX can't be zeroed like CSS — canvas/PDF/pdfium rendering, colour maths, +// colour pickers, vendor brand and self-contained docs legitimately need +// numeric colour. So this is default-deny with layered exemptions: +// 1. structural black/white/transparent +// 2. detected safe CONTEXTS on the line (var()/readColor fallback, canvas +// assignment, pdf-lib 0..1 rgb) and an explicit `// theme-allow-color` opt-out +// 3. exempt PATHS — whole areas where colour literals are inherent +// Anything else (a raw colour in a normal DOM component) fails. File list from +// `git ls-files` (no directory walk). Comments and HTML entities are stripped. +const CODE_EXEMPT_PATH = [ + /Thumbnail|Overlay|DrawingCanvas|PageEditor|MobileScannerPage/, // canvas/PDF render + /[Pp]df|PDF|pixelCompare|\/compare\.ts$|customPrimary|accentColors/, + /validateSignature\/outputtedPDFSections|CenteredMessageSection|StatusBadgeSection/, + /\/viewer\/|Annotation|useViewerReadAloud|CommentsSidebar|\/constants\/search\.ts$|SignaturePreview/, + /ColorPicker|ColorControl|WatchedFolderManagementModal|watchedFolderPresets|fileColors|unifiedBackground|folder\.ts$|policyFolders/, // pickers/palettes as data + /OAuthButtons|oauthCallbackHtml/, // vendor brand / self-contained doc + /mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons/, // theme/config objects + /\/onboarding\//, // decorative onboarding illustration gradients + /addStamp|addWatermark|\/tooltips\//, // colour params fed to PDF + tooltip prose + /UpgradeBanner|AdminPlanSection/, // bespoke marketing/upsell banner palette + /\.test\.[jt]sx?$|\.stories\.[jt]sx?$|\/types\//, // tests, stories, type decls +]; +const CODE_HEX = + /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])/g; +const CODE_RGB = /rgba?\(([^)]*)\)/gi; +const codeStructuralHex = (h) => + /^#(?:000|fff|000f|ffff|000000|ffffff|00000000|ffffffff)$/i.test(h); +const CODE_SKIP_LINE = + /theme-allow-color|var\(\s*--[a-z0-9-]+\s*,|readColor\(|getPropertyValue|\.colors\.[a-z]+\?\.\[|fillStyle|strokeStyle|shadowColor|addColorStop|createLinearGradient|ctx\.|getContext/i; +function codeStripNonCode(text) { + return text + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) + .replace( + /(^|[^:])\/\/[^\n]*/g, + (m, p) => p + m.slice(p.length).replace(/[^\n]/g, " "), + ) + .replace(/&#\d+;/g, " "); +} +function codeRgbIsColour(inner) { + if (/var\(/.test(inner)) return false; + const p = inner + .split(/[, /]+/) + .map((x) => x.trim()) + .filter(Boolean); + if (!/^\d/.test(p[0] || "")) return false; + const nums = p.slice(0, 3).map(Number); + if (nums.some((n) => Number.isNaN(n))) return false; + if (nums.every((n) => n <= 1)) return false; // pdf-lib rgb(0..1) + const k = `${nums[0]},${nums[1]},${nums[2]}`; + return k !== "0,0,0" && k !== "255,255,255"; +} +function checkCodeColors() { + const files = execSync("git ls-files -- editor/src", { encoding: "utf8" }) + .split("\n") + .map((l) => l.trim()) + .filter( + (l) => + /\.(ts|tsx)$/.test(l) && !CODE_EXEMPT_PATH.some((re) => re.test(l)), + ); + const violations = []; + for (const rel of files) { + const raw = readFileSync(rel, "utf8"); + const rawLines = raw.split("\n"); + const text = codeStripNonCode(raw); + text.split("\n").forEach((line, i) => { + // marker lives in a comment (stripped from `line`), so test the raw line + if (/theme-allow-color/.test(rawLines[i] || "")) return; + if (CODE_SKIP_LINE.test(line)) return; + const hits = []; + for (const h of line.match(CODE_HEX) || []) + if (!codeStructuralHex(h)) hits.push(h); + for (const m of line.match(CODE_RGB) || []) + if (codeRgbIsColour(m.replace(/rgba?\(|\)/gi, ""))) hits.push(m); + for (const h of hits) + violations.push({ + file: rel, + line: i + 1, + msg: `hardcoded colour ${h} — use a var(--c-*/--p-*) token, or add \`// theme-allow-color \` if this must be a literal`, + }); + }); + } + return violations; +} + // ── CLI ────────────────────────────────────────────────────────────────────── if (process.argv.includes("contrast")) { reportContrast(); process.exit(0); // never blocks } +if (process.argv.includes("code-colors")) { + const violations = checkCodeColors(); + if (violations.length) { + console.error( + `\n✖ theme-lint code-colors: ${violations.length} hardcoded colour(s) in TS/TSX:\n`, + ); + for (const v of violations) + console.error(` ${v.file}:${v.line} ${v.msg}`); + console.error(""); + process.exit(1); + } + console.log( + "✓ theme-lint code-colors: no hardcoded colour in TS/TSX DOM code (rendering/vendor/config areas exempt)", + ); + process.exit(0); +} + if (process.argv.includes("css-colors")) { const violations = checkAppCss(); if (violations.length) { diff --git a/frontend/editor/src/core/theme/README.md b/frontend/editor/src/core/theme/README.md index 52e317256d..de414c681b 100644 --- a/frontend/editor/src/core/theme/README.md +++ b/frontend/editor/src/core/theme/README.md @@ -62,9 +62,10 @@ One file: `editor/scripts/lint/theme-lint.mjs` (no baseline). Run via `task fron - **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. +- **`code-colors` (blocking):** enforces **no hardcoded colour in TS/TSX DOM code**. TS/TSX can't be zeroed like CSS (canvas/PDF/pdfium rendering, colour maths, colour pickers, vendor brand and self-contained docs legitimately need numeric colour), so it's **default-deny with layered exemptions**: (1) structural black/white/transparent; (2) detected safe contexts on the line — `var(--x, …)` / `readColor(…)` fallbacks, canvas assignments (`fillStyle`/`ctx.`…), pdf-lib `rgb(0..1)`, and an explicit **`// theme-allow-color `** opt-out; (3) exempt **paths** for whole rendering/vendor/config/illustration areas (viewer/annotation, `*Thumbnail`/`*Overlay`, `*Pdf*`, colour pickers, `onboarding/`, `mantineTheme`/`theme.ts`, tests, stories, type decls, marketing banners). A raw colour anywhere else fails. Adding a new colour to a normal component → blocked; genuine exceptions get the marker. - **`node theme-lint.mjs contrast`** (task `frontend:contrast`, non-blocking): WCAG contrast report for text-on-surface / on-primary pairs per theme. -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.) +All three blocking modes run under `task frontend:lint:colors`. Source **CSS is at zero** hardcoded colour and **TS/TSX DOM code is clean** (only the exempt rendering/vendor/config areas hold literals). Keep it there — add the literal to `primitives.css` and reference `var(--p-…)`, or for an unavoidable TS/TSX literal add `// theme-allow-color `. ## Gotchas