From 733c9058c578e1d6a0e8215c67b9ec0f58eb7e6a Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 8 Sep 2026 20:46:42 +0000 Subject: [PATCH 1/3] CORE-2736: Extract the CSS colour audit engine into a publishable module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit added in CORE-2720 is ~250 lines of pure CSS parsing that knows nothing about ui-components. REX needed exactly the same thing and, because this lived inside a spec file where nothing could import it, got a second hand-written copy instead — and the two had diverged before either merged. Moves the parsing engine to src/theme/cssColors.ts so it compiles into dist and resolves as @openstax/ui-components/theme/cssColors. tokens.spec.ts keeps the ui-components layer: the palette index, the file walk, KNOWN_OFF_PALETTE and the assertions. The published surface is REX's shape rather than this repo's, because REX's is the superset: it carries the selector `context` its baseline ratchet identifies an occurrence by, and returns structured channels rather than a formatted key. Adopting it means the REX side is a delete-and-import with no call-site changes; the reverse would have left REX forking `declarations` to get `context` back, which is the duplication this ticket exists to remove. Behaviour here is unchanged — same 90 assertions over the same stylesheets, plus the property-context and hex-grammar cases the two copies' reviews turned up. The engine's own tests come across with it so the published surface is the tested surface. --- src/theme/cssColors.spec.ts | 400 +++++++++++++++++++++++++++++++ src/theme/cssColors.ts | 457 ++++++++++++++++++++++++++++++++++++ src/theme/tokens.spec.ts | 357 +++++----------------------- 3 files changed, 921 insertions(+), 293 deletions(-) create mode 100644 src/theme/cssColors.spec.ts create mode 100644 src/theme/cssColors.ts diff --git a/src/theme/cssColors.spec.ts b/src/theme/cssColors.spec.ts new file mode 100644 index 000000000..e942103ae --- /dev/null +++ b/src/theme/cssColors.spec.ts @@ -0,0 +1,400 @@ +import { + colorKey, + declarations, + describeColor, + findColors, + opaqueKey, + stripNoise, + stylesheetColors, + takesColor, +} from './cssColors'; + +const literals = (css: string) => stylesheetColors(css).map((found) => found.literal); +const values = (css: string) => declarations(css).map((declaration) => declaration.value); + +describe('stripNoise', () => { + it('removes block comments', () => { + expect(stripNoise('a { /* #ff0000 */ color: red; }')).not.toContain('#ff0000'); + }); + + it('removes string contents so content: "tan" is not a colour', () => { + expect(stripNoise('a { content: "tan"; }')).not.toContain('tan'); + }); + + it('removes url() payloads', () => { + const blanked = stripNoise('a { background: url(data:image/svg+xml;base64,Zm9v) no-repeat; }'); + expect(blanked).not.toContain('base64'); + expect(blanked).toContain('no-repeat'); + }); + + it('keeps the url() parentheses, which are structure rather than noise', () => { + // declarations balances parens to know a `;` inside url() is not a separator + expect(stripNoise('a { background: url(x;y); }')).toContain('url('); + expect(stripNoise('a { background: url(x;y); }')).toContain(')'); + }); + + it('handles an escaped quote inside a string', () => { + expect(stripNoise('a { content: "a\\"b"; }')).not.toContain('b"'); + }); + + it('tolerates an unterminated comment', () => { + expect(stripNoise('a { color: red; /* oops')).toContain('color: red;'); + }); + + it.each([ + ['a comment', 'a { /* note */ color: red; }'], + ['a string', 'a { content: "tan"; }'], + ['an unterminated string', 'a { content: "tan }'], + ['a url()', 'a { background: url(data:image/svg+xml;base64,Zm9v); }'], + ['an unterminated url()', 'a { background: url(oops }'], + ['an unterminated comment', 'a { color: red; /* oops'], + ])('blanks %s without changing the length', (_case, css) => { + // declarations addresses two differently-blanked copies with one index, so this + // is load-bearing rather than cosmetic: a length change silently misaligns context. + expect(stripNoise(css)).toHaveLength(css.length); + }); +}); + +describe('declarations', () => { + it('reads declarations at the top level of a rule', () => { + expect(values('a { color: red; background: blue; }')).toEqual(['red', 'blue']); + }); + + it('reads declarations nested in @media', () => { + expect(values('@media (max-width: 50em) { a { color: red; } }')).toEqual(['red']); + }); + + it('does not mistake a pseudo-class selector for a declaration', () => { + expect(values('a:hover { color: red; }')).toEqual(['red']); + }); + + it('does not mistake @keyframes percentages for declarations', () => { + expect(values('@keyframes f { 0% { opacity: 0; } 100% { opacity: 1; } }')) + .toEqual(['0', '1']); + }); + + it('ignores at-rules outside a block, such as @import', () => { + expect(values('@import "./theme.css";')).toEqual([]); + }); + + it('reads a declaration with no trailing semicolon', () => { + expect(values('a { color: red }')).toEqual(['red']); + }); + + it('does not split on a semicolon inside parentheses', () => { + expect(values('a { background: url(x;y); color: red; }')).toContain('red'); + }); + + it('keeps a custom property declaration', () => { + expect(values(':root { --ox-color-x: #fff; }')).toEqual(['#fff']); + }); + + it('lower-cases the property name', () => { + expect(declarations('a { COLOR: red; }')[0].property).toEqual('color'); + }); + + it('records the selector as context', () => { + expect(declarations('a:hover .thing { color: red; }')[0].context) + .toEqual('a:hover .thing'); + }); + + it('collapses whitespace in the context', () => { + expect(declarations('a,\n b {\n color: red;\n}')[0].context).toEqual('a, b'); + }); + + it('nests the at-rule prelude and the selector in the context', () => { + expect(declarations('@media (max-width: 50em) { a { color: red; } }')[0].context) + .toEqual('@media (max-width: 50em) a'); + }); + + it('pops the context again after a nested block closes', () => { + const parsed = declarations('@media (max-width: 50em) { a { color: red; } } b { color: blue; }'); + expect(parsed.map(({ context }) => context)) + .toEqual(['@media (max-width: 50em) a', 'b']); + }); + + it('keeps string contents in the context, so attribute selectors stay distinct', () => { + // a consumer may identify an occurrence by its context, so two rules that differ + // only inside a selector string must not reduce to the same one. + const parsed = declarations( + '.x[data-loading="true"] { color: #fff; } .x[data-loading="false"] { color: #fff; }' + ); + + expect(parsed.map(({ context }) => context)) + .toEqual(['.x[data-loading="true"]', '.x[data-loading="false"]']); + }); + + it('still blanks strings in the value, where they are not colours', () => { + // the other half of the same change: context keeps strings, values must not, or + // `content: "#fff"` starts reading as a colour. + expect(declarations('a { content: "#fff"; }')).toEqual([]); + }); + + it('does not let a brace inside a selector string open a block', () => { + expect(declarations('.x[data-glyph="{"] { color: red; }')) + .toEqual([{ context: '.x[data-glyph="{"]', property: 'color', value: 'red' }]); + }); +}); + +describe('takesColor', () => { + it.each(['color', 'background-color', 'border-top-color', '-webkit-text-fill-color'])( + 'accepts %s, which names a colour', (property) => { + expect(takesColor(property)).toBe(true); + } + ); + + it.each([ + 'background', 'border', 'border-left', 'box-shadow', 'outline', 'fill', 'scrollbar', + ])('accepts the %s shorthand', (property) => { + expect(takesColor(property)).toBe(true); + }); + + it('accepts a custom property, which has no grammar to go on', () => { + expect(takesColor('--tabs-border-color')).toBe(true); + }); + + it.each(['animation-name', 'font-family', 'transition-property', 'grid-area'])( + 'rejects %s, where an identifier is not a colour', (property) => { + expect(takesColor(property)).toBe(false); + } + ); + + it.each(['border-radius', 'border-width', 'border-collapse'])( + 'rejects %s, which is border-shaped but cannot hold a colour', (property) => { + // matching the whole border family by prefix would let a named colour through here + expect(takesColor(property)).toBe(false); + } + ); + + it('sees through a vendor prefix', () => { + expect(takesColor('-webkit-box-shadow')).toBe(true); + }); +}); + +describe('findColors', () => { + it('finds a hex literal', () => { + expect(literals('a { color: #ff0000; }')).toEqual(['#ff0000']); + }); + + it('finds a bare named colour in a shorthand', () => { + expect(literals('a { border: 0.1rem solid red; }')).toEqual(['red']); + }); + + it('finds colours in gradient stops', () => { + expect(literals('a { background: linear-gradient(to top, #fff 0%, #000 100%); }')) + .toEqual(['#fff', '#000']); + }); + + it('descends into var() fallbacks rather than treating var() as a literal', () => { + expect(literals('a { color: var(--x, #fff); }')).toEqual(['#fff']); + }); + + it('descends into color-mix() over tokens and finds nothing', () => { + expect(literals('a { color: color-mix(in srgb, var(--a), var(--b)); }')).toEqual([]); + }); + + it('finds rgba()', () => { + expect(literals('a { box-shadow: 0 0 0.2rem rgba(0, 0, 0, 0.2); }')) + .toEqual(['rgba(0, 0, 0, 0.2)']); + }); + + it.each([ + ['hsl()', 'a { color: hsl(0deg 100% 50%); }'], + ['oklch()', 'a { color: oklch(0.7 0.1 200); }'], + ['color()', 'a { color: color(display-p3 1 0 0); }'], + ['device-cmyk()', 'a { color: device-cmyk(0 0.5 1 0); }'], + // rgba() may legally hold var() channels, but then there is no telling what colour + // it is. Reporting it beats skipping it, which would let an unchecked colour past. + ['rgba() over a var() channel list', 'a { color: rgba(var(--channels), 0.2); }'], + ])('finds %s, which resolves to null so it cannot pass silently', (_case, css) => { + const found = stylesheetColors(css); + expect(found).toHaveLength(1); + expect(found[0].rgba).toBeNull(); + }); + + it('does not treat a class selector named .red as a colour', () => { + expect(literals('.red { opacity: 1; }')).toEqual([]); + }); + + it('does not treat content: "tan" as a colour', () => { + expect(literals('a { content: "tan"; }')).toEqual([]); + }); + + it('does not treat a colour inside a comment as a colour', () => { + expect(literals('a { /* was #ff0000 */ color: var(--x); }')).toEqual([]); + }); + + it('does not treat transparent or currentcolor as comparable colours', () => { + expect(literals('a { color: currentcolor; background: transparent; }')).toEqual([]); + }); + + it('does not treat a non-colour keyword as a colour', () => { + expect(literals('a { transition: all 0.2s linear; }')).toEqual([]); + }); + + it('finds several colours in one declaration', () => { + expect(literals('a { box-shadow: 0 0 0 red, 0 0 0 #00f; }')).toEqual(['red', '#00f']); + }); + + it('tolerates an unbalanced function call', () => { + expect(() => literals('a { color: rgb(0, 0, 0; }')).not.toThrow(); + }); + + it.each([ + ['an animation name', 'a { animation-name: red; }'], + ['a font family', 'a { font-family: black; }'], + ['a transitioned property', 'a { transition-property: tan; }'], + ['a grid area', 'a { grid-area: navy; }'], + // the property gate has to survive the descent into a function, not just the + // top level of the value — findColors passes `named` down to itself. + ['a var() fallback under one', 'a { animation-name: var(--enter, red); }'], + ])('does not read %s as a named colour', (_case, css) => { + expect(literals(css)).toEqual([]); + }); + + it.each([ + ['a colour property', 'a { color: red; }'], + ['a shorthand', 'a { border: 0.1rem solid red; }'], + ['a custom property', 'a { --x: red; }'], + ['a box-shadow', 'a { box-shadow: 0 0 0.2rem red; }'], + ['a vendor-prefixed property', 'a { -webkit-text-fill-color: red; }'], + // the other side of the descent: gating named colours on the property must not + // stop finding them inside a function the walk descends into. + ['a gradient stop', 'a { background: linear-gradient(to top, red, transparent); }'], + ])('still reads a named colour in %s', (_case, css) => { + expect(literals(css)).toEqual(['red']); + }); + + it('still reads hex and rgb() in a property that cannot take a named colour', () => { + // only the bare-identifier case is property-sensitive: `#fff` and `rgb(...)` are + // colours wherever they are written, so they stay in scope everywhere. + expect(literals('a { animation-name: #fff; transition-property: rgb(0, 0, 0); }')) + .toEqual(['#fff', 'rgb(0, 0, 0)']); + }); + + it('has no default for the named-colour gate, so a caller cannot forget it', () => { + // defaulting it to true would quietly restore reading `animation-name: red` as a + // colour for any call site that omitted the argument. + expect(findColors('red', true)).toHaveLength(1); + expect(findColors('red', false)).toEqual([]); + }); + + it('records the declaration each colour was written in', () => { + expect(stylesheetColors('@media (max-width: 50em) { .a:hover { color: #fff; } }')) + .toEqual([{ + context: '@media (max-width: 50em) .a:hover', + literal: '#fff', + property: 'color', + rgba: { r: 255, g: 255, b: 255, a: 1 }, + }]); + }); +}); + +describe('describeColor', () => { + it('expands 3-digit hex', () => { + expect(describeColor('#fff')).toEqual({ r: 255, g: 255, b: 255, a: 1 }); + }); + + it('reads 8-digit hex alpha', () => { + expect(describeColor('#00000033')?.a).toBeCloseTo(0.2, 1); + }); + + it('reads 4-digit hex', () => { + expect(describeColor('#0000')).toEqual({ r: 0, g: 0, b: 0, a: 0 }); + }); + + it('is case insensitive', () => { + expect(describeColor('#027EB5')).toEqual(describeColor('#027eb5')); + }); + + it('resolves a named colour', () => { + expect(describeColor('white')).toEqual({ r: 255, g: 255, b: 255, a: 1 }); + }); + + it('reads comma-separated rgb()', () => { + expect(describeColor('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('reads space-separated rgb() with a slash alpha', () => { + expect(describeColor('rgb(255 0 0 / 0.5)')).toEqual({ r: 255, g: 0, b: 0, a: 0.5 }); + }); + + it('reads percentage channels', () => { + expect(describeColor('rgb(100%, 0%, 0%)')).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('reads a percentage alpha', () => { + expect(describeColor('rgba(0, 0, 0, 20%)')?.a).toBeCloseTo(0.2); + }); + + it('returns null for hsl()', () => { + expect(describeColor('hsl(0, 100%, 50%)')).toBeNull(); + }); + + it('returns null for a non-numeric channel', () => { + expect(describeColor('rgb(var(--x), 0, 0)')).toBeNull(); + }); + + it('returns null for the wrong number of channels', () => { + expect(describeColor('rgb(0, 0)')).toBeNull(); + }); + + it('returns null for an unknown identifier', () => { + expect(describeColor('notacolor')).toBeNull(); + }); + + it.each(['#12345', '#1234567', '#123456789'])( + 'returns null for the malformed hex length %s', (literal) => { + expect(describeColor(literal)).toBeNull(); + } + ); + + it.each(['#ggg', '#gggggg', '#12345g'])( + 'returns null for %s rather than a set of NaN channels', (literal) => { + // the expanded length is right, so checking only the length would hand back + // {r: NaN, g: NaN, b: NaN} and read as a resolved colour — which is how a + // malformed palette value used to pass the consumer's resolvability guard. + expect(describeColor(literal)).toBeNull(); + } + ); + + it('rounds a percentage channel the same way as its integer spelling', () => { + // 50% of 255 is 127.5, which rounds to 128. Scaling by the decimal 2.55 gives + // 127.49999999999999 and rounds to 127, so the two spellings would disagree. + expect(describeColor('rgb(50%, 50%, 50%)')).toEqual({ r: 128, g: 128, b: 128, a: 1 }); + expect(describeColor('rgb(50%, 50%, 50%)')).toEqual(describeColor('rgb(128, 128, 128)')); + }); +}); + +describe('colour keys', () => { + const rgbaOf = (literal: string) => { + const rgba = describeColor(literal); + if (rgba === null) { throw new Error(`${literal} did not resolve to channels`); } + return rgba; + }; + const key = (literal: string) => colorKey(rgbaOf(literal)); + + it('is the hex form, which is what an allowlist entry has to be recognisable as', () => { + expect(key('#ccc')).toEqual('#cccccc'); + }); + + it('treats an opaque colour as equal however it is written', () => { + expect(key('#fff')).toEqual(key('white')); + expect(key('rgb(50%, 50%, 50%)')).toEqual(key('#808080')); + }); + + it('distinguishes a translucent colour from its opaque form', () => { + expect(key('rgba(0, 0, 0, 0.2)')).not.toEqual(key('#000')); + }); + + it('keeps alpha decimal rather than rounding it onto a hex pair', () => { + // two alphas a hex pair cannot tell apart must not collapse onto one key + expect(key('rgba(0, 0, 0, 0.2)')).toEqual('#000000/0.2'); + expect(key('rgba(0, 0, 0, 0.201)')).not.toEqual(key('rgba(0, 0, 0, 0.2)')); + }); + + it('recognises a translucent colour by its opaque channels', () => { + expect(opaqueKey(rgbaOf('rgba(0, 0, 0, 0.2)'))) + .toEqual(opaqueKey(rgbaOf('#000'))); + }); +}); diff --git a/src/theme/cssColors.ts b/src/theme/cssColors.ts new file mode 100644 index 000000000..2ec4f5898 --- /dev/null +++ b/src/theme/cssColors.ts @@ -0,0 +1,457 @@ +/** + * Colour auditing for plain-CSS stylesheets: text in, structured colours out. + * + * This parses declarations rather than grepping for `#hex`, because a grep misses + * `rgba()`, `hsl()`, named colours in shorthands and colours in gradient stops — all of + * which can silently duplicate or diverge from a theme value. + * + * Published deliberately, not incidentally. This engine knows nothing about + * ui-components; REX needed exactly the same thing and, because the first copy lived + * inside a spec file where nothing could import it, got a second hand-written one + * instead. The two had already diverged before either merged. See CORE-2736. + * + * Two rules keep it publishable: + * + * - **No `fs`, no `path`, no node built-ins.** It resolves to the `browser` export + * condition via the wildcard subpath, in a library that is otherwise browser-only, so + * a node import here is something a consumer's bundler could try to follow. The file + * walk belongs to whoever owns the file tree anyway. + * - **No policy.** What counts as a theme value, which off-palette colours are + * tolerated and how a violation is worded all differ between consumers and all stay + * with them. This file only answers "what colours does this CSS contain, and what are + * they" — see src/theme/tokens.spec.ts for the ui-components layer on top. + */ + +export interface Rgba { + r: number; + g: number; + b: number; + /** 0–1; 1 for an opaque colour. */ + a: number; +} + +export interface FoundColor { + /** The literal exactly as written, e.g. `rgba(0, 0, 0, 0.2)`. */ + literal: string; + /** Resolved channels, or null when this syntax cannot be resolved statically. */ + rgba: Rgba | null; +} + +/** A declaration, with enough of its surroundings to identify it again. */ +export interface Declaration { + /** + * The selectors and at-rule preludes the declaration sits inside, outermost first, + * whitespace-collapsed: `@media (max-width: 75em) .book-banner .title`. + * + * Carried because a consumer may need to tell two occurrences of the same literal in + * one file apart — REX's baseline ratchet identifies an occurrence by the declaration + * it was written in. Consumers that only need to know a colour is wrong can ignore it. + */ + context: string; + /** Lower-cased property name, e.g. `background-color` or `--tabs-border-color`. */ + property: string; + /** Everything to the right of the `:`. */ + value: string; +} + +/** A colour literal together with the declaration it was written in. */ +export interface StylesheetColor extends FoundColor { + context: string; + property: string; +} + +/** https://www.w3.org/TR/css-color-4/#named-colors */ +const NAMED_COLORS: Record = { + aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', + azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000', + blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', + burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00', chocolate: '#d2691e', + coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', + cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', + darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', + darkturquoise: '#00ced1', darkviolet: '#9400d3', deeppink: '#ff1493', + deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969', dodgerblue: '#1e90ff', + firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22', fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', + gray: '#808080', green: '#008000', greenyellow: '#adff2f', grey: '#808080', + honeydew: '#f0fff0', hotpink: '#ff69b4', indianred: '#cd5c5c', indigo: '#4b0082', + ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa', lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', + lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', + lightgray: '#d3d3d3', lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa', + lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32', linen: '#faf0e6', + magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', + mediumseagreen: '#3cb371', mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', mediumvioletred: '#c71585', midnightblue: '#191970', + mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5', navajowhite: '#ffdead', + navy: '#000080', oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23', + orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6', palegoldenrod: '#eee8aa', + palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093', + papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb', + plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399', + red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513', + salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee', + sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd', + slategray: '#708090', slategrey: '#708090', snow: '#fffafa', springgreen: '#00ff7f', + steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8', + tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3', + white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32', +}; + +/** + * Functions whose arguments *are* the colour, rather than containing one. These are + * terminal: we try to resolve them and report them either way. Anything else that + * happens to contain a colour (`var`, `color-mix`, the gradients) is descended into. + */ +const COLOR_FUNCTIONS = [ + 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', + 'device-cmyk', +]; + +/** + * Keywords that are colour-valued but carry no fixed channels, so there is nothing to + * compare against a theme. They are never reported. + */ +const COLOR_KEYWORDS = [ + 'transparent', 'currentcolor', 'inherit', 'initial', 'unset', 'revert', 'none', +]; + +/** + * Blanks the parts of a stylesheet that can hold colour-shaped text without meaning a + * colour: comments, string contents and `url()` payloads (which may carry a data: URI + * complete with `;` and `#`, and would otherwise wreck the declaration split). + * + * Blanked to spaces rather than deleted, so the result is the same length as the input + * and every character keeps its original index. `declarations` relies on that: it finds + * structure in the blanked text and then slices the corresponding span out of a second, + * differently-blanked copy. + * + * `keepStrings` is what that second copy is for. A string is noise inside a declaration + * value — `content: "#fff"` is not a colour — but it is *meaning* inside a selector: + * `[data-loading="true"]` and `[data-loading="false"]` are different rules, and blanking + * both to `[data-loading=""]` would merge them into one context. + */ +const blankNoise = (css: string, keepStrings: boolean): string => { + const pad = (length: number) => ' '.repeat(Math.max(0, length)); + let out = ''; + let index = 0; + + while (index < css.length) { + const rest = css.slice(index); + + if (rest.startsWith('/*')) { + const end = css.indexOf('*/', index + 2); + const stop = end === -1 ? css.length : end + 2; + out += pad(stop - index); + index = stop; + continue; + } + + const quote = css[index]; + if (quote === '"' || quote === '\'') { + let cursor = index + 1; + while (cursor < css.length && css[cursor] !== quote) { + cursor += css[cursor] === '\\' ? 2 : 1; + } + const stop = Math.min(cursor + 1, css.length); + // blanked whole, quotes included: nothing downstream needs the quotes, and + // keeping them would have to handle an unterminated string running off the end. + out += keepStrings ? css.slice(index, stop) : pad(stop - index); + index = stop; + continue; + } + + const url = /^url\(/i.exec(rest); + if (url) { + const open = index + url[0].length; + let depth = 1; + let cursor = open; + while (cursor < css.length && depth > 0) { + if (css[cursor] === '(') { depth++; } + if (css[cursor] === ')') { depth--; } + cursor++; + } + // the parens themselves are structure — `declarations` balances them — so only + // the payload between them is blanked. + const closed = depth === 0; + const payloadEnd = closed ? cursor - 1 : cursor; + out += css.slice(index, open) + pad(payloadEnd - open) + (closed ? ')' : ''); + index = cursor; + continue; + } + + out += css[index]; + index++; + } + + return out; +}; + +/** Noise blanked for reading declaration values: strings go too. */ +export const stripNoise = (css: string): string => blankNoise(css, false); + +/** + * Pulls declarations out of a stylesheet at any nesting depth, so `@media` blocks are + * covered. Selectors and at-rule preludes end at a `{` and become the declaration's + * `context` rather than being read as declarations themselves, which is what keeps + * `a:hover` and `@keyframes` percentages out of the colour scan. + * + * The property name is kept as well as the value, because whether a bare identifier + * means a colour depends on the property it sits in — see `takesColor`. + * + * Two blanked copies of the source are walked in step. Structure is read from `values`, + * where strings are gone, so a `;` or `{` inside one cannot split a declaration. The + * `context` is sliced out of `selectors`, where string contents survive, so that + * `[data-loading="true"]` and `[data-loading="false"]` stay distinguishable. Both are + * the same length as the input, which is what lets one index address both. + */ +export const declarations = (css: string): Declaration[] => { + const found: Declaration[] = []; + const values = stripNoise(css); + const selectors = blankNoise(css, true); + const stack: string[] = []; + let start = 0; + let parens = 0; + + const flush = (end: number) => { + const segment = values.slice(start, end); + const separator = segment.indexOf(':'); + + if (stack.length > 0 && separator !== -1) { + const value = segment.slice(separator + 1).trim(); + const property = segment.slice(0, separator).trim().toLowerCase(); + if (value) { found.push({ context: stack.join(' '), property, value }); } + } + + start = end + 1; + }; + + for (let index = 0; index < values.length; index++) { + const character = values[index]; + + if (character === '(') { parens++; } + if (character === ')') { parens = Math.max(0, parens - 1); } + if (parens !== 0) { continue; } + + if (character === '{') { + stack.push(selectors.slice(start, index).replace(/\s+/g, ' ').trim()); + start = index + 1; + } else if (character === '}') { + flush(index); + stack.pop(); + } else if (character === ';') { + flush(index); + } + } + + return found; +}; + +/** + * Properties whose value can hold a ``, directly or inside a shorthand. + * + * Hex and the colour functions are only ever colours, so they are read wherever they + * appear. A bare identifier is not: `animation-name: red` names a keyframe animation and + * `font-family: white` names a font, and reporting either as a palette violation would be + * wrong — with a suggested fix that would break the declaration. Named colours are + * therefore only read in these properties. + * + * Spelled out rather than matched by prefix, so that `border-radius`, `border-width` and + * the rest of the border family that cannot take a colour do not let one through. + */ +const COLOR_SHORTHANDS = [ + 'background', 'background-image', 'border', 'border-block', 'border-block-end', + 'border-block-start', 'border-bottom', 'border-image', 'border-image-source', + 'border-inline', 'border-inline-end', 'border-inline-start', 'border-left', + 'border-right', 'border-top', 'box-shadow', 'caret', 'column-rule', 'fill', 'filter', + 'backdrop-filter', 'list-style', 'mask', 'mask-image', 'outline', 'scrollbar', 'stroke', + 'text-decoration', 'text-emphasis', 'text-shadow', 'text-stroke', +]; + +/** Whether a bare identifier in this property's value could be a colour. */ +export const takesColor = (property: string): boolean => { + // custom properties have no grammar to go on, so anything in one counts + if (property.startsWith('--')) { return true; } + + const name = property.replace(/^-(?:webkit|moz|ms|o)-/, ''); + + return name.includes('color') || COLOR_SHORTHANDS.includes(name); +}; + +const clamp = (value: number, max: number) => Math.min(max, Math.max(0, value)); + +const channel = (raw: string): number | null => { + const text = raw.trim(); + const percent = /^(-?[\d.]+)%$/.exec(text); + // scale by 255/100 rather than by the decimal 2.55, which is not representable in + // binary: 50 * 2.55 is 127.49999999999999 and rounds to 127, where 50% of 255 is + // 127.5 and rounds to 128. The two spellings of the same colour must agree, or they + // get different keys and the audit misclassifies one of them. + if (percent) { return Math.round((clamp(parseFloat(percent[1]), 100) / 100) * 255); } + return /^-?[\d.]+$/.test(text) ? Math.round(clamp(parseFloat(text), 255)) : null; +}; + +const alphaChannel = (raw?: string): number | null => { + if (raw === undefined) { return 1; } + const text = raw.trim(); + const percent = /^(-?[\d.]+)%$/.exec(text); + if (percent) { return clamp(parseFloat(percent[1]), 100) / 100; } + return /^-?[\d.]+$/.test(text) ? clamp(parseFloat(text), 1) : null; +}; + +/** + * Only the four lengths CSS defines, and only hex digits. Checking the grammar rather + * than just the expanded length matters: `#ggg` would otherwise expand to six characters, + * `parseInt` them to NaN, and hand back an Rgba of NaNs that reads as a resolved colour. + * A malformed *theme* value would then pass the "every colour token resolves" guard while + * generating invalid CSS — the one check meant to stop a palette value from silently + * becoming unmatchable. + */ +const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/; + +const fromHex = (literal: string): Rgba | null => { + if (!HEX.test(literal.toLowerCase())) { return null; } + + const digits = literal.slice(1); + const expand = (text: string) => text.split('').map((c) => c + c).join(''); + const full = digits.length === 3 || digits.length === 4 ? expand(digits) : digits; + + return { + r: parseInt(full.slice(0, 2), 16), + g: parseInt(full.slice(2, 4), 16), + b: parseInt(full.slice(4, 6), 16), + a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1, + }; +}; + +/** + * Resolves a colour literal to channels, or null when it cannot be resolved statically. + * Returning null is deliberate: `hsl()`, `oklch()` and `color()` are reported by the + * consumer rather than passing silently, so the escape hatch stays explicit. + */ +export const describeColor = (literal: string): Rgba | null => { + const text = literal.trim(); + + if (text.startsWith('#')) { return fromHex(text.toLowerCase()); } + + const named = NAMED_COLORS[text.toLowerCase()]; + if (named) { return fromHex(named); } + + const fn = /^(rgba?)\((.*)\)$/is.exec(text); + if (!fn) { return null; } + + const args = fn[2].includes(',') + ? fn[2].split(',') + : fn[2].replace(/\//g, ' ').trim().split(/\s+/); + + if (args.length < 3 || args.length > 4) { return null; } + + const [r, g, b] = args.slice(0, 3).map(channel); + const a = alphaChannel(args[3]); + + return r === null || g === null || b === null || a === null ? null : { r, g, b, a }; +}; + +/** + * Finds every colour literal in a declaration value, at any depth. Functions that merely + * contain colours are descended into; colour functions are terminal. + * + * `named` says whether a bare identifier may be read as a colour, which depends on the + * property the value belongs to — see `takesColor`. Hex and the colour functions are + * unambiguous and are found either way. It has no default: defaulting it to `true` would + * quietly restore the over-eager behaviour for any caller that forgot it. + */ +export const findColors = (value: string, named: boolean): FoundColor[] => { + const found: FoundColor[] = []; + let index = 0; + + while (index < value.length) { + const rest = value.slice(index); + + const call = /^([a-z][\w-]*)\(/i.exec(rest); + if (call) { + let depth = 1; + let cursor = index + call[0].length; + while (cursor < value.length && depth > 0) { + if (value[cursor] === '(') { depth++; } + if (value[cursor] === ')') { depth--; } + cursor++; + } + const literal = value.slice(index, cursor); + const args = literal.slice(call[0].length, literal.endsWith(')') ? -1 : undefined); + + if (COLOR_FUNCTIONS.includes(call[1].toLowerCase())) { + found.push({ literal, rgba: describeColor(literal) }); + } else { + found.push(...findColors(args, named)); + } + + index = cursor; + continue; + } + + const hex = /^#[0-9a-fA-F]{3,8}\b/.exec(rest); + if (hex) { + found.push({ literal: hex[0], rgba: describeColor(hex[0]) }); + index += hex[0].length; + continue; + } + + const word = /^-?[a-zA-Z][\w-]*/.exec(rest); + if (word) { + const name = word[0].toLowerCase(); + if (named && NAMED_COLORS[name] && !COLOR_KEYWORDS.includes(name)) { + found.push({ literal: word[0], rgba: describeColor(word[0]) }); + } + index += word[0].length; + continue; + } + + index++; + } + + return found; +}; + +/** + * Every colour literal written in a stylesheet, in source order. + * + * Accumulated with `push` rather than by spreading into a new array per declaration: + * this runs over every stylesheet in a tree, so the quadratic version was copying every + * colour found so far once per subsequent declaration. + */ +export const stylesheetColors = (css: string): StylesheetColor[] => { + const found: StylesheetColor[] = []; + + for (const { context, property, value } of declarations(css)) { + for (const color of findColors(value, takesColor(property))) { + found.push({ ...color, context, property }); + } + } + + return found; +}; + +const hex = (rgba: Rgba) => + `#${[rgba.r, rgba.g, rgba.b].map((c) => c.toString(16).padStart(2, '0')).join('')}`; + +/** + * Canonical key for comparing two colours, and the form a consumer's allowlist is keyed + * by. `#rrggbb` for an opaque colour, with the alpha appended when there is one. + * + * Hex rather than the raw channels because this is the form that ends up in an allowlist + * and in a failure message, where a reader has to recognise it. Alpha stays decimal + * rather than becoming a fourth hex pair, which would round two distinct alphas onto one + * key. + */ +export const colorKey = (rgba: Rgba): string => + rgba.a === 1 ? hex(rgba) : `${hex(rgba)}/${rgba.a}`; + +/** Key ignoring alpha, so `rgba(0, 0, 0, 0.2)` can be recognised as the theme's black. */ +export const opaqueKey = (rgba: Rgba): string => hex(rgba); diff --git a/src/theme/tokens.spec.ts b/src/theme/tokens.spec.ts index b11e82958..3780a42be 100644 --- a/src/theme/tokens.spec.ts +++ b/src/theme/tokens.spec.ts @@ -1,5 +1,8 @@ import fs from 'fs'; import path from 'path'; +import { + colorKey, describeColor, FoundColor, opaqueKey, stripNoise, stylesheetColors, +} from './cssColors'; import { renderThemeCss, themeTokens } from './themeCss'; const srcDir = path.join(__dirname, '..'); @@ -12,8 +15,8 @@ const themeCssPath = path.join(__dirname, 'theme.css'); * refactor. Adding to this list should be a deliberate act — prefer adding the colour to * palette.ts if it is really part of the design. * - * Keyed by the canonical form the checker computes: `#rrggbb` for an opaque colour, or the - * whitespace-collapsed literal for anything translucent or otherwise unresolvable. + * Keyed by the form the checker computes: `colorKey` for a colour it can resolve, or the + * whitespace-collapsed literal for one it cannot. See `allowlistKey` below. * * Translucent colours do not need an entry when their opaque channels are a theme value — * `rgba(0, 0, 0, 0.2)` is black at 20% and passes on its own. That rule is what lets @@ -26,258 +29,15 @@ const KNOWN_OFF_PALETTE = new Map([ ]); /** - * The CSS named colours, so that `border: 1px solid tan` is caught the same way `#d2b48c` - * is. Deliberately excludes `transparent`, `currentcolor`, the CSS-wide keywords and the - * system colours: none of those hardcode a value, so none of them are a problem. - */ -const NAMED_COLORS = new Map( - ( - 'aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff ' + - 'beige:f5f5dc bisque:ffe4c4 black:000000 blanchedalmond:ffebcd blue:0000ff ' + - 'blueviolet:8a2be2 brown:a52a2a burlywood:deb887 cadetblue:5f9ea0 chartreuse:7fff00 ' + - 'chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed cornsilk:fff8dc crimson:dc143c ' + - 'cyan:00ffff darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b darkgray:a9a9a9 ' + - 'darkgreen:006400 darkgrey:a9a9a9 darkkhaki:bdb76b darkmagenta:8b008b ' + - 'darkolivegreen:556b2f darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 ' + - 'darksalmon:e9967a darkseagreen:8fbc8f darkslateblue:483d8b darkslategray:2f4f4f ' + - 'darkslategrey:2f4f4f darkturquoise:00ced1 darkviolet:9400d3 deeppink:ff1493 ' + - 'deepskyblue:00bfff dimgray:696969 dimgrey:696969 dodgerblue:1e90ff firebrick:b22222 ' + - 'floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc ' + - 'ghostwhite:f8f8ff gold:ffd700 goldenrod:daa520 gray:808080 green:008000 ' + - 'greenyellow:adff2f grey:808080 honeydew:f0fff0 hotpink:ff69b4 indianred:cd5c5c ' + - 'indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa lavenderblush:fff0f5 ' + - 'lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 ' + - 'lightcyan:e0ffff lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 ' + - 'lightgrey:d3d3d3 lightpink:ffb6c1 lightsalmon:ffa07a lightseagreen:20b2aa ' + - 'lightskyblue:87cefa lightslategray:778899 lightslategrey:778899 ' + - 'lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 ' + - 'magenta:ff00ff maroon:800000 mediumaquamarine:66cdaa mediumblue:0000cd ' + - 'mediumorchid:ba55d3 mediumpurple:9370db mediumseagreen:3cb371 ' + - 'mediumslateblue:7b68ee mediumspringgreen:00fa9a mediumturquoise:48d1cc ' + - 'mediumvioletred:c71585 midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 ' + - 'moccasin:ffe4b5 navajowhite:ffdead navy:000080 oldlace:fdf5e6 olive:808000 ' + - 'olivedrab:6b8e23 orange:ffa500 orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa ' + - 'palegreen:98fb98 paleturquoise:afeeee palevioletred:db7093 papayawhip:ffefd5 ' + - 'peachpuff:ffdab9 peru:cd853f pink:ffc0cb plum:dda0dd powderblue:b0e0e6 ' + - 'purple:800080 rebeccapurple:663399 red:ff0000 rosybrown:bc8f8f royalblue:4169e1 ' + - 'saddlebrown:8b4513 salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee ' + - 'sienna:a0522d silver:c0c0c0 skyblue:87ceeb slateblue:6a5acd slategray:708090 ' + - 'slategrey:708090 snow:fffafa springgreen:00ff7f steelblue:4682b4 tan:d2b48c ' + - 'teal:008080 thistle:d8bfd8 tomato:ff6347 turquoise:40e0d0 violet:ee82ee ' + - 'wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 yellowgreen:9acd32' - ) - .split(' ') - .map((entry) => entry.split(':') as [string, string]) -); - -/** Functions whose arguments *are* the colour, rather than containing one. */ -const COLOR_FUNCTIONS = new Set([ - 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', 'device-cmyk', -]); - -interface Color { - /** Opaque channels as `#rrggbb`, or null when the syntax is one we cannot resolve. */ - hex: string | null; - /** 0–1; 1 for an opaque colour. */ - alpha: number; - /** Canonical key for the allowlist. */ - key: string; -} - -const expandHex = (body: string) => - body.length <= 4 ? body.split('').map((c) => c + c).join('') : body; - -/** - * The four legal hex-colour lengths, digits included. Checking the *expanded* length is not - * enough: `#ggg` expands to six characters and would sail through as a colour, which would - * defeat unresolvableThemeColors — the one guard that stops a malformed palette value from - * entering themeValues under a key nothing can match. - */ -const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/; - -const parseHex = (literal: string): Color | null => { - const lowered = literal.toLowerCase(); - if (!HEX_COLOR.test(lowered)) { return null; } - const body = expandHex(lowered.slice(1)); - const hex = `#${body.slice(0, 6)}`; - const alpha = body.length === 8 ? parseInt(body.slice(6, 8), 16) / 255 : 1; - return { hex, alpha, key: alpha === 1 ? hex : `#${body}` }; -}; - -const channel = (raw: string) => { - const value = raw.endsWith('%') ? (parseFloat(raw) / 100) * 255 : parseFloat(raw); - return Number.isFinite(value) ? Math.round(Math.min(255, Math.max(0, value))) : null; -}; - -const parseRgb = (args: string[], key: string): Color => { - const channels = args.slice(0, 3).map(channel); - const rawAlpha = args[3]; - const alpha = rawAlpha === undefined - ? 1 - : (rawAlpha.endsWith('%') ? parseFloat(rawAlpha) / 100 : parseFloat(rawAlpha)); - if (channels.length !== 3 || channels.some((c) => c === null) || !Number.isFinite(alpha)) { - return { hex: null, alpha: 1, key }; - } - const hex = `#${channels.map((c) => (c as number).toString(16).padStart(2, '0')).join('')}`; - return { hex, alpha, key: alpha === 1 ? hex : key }; -}; - -/** Reduce a colour literal to channels + alpha, or `hex: null` when we cannot. */ -const describeColor = (literal: string): Color => { - const key = literal.replace(/\s+/g, ' ').trim().toLowerCase(); - - if (literal.startsWith('#')) { - return parseHex(literal) ?? { hex: null, alpha: 1, key }; - } - - const call = /^([a-zA-Z-]+)\((.*)\)$/s.exec(literal); - if (call) { - const name = call[1].toLowerCase(); - const args = call[2].split(/[\s,/]+/).filter(Boolean); - // rgb()/rgba() can legally contain var() arguments; treat unresolvable ones as errors - // rather than skipping the check entirely (which would allow off-palette colours through). - if (name === 'rgb' || name === 'rgba') { return parseRgb(args, key); } - return { hex: null, alpha: 1, key }; - } - - const named = NAMED_COLORS.get(literal.toLowerCase()); - return named ? { hex: `#${named}`, alpha: 1, key: `#${named}` } : { hex: null, alpha: 1, key }; -}; - -/** - * Strip the parts of a stylesheet that can hold colour-shaped text without meaning a - * colour: comments, string literals and url() payloads (which may contain a data: URI - * complete with `;` and `#`, and would otherwise wreck the declaration split). - */ -const stripNoise = (css: string) => css - .replace(/\/\*[\s\S]*?\*\//g, ' ') - .replace(/url\(\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^)]*)\)/gi, 'url()') - .replace(/"(?:[^"\\]|\\.)*"/g, '""') - .replace(/'(?:[^'\\]|\\.)*'/g, "''"); - -interface Declaration { - /** Lower-cased property name. */ - property: string; - value: string; -} - -/** - * Declarations at any nesting depth (so inside @media too). Only text that ends up on the - * right of a `:` inside a block counts, which keeps selectors, at-rule preludes and - * @keyframes percentages out of the colour scan. The property comes back too, because - * whether a bare identifier means a colour depends on where it sits. - */ -const declarations = (css: string): Declaration[] => { - const found: Declaration[] = []; - let depth = 0; - let buffer = ''; - - const flush = () => { - const separator = buffer.indexOf(':'); - if (depth > 0 && separator > -1) { - found.push({ - property: buffer.slice(0, separator).trim().toLowerCase(), - value: buffer.slice(separator + 1).trim(), - }); - } - buffer = ''; - }; - - for (const char of stripNoise(css)) { - if (char === '{') { buffer = ''; depth += 1; } - else if (char === '}') { flush(); depth = Math.max(0, depth - 1); } - else if (char === ';') { flush(); } - else { buffer += char; } - } - - return found; -}; - -/** - * Shorthands that can hold a colour without saying so in their name. Anything containing - * "color", the border family and custom properties are handled separately. - */ -const COLOR_SHORTHANDS = new Set([ - 'background', 'background-image', 'outline', 'box-shadow', 'text-shadow', 'text-decoration', - 'text-emphasis', 'column-rule', 'list-style', 'fill', 'stroke', 'caret', 'mask', 'filter', - 'backdrop-filter', 'scrollbar', -]); - -/** - * Whether a bare identifier in this property's value could be a colour. Without this, - * `animation-name: red` reads as an off-palette colour and `font-family: white` as a - * duplicate of --ox-color-white, with a suggested fix that would break the declaration. + * How a colour is looked up in KNOWN_OFF_PALETTE. * - * Only bare identifiers need the gate. Hex and the colour functions are only ever colours, - * so they stay in scope for every property. + * A resolvable colour is keyed by its channels, so the entry covers every spelling of it + * at once. One we cannot resolve has no channels to key by, so it falls back to the + * literal as written — meaning `hsl()` and friends have to be allowlisted per spelling, + * which is the right amount of friction for a value the checker cannot reason about. */ -const acceptsColor = (property: string) => { - const name = property.replace(/^-(?:webkit|moz|ms|o)-/, ''); - return name.startsWith('--') - || name.includes('color') - || name.startsWith('border') - || COLOR_SHORTHANDS.has(name); -}; - -const closingParen = (text: string, open: number) => { - let depth = 0; - for (let i = open; i < text.length; i += 1) { - if (text[i] === '(') { depth += 1; } - if (text[i] === ')') { - depth -= 1; - if (depth === 0) { return i; } - } - } - return text.length - 1; -}; - -/** - * Every colour literal in a declaration value, in any syntax: hex, the functional - * notations, and — when the property can hold one — bare named colours wherever they - * appear, including inside shorthands and gradient stops. Functions that merely *contain* colours (var, color-mix, the - * gradients) are descended into rather than treated as literals themselves, so - * `color-mix(in srgb, var(--ox-color-black) 20%, transparent)` is clean. - */ -const colorLiterals = (value: string, namedColorsInScope: boolean): string[] => { - const found: string[] = []; - let i = 0; - - while (i < value.length) { - const rest = value.slice(i); - - const hex = /^#[0-9a-fA-F]{3,8}\b/.exec(rest); - if (hex) { - found.push(hex[0]); - i += hex[0].length; - continue; - } - - const ident = /^(?:-{1,2})?[a-zA-Z_][\w-]*/.exec(rest); - if (ident) { - const name = ident[0].toLowerCase(); - const after = i + ident[0].length; - - if (value[after] === '(') { - const end = closingParen(value, after); - if (COLOR_FUNCTIONS.has(name)) { - found.push(value.slice(i, end + 1)); - i = end + 1; - } else { - i = after + 1; // descend into the arguments - } - continue; - } - - if (namedColorsInScope && NAMED_COLORS.has(name)) { found.push(ident[0]); } - i = after; - continue; - } - - i += 1; - } - - return found; -}; +const allowlistKey = ({ literal, rgba }: FoundColor) => + rgba === null ? literal.replace(/\s+/g, ' ').trim().toLowerCase() : colorKey(rgba); const walk = (dir: string, out: string[] = []): string[] => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -307,13 +67,18 @@ const themeColors: ReadonlyArray = [...themeTokens()] /** * Theme colours describeColor cannot reduce to channels. Asserted empty below rather than - * cast away: such an entry would drop out of themeValues, and the colour would then read as - * off-palette everywhere it is used — a confusing failure a long way from its cause. + * cast away: such an entry would drop out of themeValues, and the colour would then read + * as off-palette everywhere it is used — a confusing failure a long way from its cause. + * + * Taken as a function of the entries so that the guard itself can be tested against a + * malformed value, rather than only ever being run over a theme that happens to be sound. */ -const unresolvableThemeColors = themeColors - .filter(([, value]) => describeColor(value).hex === null) +const unresolvableColors = (entries: ReadonlyArray) => entries + .filter(([, value]) => describeColor(value) === null) .map(([token, value]) => `${token}: ${value}`); +const unresolvableThemeColors = unresolvableColors(themeColors); + /** * Every theme colour, by opaque channels, so a literal can be traced back to its token. * @@ -323,8 +88,11 @@ const unresolvableThemeColors = themeColors * entry happened to be written last. */ const themeValues = themeColors.reduce((byValue, [token, value]) => { - const { hex } = describeColor(value); - if (hex !== null) { byValue.set(hex, [...(byValue.get(hex) ?? []), token]); } + const rgba = describeColor(value); + if (rgba !== null) { + const key = opaqueKey(rgba); + byValue.set(key, [...(byValue.get(key) ?? []), token]); + } return byValue; }, new Map()); @@ -332,37 +100,37 @@ const themeValues = themeColors.reduce((byValue, [token, value]) => { const colorProblems = (css: string): string[] => { const problems: string[] = []; - for (const { property, value } of declarations(css)) { - for (const literal of colorLiterals(value, acceptsColor(property))) { - const { hex, alpha, key } = describeColor(literal); + for (const found of stylesheetColors(css)) { + const { literal, rgba } = found; + const key = allowlistKey(found); - if (KNOWN_OFF_PALETTE.has(key)) { continue; } + if (KNOWN_OFF_PALETTE.has(key)) { continue; } - if (hex === null) { - problems.push( - `"${literal}" is a colour this check cannot resolve — build it from a theme token, or add "${key}" to KNOWN_OFF_PALETTE in ${here} with a reason` - ); - continue; - } + if (rgba === null) { + problems.push( + `"${literal}" is a colour this check cannot resolve — build it from a theme token, or add "${key}" to KNOWN_OFF_PALETTE in ${here} with a reason` + ); + continue; + } - const tokens = themeValues.get(hex); + const hex = opaqueKey(rgba); + const tokens = themeValues.get(hex); - if (alpha < 1) { - // An alpha variant of a theme colour is fine — there is no token form for it. - if (!tokens) { - problems.push( - `"${literal}" is translucent and its channels (${hex}) are not a theme value — add ${hex} to palette.ts, or "${key}" to KNOWN_OFF_PALETTE in ${here} with a reason` - ); - } - } else if (tokens) { - problems.push( - `${literal} duplicates the theme — use ${tokens.map((name) => `var(${name})`).join(' or ')}` - ); - } else { + if (rgba.a < 1) { + // An alpha variant of a theme colour is fine — there is no token form for it. + if (!tokens) { problems.push( - `${literal} is not a theme value — add it to palette.ts, or to KNOWN_OFF_PALETTE in ${here} with a reason` + `"${literal}" is translucent and its channels (${hex}) are not a theme value — add ${hex} to palette.ts, or "${key}" to KNOWN_OFF_PALETTE in ${here} with a reason` ); } + } else if (tokens) { + problems.push( + `${literal} duplicates the theme — use ${tokens.map((name) => `var(${name})`).join(' or ')}` + ); + } else { + problems.push( + `${literal} is not a theme value — add it to palette.ts, or to KNOWN_OFF_PALETTE in ${here} with a reason` + ); } } @@ -391,6 +159,10 @@ describe('theme.css', () => { * The checker below is only worth anything if it fails on the things it claims to fail on. * These cases are the contract: every colour syntax reaches the palette check, and the * ways of writing a colour that are legitimately fine stay quiet. + * + * The parsing underneath is covered in cssColors.spec.ts. What is tested here is the layer + * this file adds: which colours the ui-components palette recognises, and what an author + * is told about the ones it does not. */ describe('the colour check itself', () => { const rule = (declaration: string) => colorProblems(`.x { ${declaration} }`); @@ -402,6 +174,7 @@ describe('the colour check itself', () => { ['named colour in a shorthand', 'border: 1px solid whitesmoke;', '--ox-color-neutral-bright'], ['functional rgb', 'color: rgb(213, 213, 213);', '--ox-color-pale'], ['space-separated rgb', 'color: rgb(213 213 213 / 100%);', '--ox-color-pale'], + ['percentage rgb', 'color: rgb(100%, 100%, 100%);', '--ox-color-white'], ['hex in a var() fallback', 'color: var(--thing, #d5d5d5);', '--ox-color-pale'], ['colour in a gradient stop', 'background: linear-gradient(to right, #d5d5d5, transparent);', '--ox-color-pale'], ['named colour in a custom property', '--tabs-border-color: whitesmoke;', '--ox-color-neutral-bright'], @@ -415,6 +188,8 @@ describe('the colour check itself', () => { it.each([ ['hex', 'color: #123456;'], ['named colour', 'color: tan;'], + ['named colour in a longhand', 'color: red;'], + ['named colour in a shorthand', 'border: 1px solid red;'], ['named colour in a gradient', 'background: linear-gradient(to right, tan, transparent);'], ['rgb', 'color: rgb(1, 2, 3);'], ['hsl', 'color: hsl(200 50% 50%);'], @@ -457,23 +232,19 @@ describe('the colour check itself', () => { expect(colorProblems(css)).toEqual([expect.stringContaining('use var(--ox-color-pale)')]); }); - it.each([ - ['non-hex digits', '#ggg'], - ['five digits', '#12345'], - ['seven digits', '#1234567'], - ['nine digits', '#123456789'], - ])('treats a malformed hex (%s) as unresolvable', (_case, literal) => { - // Length alone is not enough: expandHex('#ggg') is six characters long and would - // otherwise sail through as a colour, defeating unresolvableThemeColors. - expect(describeColor(literal).hex).toBeNull(); - }); - it('can reduce every theme colour to channels', () => { // Guards the themeValues map: see unresolvableThemeColors above for why a silent drop // would be worse than a failure here. expect(unresolvableThemeColors).toEqual([]); }); + it('would fail if a theme colour were malformed', () => { + // The guard above only means something if it can fail. `#ggg` is the case that used to + // slip through it: expanded to six characters it looked like a colour, so it entered + // themeValues under a key nothing could ever match. + expect(unresolvableColors([['--ox-color-bad', '#ggg']])).toEqual(['--ox-color-bad: #ggg']); + }); + it('checks every colour token the theme projects, semantic ones included', () => { // themeColors is derived from the projection, so this cannot drift the way the // hand-written list did — --ox-color-link was absent from it, leaving the link colour From 7be1a7221cf2757571fd9bcaf5319bbe7cda157a Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Wed, 9 Sep 2026 16:40:02 +0000 Subject: [PATCH 2/3] Point the module field at the ESM entry that the build emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "module": "index.js" names a file that does not exist — there is no index.js at the package root, only dist/esm/index.js and dist/cjs/index.js. Bundlers that read the exports map never notice, because exports already routes browser/import to dist/esm. Webpack 4 does not read exports, so it falls through the missing module target to main and bundles dist/cjs, and CommonJS does not tree-shake: REX measures chunk 519 at 5.4 MB that way, over the 5 MB workbox precache limit, versus 2.4 MB through the ESM entry. Collateral damage of a types fix rather than an original sin. 1e4b31136 (#116) introduced the esm/cjs split with the correct "./dist/esm/index.js"; 7ac2a8a57 (#118) rewrote module and types together in one edit. The types half had to change — typesVersions rewrites "*" into dist/esm/, so a full path there resolves twice — but module is not subject to typesVersions and did not. So types stays "index.d.ts". Verified after the change: tsc resolves the package to dist/esm/index.d.ts and the subpath REX imports to dist/esm/theme/cssColors.d.ts, require() still gets cjs and import still gets esm through the exports map, and a webpack-4 mainFields resolver (['browser','module','main']) moves from dist/cjs/index.js to dist/esm/index.js. Asked for in review, as item 1 of "What this needs" on openstax/rex-web#3137. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 31d2eedb6..5f0013696 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "**/*.css" ], "main": "./dist/cjs/index.js", - "module": "index.js", + "module": "./dist/esm/index.js", "types": "index.d.ts", "exports": { ".": { From 584c3db7bd40adb0a94e5d109263c4365a5dcc93 Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 9 Sep 2026 13:50:12 -0500 Subject: [PATCH 3/3] Remove unnecessary explanation from comment --- src/theme/cssColors.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/theme/cssColors.ts b/src/theme/cssColors.ts index 2ec4f5898..9fd631abf 100644 --- a/src/theme/cssColors.ts +++ b/src/theme/cssColors.ts @@ -5,11 +5,6 @@ * `rgba()`, `hsl()`, named colours in shorthands and colours in gradient stops — all of * which can silently duplicate or diverge from a theme value. * - * Published deliberately, not incidentally. This engine knows nothing about - * ui-components; REX needed exactly the same thing and, because the first copy lived - * inside a spec file where nothing could import it, got a second hand-written one - * instead. The two had already diverged before either merged. See CORE-2736. - * * Two rules keep it publishable: * * - **No `fs`, no `path`, no node built-ins.** It resolves to the `browser` export