diff --git a/theme/scripts/audit.mjs b/theme/scripts/audit.mjs index 02c586a..97e9552 100644 --- a/theme/scripts/audit.mjs +++ b/theme/scripts/audit.mjs @@ -57,9 +57,12 @@ * minified >=1e30px form) is sanctioned; every other non-zero * radius — including 50% — is flagged. * - * 5. rgb()/rgba()/hsl()/hsl() literals are checked too (hex is not - * the only escape hatch for off-token colors). var() references - * are always token paths and pass by construction. + * 5. rgb()/rgba()/hsl()/hsla() literals are checked too (hex is not + * the only escape hatch for off-token colors) — as are named color + * keywords (on color-bearing properties; currentcolor/inherit/ + * transparent stay sanctioned) and modern color functions — + * oklch()/oklab()/lab()/lch()/hwb()/color()/color-mix(). var() + * references are always token paths and pass by construction. * * Reuse (D-14 lineage): later UI phases extend IMPLEMENTATION_PAGES * with their pages and keep running `npm run audit` in CI/local @@ -361,6 +364,84 @@ function splitDecl(text) { /* ————— declaration checks ————— */ +/* CSS named colors (the full CSS Color keyword list, WR-03): a named + * keyword on a color-bearing property is an off-token color — the + * palette lives in tokens.css (D-07). currentcolor/transparent and + * the css-wide keywords (inherit/initial/unset/revert) are not in + * the list and stay sanctioned. */ +const NAMED_COLORS = new Set([ + 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', + 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', + 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', + 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', + 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', + 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', + 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', + 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', + 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', + 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', + 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', + 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', + 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', + 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', + 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', + 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', + 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', + 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', + 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', + 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', + 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', + 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', + 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', + 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', + 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', + 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', + 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', + 'whitesmoke', 'yellow', 'yellowgreen', +]); + +/* Color-bearing properties, shorthands included (WR-03): a bare named + * keyword in any of these values is a color choice. box-shadow and + * text-shadow are not listed — any non-none value is already forbidden + * outright by shadowViolations. */ +const COLORISH_PROPS = new Set([ + 'color', 'background', 'background-color', 'background-image', 'fill', + 'stroke', 'caret-color', 'outline', 'outline-color', 'border', + 'border-top', 'border-right', 'border-bottom', 'border-left', + 'border-x', 'border-y', 'border-block-start', 'border-block-end', + 'border-inline-start', 'border-inline-end', 'border-color', + 'border-top-color', 'border-right-color', 'border-bottom-color', + 'border-left-color', 'border-block-color', 'border-block-start-color', + 'border-block-end-color', 'border-inline-color', + 'border-inline-start-color', 'border-inline-end-color', + 'text-decoration', 'text-decoration-color', 'accent-color', +]); + +function namedColorViolations(prop, value) { + if (!COLORISH_PROPS.has(prop)) return []; + const bad = []; + for (const part of splitTopLevel(value, ',')) { + for (const tok of splitTopLevel(part, ' ')) { + const t = tok.trim().toLowerCase().replace(/[(),]+$/g, ''); + if (NAMED_COLORS.has(t)) bad.push(`named color keyword ${tok.trim()} — use var(--color-*) (Rams palette, D-07)`); + } + } + return bad; +} + +/* Modern color functions (CSS Color 4, WR-03): unambiguous color + * literals on any property — the same surface the hex check covers. + * var()-bearing values pass by construction (Tailwind's own color-mix + * emissions are var-driven). */ +function colorFunctionViolations(value) { + if (/var\(/i.test(value)) return []; + const bad = []; + for (const m of value.matchAll(/\b(?:oklch|oklab|lab|lch|hwb|color-mix|color)\(/gi)) { + bad.push(`off-token color function ${m[0].slice(0, -1)}(…) — color values derive from var(--color-*) (Rams palette, D-07)`); + } + return bad; +} + function literalHexViolations(value, tokens) { const found = []; const hexRe = /#([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; @@ -422,6 +503,7 @@ function radiusAllowed(value) { function shadowViolations(prop, value) { const v = value.trim().toLowerCase(); if (prop === 'box-shadow') return v === 'none' ? [] : ['box-shadow forbidden (Rams Shapes: no shadows)']; + if (prop === 'text-shadow') return v === 'none' ? [] : ['text-shadow forbidden (Rams Shapes: no shadows)']; if (prop === 'backdrop-filter') return v === 'none' ? [] : ['backdrop-filter forbidden (Rams Shapes: no glass)']; if (prop === 'filter') { return /blur\(|drop-shadow\(|var\(--tw-blur|var\(--tw-drop-shadow/i.test(value) @@ -437,7 +519,9 @@ function checkDeclaration(prop, value, tokens) { const violations = []; if (prop.startsWith('--tw-')) return violations; // framework plumbing registration violations.push(...literalHexViolations(value, tokens)); + violations.push(...colorFunctionViolations(value)); if (prop.startsWith('--')) return violations; // custom property: color literals above are the checkable surface + violations.push(...namedColorViolations(prop, value)); violations.push(...shadowViolations(prop, value)); if (prop === 'font') { /* WR-02: a literal font: shorthand embeds unvalidated weight/ diff --git a/theme/scripts/audit.test.mjs b/theme/scripts/audit.test.mjs index 8989910..d18f94d 100644 --- a/theme/scripts/audit.test.mjs +++ b/theme/scripts/audit.test.mjs @@ -37,6 +37,10 @@ * family (convention: font: var(--type-*)); var() * shorthands pass; familiesFromShorthand extracts * the family for the message → flagged (exit 1) + * T11 named color keywords, oklch()/oklab()/lab()/lch()/ + * hwb()/color()/color-mix(), text-shadow → flagged + * (exit 1); currentcolor/inherit/transparent stay + * sanctioned */ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; @@ -179,6 +183,23 @@ writeFileSync(fontShorthand, `/* literal font: shorthand — flagged regardless .c-font-shorthand-var { font: var(--type-body-md); } `); +/* T11 — color-evasion gaps (WR-03): named keywords, modern color + * functions, text-shadow — all previously passed the audit unflagged. */ +const colorEvasions = join(FIX, 'color-evasions.css'); +writeFileSync(colorEvasions, `/* every flagged declaration must be listed; sanctioned keywords pass */ +.k-named { color: red; } +.l-oklch { background: oklch(0.6 0.2 30); } +.m-colorfn { fill: color(display-p3 1 0 0); } +.n-hwb { stroke: hwb(90 10% 0); } +.o-lab { border-color: lab(50% 40 60); } +.p-lch { background-color: lch(50% 40 60); } +.q-text-shadow { text-shadow: 0 1px 0 red; } +/* sanctioned: css-wide + currentcolor/transparent stay clean */ +.r-ok { color: currentcolor; } +.s-ok { background: transparent; } +.t-ok { outline: 2px solid inherit; } +`); + /* ————— tests ————— */ // T1 — clean stylesheet exits 0 @@ -319,6 +340,21 @@ writeFileSync(fontShorthand, `/* literal font: shorthand — flagged regardless `out=${r.out.slice(0, 300)}`); } +// T11 — named colors, modern color functions, text-shadow flagged +{ + const r = runAudit([colorEvasions]); + const markers = ['color: red', 'oklch(0.6 0.2 30)', 'color(display-p3 1 0 0)', + 'hwb(90 10% 0)', 'lab(50% 40 60)', 'lch(50% 40 60)', 'text-shadow: 0 1px 0 red']; + const missing = markers.filter((m) => !r.out.includes(m)); + check('T11 color evasions → exit 1', r.status === 1, + `status=${r.status} error=${r.error}`); + check('T11 every evasion listed', missing.length === 0, + `missing markers: ${missing.join(', ')}`); + check('T11 sanctioned keywords pass (currentcolor/transparent/inherit)', + !/currentcolor/.test(r.out) && !/transparent/.test(r.out) && !/outline: 2px solid inherit/.test(r.out), + `out=${r.out.slice(0, 300)}`); +} + /* ————— report ————— */ console.log(`audit.test: ${passes.length} passed, ${failures.length} failed`); passes.forEach((p) => console.log(` PASS ${p}`));