diff --git a/package.json b/package.json
index 057c6370b..3384fd4dd 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": {
".": {
diff --git a/scripts/verify-css-colors.mjs b/scripts/verify-css-colors.mjs
new file mode 100644
index 000000000..e8054d849
--- /dev/null
+++ b/scripts/verify-css-colors.mjs
@@ -0,0 +1,63 @@
+/**
+ * Regenerates the browser-verified table in src/theme/cssColors.spec.ts.
+ *
+ * Whether a colour value is valid CSS is a question of fact, not of reading the
+ * grammar carefully — several rounds of review on this parser turned on cases where
+ * the grammar reads one way and browsers do another (`rgb(255 50% 0)` looks legal and
+ * is not; `\72ed` looks like `red` and is not). So the expectations are taken from a
+ * real engine and checked in, rather than argued about.
+ *
+ * Needs a browser, which CI does not have, so this is run by hand:
+ *
+ * npx playwright install chromium
+ * node scripts/verify-css-colors.mjs
+ *
+ * It prints the table; paste it over the CHROMIUM block in cssColors.spec.ts. The
+ * version is printed with it, because a verdict is only ever that engine's: this build
+ * has no oklch(), so it rejects values a newer one accepts. The assertion is written to
+ * survive that — the audit may always decline to resolve a value, it may never resolve
+ * one to different channels than the browser, and it may never resolve one the browser
+ * rejects.
+ */
+import { chromium } from '@playwright/test';
+
+const VALUES = [
+ '#ff0000', '#f00', '#f008', '#ff000080', '#027EB5', '#ABC',
+ '#12345', '#1234567', '#123456789', '#ggg', '#gggggg', '#12345g',
+ 'red', 'RED', 'Red', 'rebeccapurple', 'notacolor', 'tan', 'white',
+ 'r\\65 d', 're\\64', '\\red', '\\72 ed', '\\72ed', '\\110000', 'r\\65d',
+ 'rgb(255, 0, 0)', 'rgba(255, 0, 0, 0.5)', 'rgb(100%, 0%, 0%)',
+ 'rgba(0, 0, 0, 20%)', 'rgb(0, 50%, 0)', 'rgba(255, 50%, 0, 0.5)',
+ 'rgb(0, 0)', 'rgb(0, 0, 0, 0, 0)', 'rgb(0, 0, 0 / 0.5)', 'rgb(300, 0, 0)',
+ 'rgb(-10, 0, 0)', 'rgb(255 0 0)', 'rgb(255 0 0 / 0.5)', 'rgb(50% 50% 50%)',
+ 'rgb(255 50% 0)', 'rgb(0 0 0 0.5)', 'rgb(0 0 0 // 0.5)', 'rgb(0 0 0 /)',
+ 'rgb(2.55e2 0 0)', 'rgb(.0 0 0)', 'rgb(+255 0 0)', 'rgb(. 0 0)',
+ 'rgb(1..2 0 0)', 'rgb(1e 0 0)', 'rgb(0 0 0 / 50%)', 'rgb(var(--c), 0, 0)',
+ 'hsl(0 100% 50%)', 'hsl(0, 100%, 50%)', 'oklch(0.7 0.1 200)',
+ 'color(display-p3 1 0 0)', 'hwb(0 0% 0%)', 'lab(50% 40 30)',
+];
+
+const SENTINEL = 'rgb(1, 2, 3)';
+const browser = await chromium.launch();
+const page = await browser.newPage();
+await page.setContent(
+ `' +
+ VALUES.map((_, i) => `
x
`).join('')
+);
+
+const rows = [];
+for (let i = 0; i < VALUES.length; i++) {
+ const computed = await page.$eval('#c' + i, (el) => getComputedStyle(el).color);
+ rows.push([VALUES[i], computed === SENTINEL ? 'REJECTED' : computed]);
+}
+const version = browser.version();
+await browser.close();
+
+console.log(` // generated by scripts/verify-css-colors.mjs against Chromium ${version}`);
+
+const escape = (text) => text.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+const width = Math.max(...rows.map(([v]) => escape(v).length)) + 3;
+console.log(rows
+ .map(([v, c]) => ` [${(`'${escape(v)}',`).padEnd(width)}'${c}'],`)
+ .join('\n'));
diff --git a/src/theme/cssColors.spec.ts b/src/theme/cssColors.spec.ts
new file mode 100644
index 000000000..32c385387
--- /dev/null
+++ b/src/theme/cssColors.spec.ts
@@ -0,0 +1,778 @@
+import {
+ Rgba,
+ 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('only blanks url(), not a function whose name merely ends in url', () => {
+ // `myurl(...)` is an unknown container to be descended into, not noise
+ expect(stripNoise('a { --x: myurl(#fff); }')).toContain('#fff');
+ });
+
+ 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('does not end a url() at a parenthesis inside its quoted payload', () => {
+ // `url("icon).svg")` closes at the last paren. Stopping at the first one leaves the
+ // trailing quote behind, and that "unterminated string" blanks the rest of the rule.
+ const blanked = stripNoise('a { background: url("icon).svg"); color: red; }');
+ expect(blanked).not.toContain('icon');
+ expect(blanked).toContain('color: red;');
+ });
+
+ it('does not end a url() at an escaped parenthesis', () => {
+ expect(stripNoise('a { background: url(icon\\).svg); color: red; }'))
+ .toContain('color: red;');
+ });
+
+ it('treats an escaped quote in a selector as ordinary text', () => {
+ // `.foo\"bar` is a valid class name. Reading its quote as a string opener blanks
+ // everything after it, and the declaration disappears from the audit.
+ expect(stripNoise('.foo\\"bar { color: red; }')).toContain('color: red;');
+ });
+
+ 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 }'],
+ ['a url() with a paren in its payload', 'a { background: url("icon).svg"); }'],
+ ['a url() with an unterminated quoted payload', 'a { background: url("oops }'],
+ ['a url() ending in a trailing escape', 'a { background: url(oops\\'],
+ ['an escaped quote in a selector', '.foo\\"bar { color: red; }'],
+ ['a stylesheet ending in an escape', 'a { color: red; } \\'],
+ ['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 the declarations after a url() whose payload contains a parenthesis', () => {
+ const parsed = values('a { background: url("icon).svg"); color: red; }');
+
+ expect(parsed).toHaveLength(2);
+ expect(parsed[1]).toEqual('red');
+ });
+
+ it('reads several url() payloads in one value', () => {
+ const parsed = declarations('a { background: url("a).svg") no-repeat, url(b); color: red; }');
+
+ expect(parsed).toHaveLength(2);
+ expect(parsed[1]).toEqual({ context: 'a', property: 'color', value: 'red' });
+ });
+
+ it('keeps a brace block as a custom property value rather than a nested rule', () => {
+ // `--x: { red }` is a valid declaration whose value is a block of component
+ // values. Pushing the block as selector context loses the value entirely.
+ expect(declarations(':root { --x: { red }; }'))
+ .toEqual([{ context: ':root', property: '--x', value: '{ red }' }]);
+ });
+
+ it('still reads a nested rule as a rule, not as a value', () => {
+ // the brace-block rule must not swallow real nesting: this is two contexts
+ expect(declarations('a { color: red; b { color: blue; } }').map((d) => d.context))
+ .toEqual(['a', 'a b']);
+ });
+
+ 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('keeps the case of a custom property name, which CSS is case-sensitive about', () => {
+ // `--Brand` and `--brand` are two different custom properties, so lower-casing them
+ // would merge two distinct declarations in the audit metadata.
+ expect(declarations(':root { --Brand: #fff; --brand: #000; }')
+ .map(({ property }) => property)).toEqual(['--Brand', '--brand']);
+ });
+
+ 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('does not collapse whitespace inside a selector string', () => {
+ // the whole point of keeping string contents is that two rules differing only
+ // inside a selector string stay distinguishable — collapsing runs of spaces there
+ // merges them again.
+ const parsed = declarations(
+ '[data-label="a b"] { color: #fff; } [data-label="a b"] { color: #fff; }'
+ );
+
+ expect(parsed.map(({ context }) => context))
+ .toEqual(['[data-label="a b"]', '[data-label="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.each([
+ ['a quote', '.foo\\"bar'],
+ ['a brace', '.foo\\{bar'],
+ ['a semicolon', '.foo\\;bar'],
+ ])('does not read %s escaped in a selector as structure', (_case, selector) => {
+ // each of these is one class name. Read as structure they corrupt the context
+ // stack — the brace opens a block that never closes, the semicolon truncates the
+ // selector — and the quote blanks the rest of the stylesheet outright.
+ expect(declarations(`${selector} { color: red; }`))
+ .toEqual([{ context: selector, property: 'color', value: 'red' }]);
+ });
+
+ 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.each(['list-style', 'color-scheme'])(
+ 'rejects %s, whose bare identifier names something the author defined', (property) => {
+ // `@counter-style red` and a `red` colour scheme are both legal, and neither is
+ // a colour — so neither can be reported as one.
+ expect(takesColor(property)).toBe(false);
+ }
+ );
+
+ it.each([
+ 'print-color-adjust', '-webkit-print-color-adjust', 'forced-color-adjust',
+ 'color-interpolation-filters',
+ ])('rejects %s, which is colour-named but holds no colour', (property) => {
+ 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; }'],
+ ['a counter style', 'a { list-style: red; }'],
+ ['a colour scheme', 'a { color-scheme: red; }'],
+ // 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.each([
+ ['a gradient', 'a { list-style-image: linear-gradient(red, blue); }'],
+ ['a repeating gradient', 'a { list-style-image: repeating-conic-gradient(red, blue); }'],
+ ['a vendor-prefixed gradient', 'a { list-style-image: -webkit-linear-gradient(red, blue); }'],
+ ['color-mix()', 'a { list-style-image: color-mix(in srgb, red, blue); }'],
+ ])('reads named colours in %s under a property that cannot hold one', (_case, css) => {
+ // `list-style-image` takes an image, but a gradient's stops are colours wherever
+ // the gradient is written, so the property gate must not reach inside one.
+ expect(literals(css)).toEqual(['red', 'blue']);
+ });
+
+ it('reads the fallback colour in image(), whose first argument is a url', () => {
+ // `image()` takes an image and then a bare `` to fall back to, so the stop
+ // is a colour however the property is spelled. The url payload is blanked, so the
+ // `#` of a fragment in it cannot be read as a hex literal.
+ expect(literals('a { list-style-image: image(url(marker.svg#a), red); }'))
+ .toEqual(['red']);
+ });
+
+ it('does not open the named-colour gate inside image-set(), which holds no colour', () => {
+ // the sibling function takes images and resolutions only, so an identifier there is
+ // not a colour -- it is listed as deliberately absent from COLOR_CONTAINERS.
+ expect(literals('a { list-style-image: image-set(red 1x); }')).toEqual([]);
+ // a gradient inside one still opens its own gate, so nothing is lost by the absence
+ expect(literals('a { list-style-image: image-set(linear-gradient(red, blue) 1x); }'))
+ .toEqual(['red', 'blue']);
+ });
+
+ it('keeps the property gate inside var(), whose fallback is not known to be a colour', () => {
+ // the other half: `var()` is whatever the property makes of it, so an identifier in
+ // a fallback is only a colour when the property says so.
+ expect(literals('a { animation-name: var(--enter, red); }')).toEqual([]);
+ expect(literals('a { color: var(--enter, red); }')).toEqual(['red']);
+ });
+
+ it('still finds a gradient in the list-style shorthand', () => {
+ // dropping `list-style` from the shorthands must not lose its image component:
+ // the gradient opens the gate for its own stops whatever property it sits in.
+ expect(literals('a { list-style: square linear-gradient(red, blue); }'))
+ .toEqual(['red', 'blue']);
+ });
+
+ 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.each([
+ ['a url fragment', 'url(#fff)'],
+ ['a quoted string', '"red"'],
+ ['a string in a shorthand', '0 0 0 "red"'],
+ ['a comment', '/* red */ 0'],
+ ['a data: URI', 'url(data:image/svg+xml;utf8,)'],
+ ])('blanks %s in a raw value handed straight to findColors', (_case, value) => {
+ // findColors takes a value as written, not one a caller has already cleaned up:
+ // `stylesheetColors` gets that for free from `declarations` and a direct caller
+ // should not have to know it is a precondition.
+ expect(findColors(value, true)).toEqual([]);
+ });
+
+ it.each([
+ ['a hex literal', '#fff', '#fff'],
+ ['a named colour beside a string', '"x" red', 'red'],
+ ['a colour after a url', 'url(a.svg) red', 'red'],
+ ])('still finds %s in a raw value', (_case, value, literal) => {
+ // the other direction: blanking the noise must not blank the colours with it
+ expect(findColors(value, true).map((found) => found.literal)).toEqual([literal]);
+ });
+
+ it.each([
+ ['a hex escape with its terminating space', 'a { color: r\\65 d; }', 'r\\65 d'],
+ ['a hex escape at the end of the identifier', 'a { color: re\\64; }', 're\\64'],
+ ['an escaped ordinary character', 'a { color: \\red; }', '\\red'],
+ ['a hex escape spelling the first letter', 'a { color: \\72 ed; }', '\\72 ed'],
+ ])('decodes %s so the named colour is not evaded', (_case, css, literal) => {
+ // CSS tokenizes all three of these as the identifier `red`, so an audit that
+ // reads them as separate words is trivially bypassed.
+ const found = stylesheetColors(css);
+ expect(found).toHaveLength(1);
+ // the literal stays the original source span, since that is what a consumer
+ // has to find and rewrite in the file
+ expect(found[0].literal).toEqual(literal);
+ expect(found[0].rgba).toEqual({ r: 255, g: 0, b: 0, a: 1 });
+ });
+
+ it('does not decode an escape into a colour where the property forbids one', () => {
+ expect(literals('a { animation-name: r\\65 d; }')).toEqual([]);
+ });
+
+ it('consumes up to six hex digits, so \\72ed is one character and not red', () => {
+ // `e` and `d` are hex digits, so this escape is U+72ED and the declaration is not
+ // a colour at all — Chromium rejects it. Stopping at two digits would invent a
+ // finding out of valid CSS.
+ expect(literals('a { color: \\72ed; }')).toEqual([]);
+ });
+
+ it('does not throw on an escape outside the Unicode range', () => {
+ expect(() => literals('a { color: \\110000 ; }')).not.toThrow();
+ });
+
+ it.each(['constructor', '__proto__'])(
+ 'does not report or crash on %s, which is inherited rather than a colour', (name) => {
+ // a crash in the audit takes down the suite of whichever consumer is running it,
+ // so this is worse than the wrong answer it also gave
+ expect(() => stylesheetColors(`a { color: ${name}; }`)).not.toThrow();
+ expect(literals(`a { color: ${name}; }`)).toEqual([]);
+ expect(literals(`:root { --x: ${name}; }`)).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.each([
+ // modern syntax puts the alpha behind a single slash; without it this is four
+ // channels, which is not a grammar rgb() has
+ 'rgb(0 0 0 0.5)',
+ 'rgb(0 0 0 // 0.5)',
+ 'rgb(0 0 0 /)',
+ // and the legacy comma syntax has no slash at all
+ 'rgb(0, 0, 0 / 0.5)',
+ ])('returns null for the malformed rgb() grammar %s', (literal) => {
+ expect(describeColor(literal)).toBeNull();
+ });
+
+ it.each([
+ 'rgb(0, 50%, 0)', 'rgba(255, 50%, 0, 0.5)',
+ // not just the comma syntax: rgb() splits into an all-number and an
+ // all-percentage production in both spellings, so neither permits mixing.
+ // Chromium rejects all three of these.
+ 'rgb(255 50% 0)',
+ ])('returns null for %s, since rgb() cannot mix channel units', (literal) => {
+ expect(describeColor(literal)).toBeNull();
+ });
+
+ it('reads an all-percentage legacy triple, which is consistent', () => {
+ expect(describeColor('rgb(100%, 50%, 0%)')).toEqual({ r: 255, g: 128, b: 0, a: 1 });
+ });
+
+ 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(['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty'])(
+ 'returns null for %s rather than reading a key off Object.prototype', (name) => {
+ // `constructor` and `__proto__` are the inherited keys that survive being
+ // lower-cased. They used to look up to a function and an object, both truthy,
+ // which `fromHex` then crashed on.
+ expect(describeColor(name)).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.each([
+ 'rgb(., 0, 0)', 'rgb(1..2, 0, 0)', 'rgb(1.2.3, 0, 0)', 'rgb(0, 0, 0, .)',
+ 'rgba(0, 0, 0, 1..2)', 'rgb(.%, 0, 0)', 'rgb(1e, 0, 0)',
+ ])('returns null for the malformed number in %s', (literal) => {
+ // `[\\d.]+` also matches `.` and `1..2`; parseFloat turns those into NaN and a
+ // truncated 1, either of which would be handed back as a resolved channel.
+ expect(describeColor(literal)).toBeNull();
+ });
+
+ it.each([
+ ['no integer part', 'rgb(.0, 0, 0)'],
+ ['an explicit plus sign', 'rgb(+255, 0, 0)'],
+ ['exponent notation', 'rgb(2.55e2, 0, 0)'],
+ ])('still reads a channel written with %s', (_case, literal) => {
+ expect(describeColor(literal)?.r).toEqual(literal.includes('.0') ? 0 : 255);
+ });
+
+ 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')));
+ });
+});
+
+/**
+ * What a real engine makes of each value, generated by scripts/verify-css-colors.mjs.
+ *
+ * Whether a colour value is valid CSS turned out to be a question of fact rather than
+ * of reading the grammar carefully. Two rounds of review here turned on cases where the
+ * grammar reads one way and browsers go the other: `rgb(255 50% 0)` looks legal and is
+ * not, because rgb() splits into an all-number and an all-percentage production rather
+ * than accepting three of either; `\72ed` looks like `red` and is not, because hex
+ * escapes consume up to six digits and `e` and `d` are hex digits. So these are taken
+ * from the engine instead of argued about.
+ *
+ * The verdict is only that build's, so the assertion is one-directional where it has to
+ * be: this engine has no oklch(), and a newer one would accept what it rejects. The
+ * audit may always decline to resolve a value, since `hsl()` and the rest are reported
+ * as unresolvable by design. What it may never do is resolve one to different channels
+ * than the browser, or resolve one the browser throws away.
+ */
+const CHROMIUM: Array<[string, string]> = [
+ // generated by scripts/verify-css-colors.mjs against Chromium 105.0.5195.19
+ ['#ff0000', 'rgb(255, 0, 0)'],
+ ['#f00', 'rgb(255, 0, 0)'],
+ ['#f008', 'rgba(255, 0, 0, 0.533)'],
+ ['#ff000080', 'rgba(255, 0, 0, 0.5)'],
+ ['#027EB5', 'rgb(2, 126, 181)'],
+ ['#ABC', 'rgb(170, 187, 204)'],
+ ['#12345', 'REJECTED'],
+ ['#1234567', 'REJECTED'],
+ ['#123456789', 'REJECTED'],
+ ['#ggg', 'REJECTED'],
+ ['#gggggg', 'REJECTED'],
+ ['#12345g', 'REJECTED'],
+ ['red', 'rgb(255, 0, 0)'],
+ ['RED', 'rgb(255, 0, 0)'],
+ ['Red', 'rgb(255, 0, 0)'],
+ ['rebeccapurple', 'rgb(102, 51, 153)'],
+ ['notacolor', 'REJECTED'],
+ ['tan', 'rgb(210, 180, 140)'],
+ ['white', 'rgb(255, 255, 255)'],
+ ['r\\65 d', 'rgb(255, 0, 0)'],
+ ['re\\64', 'rgb(255, 0, 0)'],
+ ['\\red', 'rgb(255, 0, 0)'],
+ ['\\72 ed', 'rgb(255, 0, 0)'],
+ ['\\72ed', 'REJECTED'],
+ ['\\110000', 'REJECTED'],
+ ['r\\65d', 'REJECTED'],
+ ['rgb(255, 0, 0)', 'rgb(255, 0, 0)'],
+ ['rgba(255, 0, 0, 0.5)', 'rgba(255, 0, 0, 0.5)'],
+ ['rgb(100%, 0%, 0%)', 'rgb(255, 0, 0)'],
+ ['rgba(0, 0, 0, 20%)', 'rgba(0, 0, 0, 0.2)'],
+ ['rgb(0, 50%, 0)', 'REJECTED'],
+ ['rgba(255, 50%, 0, 0.5)', 'REJECTED'],
+ ['rgb(0, 0)', 'REJECTED'],
+ ['rgb(0, 0, 0, 0, 0)', 'REJECTED'],
+ ['rgb(0, 0, 0 / 0.5)', 'REJECTED'],
+ ['rgb(300, 0, 0)', 'rgb(255, 0, 0)'],
+ ['rgb(-10, 0, 0)', 'rgb(0, 0, 0)'],
+ ['rgb(255 0 0)', 'rgb(255, 0, 0)'],
+ ['rgb(255 0 0 / 0.5)', 'rgba(255, 0, 0, 0.5)'],
+ ['rgb(50% 50% 50%)', 'rgb(128, 128, 128)'],
+ ['rgb(255 50% 0)', 'REJECTED'],
+ ['rgb(0 0 0 0.5)', 'REJECTED'],
+ ['rgb(0 0 0 // 0.5)', 'REJECTED'],
+ ['rgb(0 0 0 /)', 'REJECTED'],
+ ['rgb(2.55e2 0 0)', 'rgb(255, 0, 0)'],
+ ['rgb(.0 0 0)', 'rgb(0, 0, 0)'],
+ ['rgb(+255 0 0)', 'rgb(255, 0, 0)'],
+ ['rgb(. 0 0)', 'REJECTED'],
+ ['rgb(1..2 0 0)', 'REJECTED'],
+ ['rgb(1e 0 0)', 'REJECTED'],
+ ['rgb(0 0 0 / 50%)', 'rgba(0, 0, 0, 0.5)'],
+ ['rgb(var(--c), 0, 0)', 'REJECTED'],
+ ['hsl(0 100% 50%)', 'rgb(255, 0, 0)'],
+ ['hsl(0, 100%, 50%)', 'rgb(255, 0, 0)'],
+ ['oklch(0.7 0.1 200)', 'REJECTED'],
+ ['color(display-p3 1 0 0)','REJECTED'],
+ ['hwb(0 0% 0%)', 'rgb(255, 0, 0)'],
+ ['lab(50% 40 30)', 'REJECTED'],
+];
+
+describe('agreement with a browser', () => {
+ /**
+ * Alpha is compared as eighths of a bit rather than as a decimal, because that is all
+ * the precision a browser keeps: Chromium serialises `#ff000080` as alpha `0.5`, not
+ * as 128/255 = `0.502`. That is a narrower comparison than `colorKey` makes, which
+ * holds alpha decimal on purpose so that two allowlist entries cannot collide — a
+ * different question from whether we agree with the browser about the colour.
+ */
+ const comparable = ({ r, g, b, a }: Rgba) =>
+ `rgb(${r}, ${g}, ${b}) at alpha ${Math.round(a * 255)}/255`;
+
+ const asChromiumSees = (serialised: string) => {
+ const args = /^rgba?\(([^)]*)\)$/.exec(serialised);
+ if (args === null) { throw new Error(`cannot read ${serialised}`); }
+ const [r, g, b, a] = args[1].split(',').map((part) => parseFloat(part));
+ return comparable({ r, g, b, a: a === undefined ? 1 : a });
+ };
+
+ it.each(CHROMIUM)('resolves %s as Chromium does', (value, verdict) => {
+ const resolved = stylesheetColors(`a { color: ${value}; }`)
+ .map(({ rgba }) => rgba)
+ .filter((rgba): rgba is Rgba => rgba !== null);
+
+ if (verdict === 'REJECTED') {
+ // resolving one of these is how a malformed theme value passes the consumer's
+ // "every colour token resolves" guard while generating CSS the browser drops
+ expect(resolved.map(comparable)).toEqual([]);
+ return;
+ }
+
+ // declining is allowed, being wrong is not
+ if (resolved.length === 0) { return; }
+
+ expect(comparable(resolved[0])).toEqual(asChromiumSees(verdict));
+ });
+});
diff --git a/src/theme/cssColors.ts b/src/theme/cssColors.ts
new file mode 100644
index 000000000..976dc69dd
--- /dev/null
+++ b/src/theme/cssColors.ts
@@ -0,0 +1,780 @@
+/**
+ * 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.
+ *
+ * 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,
+ * with runs of whitespace collapsed outside strings:
+ * `@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;
+ /**
+ * The property name, e.g. `background-color`. Lower-cased, because CSS matches
+ * property names case-insensitively — except for a custom property such as
+ * `--tabs-border-color`, whose name is case-sensitive and is kept as written.
+ */
+ 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
+ *
+ * Prototype-less, so a lookup can only answer for a colour that is actually in the
+ * table. An object literal inherits from `Object.prototype`, where `constructor` and
+ * `__proto__` are truthy — and they are the two inherited keys that survive being
+ * lower-cased, so `color: constructor` looked up to a function, was reported as a
+ * colour, and then crashed `fromHex` on `.toLowerCase()`. A crash here takes down the
+ * whole suite of whichever consumer is running the audit.
+ *
+ * Fixed on the table rather than at the two lookups, because guarding a call site only
+ * holds until someone adds a third.
+ */
+const NAMED_COLORS: Record = Object.assign(Object.create(null), {
+ 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 —
+ * see `COLOR_CONTAINERS` for which of those are colour-bearing by definition.
+ */
+const COLOR_FUNCTIONS = [
+ 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color',
+ 'device-cmyk',
+];
+
+/**
+ * Functions that contain colours rather than being one, and whose arguments are known
+ * to be colour-valued whatever property they sit in. `list-style-image` takes an image,
+ * but `linear-gradient(red, blue)` is still a gradient between two colours, so the
+ * property gate must not reach inside one.
+ *
+ * `var()` is deliberately absent: a fallback is whatever the property makes of it, so
+ * it keeps the gate of the property it was written in.
+ *
+ * `image()` is here for its second argument, which is a bare fallback ``:
+ * `image(url(marker.svg), red)` renders red if the marker fails to load. `image-set()`
+ * is deliberately *not* here -- it holds images and resolutions, never a colour.
+ */
+const COLOR_CONTAINERS = [
+ 'linear-gradient', 'radial-gradient', 'conic-gradient', 'repeating-linear-gradient',
+ 'repeating-radial-gradient', 'repeating-conic-gradient', 'color-mix', 'light-dark',
+ 'cross-fade', 'image',
+];
+
+/**
+ * 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);
+
+ // A CSS escape makes the character after it ordinary source text, so it has to be
+ // taken before anything that looks for a delimiter: `.foo\"bar` is a valid class
+ // name whose quote opens no string, and treating it as one blanks the rest of the
+ // stylesheet. Both characters are copied through rather than blanked — an escape in
+ // a value is part of an identifier, and `\red` really is the colour `red`.
+ if (css[index] === '\\') {
+ out += css.slice(index, index + 2);
+ index += 2;
+ continue;
+ }
+
+ 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;
+ }
+
+ // `url` has to be the whole function name rather than the tail of one. In
+ // `--x: myurl(#fff)` the payload is ordinary value text to descend into, and
+ // blanking it loses the colour. The preceding source character settles it: an
+ // ident character there means `url` is only a suffix.
+ const boundary = index === 0 || !/[\w-]/.test(css[index - 1]);
+ const url = boundary ? /^url\(/i.exec(rest) : null;
+ if (url) {
+ const open = index + url[0].length;
+ let depth = 1;
+ let cursor = open;
+ // only a structural `)` ends the url: `url("icon).svg")` closes at the last
+ // paren, not at the one in the filename. Stopping early would leave the trailing
+ // quote behind, and blanking that "unterminated string" would swallow every
+ // declaration after it.
+ while (cursor < css.length && depth > 0) {
+ const character = css[cursor];
+
+ if (character === '\\') { cursor += 2; continue; }
+
+ if (character === '"' || character === '\'') {
+ cursor++;
+ while (cursor < css.length && css[cursor] !== character) {
+ cursor += css[cursor] === '\\' ? 2 : 1;
+ }
+ cursor++;
+ continue;
+ }
+
+ if (character === '(') { depth++; }
+ if (character === ')') { depth--; }
+ cursor++;
+ }
+ // an escape or a quote at the very end can carry the cursor past the end, and the
+ // blanked copy has to stay the same length as the input.
+ cursor = Math.min(cursor, css.length);
+ // 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);
+
+/**
+ * Collapses runs of whitespace in a selector, but only where the whitespace is
+ * separator rather than content. `[data-label="a b"]` and `[data-label="a b"]` match
+ * different values, so a context that collapsed both to the latter would stop telling
+ * two rules apart — which is the one job the context has.
+ */
+const collapseSeparators = (selector: string): string => {
+ let out = '';
+ let index = 0;
+
+ while (index < selector.length) {
+ const character = selector[index];
+
+ if (character === '\\') {
+ // an escaped space is part of an identifier, e.g. the class `.a\\ b`
+ out += selector.slice(index, index + 2);
+ index += 2;
+ continue;
+ }
+
+ if (character === '"' || character === '\'') {
+ let cursor = index + 1;
+ while (cursor < selector.length && selector[cursor] !== character) {
+ cursor += selector[cursor] === '\\' ? 2 : 1;
+ }
+ const stop = Math.min(cursor + 1, selector.length);
+ out += selector.slice(index, stop);
+ index = stop;
+ continue;
+ }
+
+ if (/\s/.test(character)) {
+ while (index < selector.length && /\s/.test(selector[index])) { index++; }
+ out += ' ';
+ continue;
+ }
+
+ out += character;
+ index++;
+ }
+
+ return out.trim();
+};
+
+/**
+ * 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.
+ */
+/**
+ * A declaration whose property is a custom property, up to and including its colon.
+ * This is what tells `--x: { red }`, a declaration whose value happens to be a block,
+ * from `a { ... }`, a nested rule.
+ */
+const CUSTOM_PROPERTY_DECLARATION = /^\s*--[^\s:]*\s*:/;
+
+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;
+ let blocks = 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 name = segment.slice(0, separator).trim();
+ // ordinary property names are case-insensitive, but a custom property's is not:
+ // `--Brand` and `--brand` are two different properties and must stay two.
+ const property = name.startsWith('--') ? name : name.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];
+
+ // same rule as in `blankNoise`, and needed again because this scanner reads the
+ // blanked copy, where escapes survive: `.foo\{bar` is one class name, so its brace
+ // must not open a block and its semicolon must not end a declaration.
+ if (character === '\\') { index++; continue; }
+
+ if (character === '(') { parens++; }
+ if (character === ')') { parens = Math.max(0, parens - 1); }
+ if (parens !== 0) { continue; }
+
+ // A custom property takes an arbitrary token stream, so a `{}` block in its value
+ // is a component value rather than a nested rule: `--x: { red }` is a declaration
+ // whose value is `{ red }`. Everything inside belongs to the value, `;` included,
+ // so the structural rules are suspended until the block closes.
+ if (blocks > 0) {
+ if (character === '{') { blocks++; }
+ if (character === '}') { blocks--; }
+ continue;
+ }
+
+ if (character === '{') {
+ if (CUSTOM_PROPERTY_DECLARATION.test(values.slice(start, index))) {
+ blocks = 1;
+ continue;
+ }
+ stack.push(collapseSeparators(selectors.slice(start, index)));
+ 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.
+ *
+ * `list-style` is deliberately absent, though it is image-bearing: its bare identifier
+ * is a `` name, and after `@counter-style red { ... }` the declaration
+ * `list-style: red` is valid and means that counter. A gradient written there is still
+ * found, because a gradient opens the gate for its own stops — see `COLOR_CONTAINERS`.
+ */
+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', 'mask', 'mask-image', 'outline', 'scrollbar', 'stroke',
+ 'text-decoration', 'text-emphasis', 'text-shadow', 'text-stroke',
+];
+
+/**
+ * Properties the `color` substring claims but that hold no ``.
+ *
+ * `color-scheme` is the one that actually produces a false finding: its value is an
+ * author-defined ``, so `color-scheme: red` names a scheme and reporting
+ * it as red would be wrong — with a suggested fix that breaks the declaration. The rest
+ * take fixed keywords, none of which is a colour name, so excluding them changes no
+ * result today; they are listed because the substring has no business claiming them and
+ * a future keyword could collide.
+ */
+const NOT_COLOR_PROPERTIES = [
+ 'color-scheme', 'color-adjust', 'print-color-adjust', 'forced-color-adjust',
+ 'color-interpolation', 'color-interpolation-filters', 'color-rendering',
+];
+
+const unprefixed = (name: string) => name.replace(/^-(?:webkit|moz|ms|o)-/, '');
+
+/** 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 = unprefixed(property.toLowerCase());
+
+ if (NOT_COLOR_PROPERTIES.includes(name)) { return false; }
+
+ return name.includes('color') || COLOR_SHORTHANDS.includes(name);
+};
+
+/** Whether this function's arguments are colours regardless of the enclosing property. */
+const holdsColor = (fn: string): boolean => COLOR_CONTAINERS.includes(unprefixed(fn));
+
+const clamp = (value: number, max: number) => Math.min(max, Math.max(0, value));
+
+/**
+ * The CSS `` grammar, shared by the channels and the alpha rather than
+ * approximated as "digits and dots". `[\d.]+` also matches `.` and `1..2`, which
+ * `parseFloat` turns into `NaN` and a truncated `1`; both would then be handed back as
+ * resolved channels, so a malformed declaration would read as a real colour and get a
+ * comparison key built out of `NaN`.
+ */
+const NUMBER = '[+-]?(?:\\d+|\\d*\\.\\d+)(?:e[+-]?\\d+)?';
+const IS_NUMBER = new RegExp(`^${NUMBER}$`, 'i');
+const IS_PERCENTAGE = new RegExp(`^(${NUMBER})%$`, 'i');
+
+const channel = (raw: string): number | null => {
+ const text = raw.trim();
+ const percent = IS_PERCENTAGE.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 IS_NUMBER.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 = IS_PERCENTAGE.exec(text);
+ if (percent) { return clamp(parseFloat(percent[1]), 100) / 100; }
+ return IS_NUMBER.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,
+ };
+};
+
+/**
+ * Splits `rgb()`/`rgba()` arguments, or null when none of the productions hold.
+ *
+ * The two syntaxes are parsed separately rather than by normalising the separators
+ * away, because they are four distinct productions and not interchangeable:
+ *
+ * rgb( {3} [ / ]? ) rgb( #{3} , ? )
+ * rgb( {3} [ / ]? ) rgb( #{3} , ? )
+ *
+ * Two consequences, both checked against Chromium rather than read off the grammar:
+ * the modern syntax puts its alpha behind exactly one slash, so `rgb(0 0 0 0.5)` is
+ * four channels and not a colour; and each production takes three channels of *one*
+ * type, so `rgb(0, 50%, 0)` and `rgb(0 50% 0)` are both invalid. Resolving either
+ * would let a malformed theme value pass the consumer's "every colour token resolves"
+ * guard while generating CSS the browser drops on the floor.
+ */
+const rgbaArgs = (raw: string): string[] | null => {
+ const consistent = (channels: string[]) => {
+ const percentages = channels.filter((c) => c.trim().endsWith('%')).length;
+ return percentages === 0 || percentages === channels.length;
+ };
+
+ if (raw.includes(',')) {
+ // the legacy syntax has no slash anywhere
+ if (raw.includes('/')) { return null; }
+
+ const parts = raw.split(',');
+ if (parts.length < 3 || parts.length > 4) { return null; }
+
+ return consistent(parts.slice(0, 3)) ? parts : null;
+ }
+
+ const [channels, alpha, ...extra] = raw.split('/');
+ if (extra.length > 0) { return null; }
+
+ const parts = channels.trim().split(/\s+/);
+ if (parts.length !== 3 || !consistent(parts)) { return null; }
+ if (alpha === undefined) { return parts; }
+
+ // a slash with nothing after it is not an alpha
+ return alpha.trim() === '' ? null : [...parts, alpha];
+};
+
+/**
+ * One CSS escape: a backslash and up to six hex digits, whose terminating whitespace
+ * belongs to the escape, or a backslash and any single character bar a newline.
+ *
+ * The six-digit limit is the whole subtlety. `e` and `d` are hex digits, so `\72ed` is
+ * U+72ED rather than `r` followed by `ed` — Chromium rejects `color: \72ed` outright.
+ * Stopping short of six would invent a finding out of valid CSS.
+ */
+const ESCAPE = /^\\(?:([0-9a-fA-F]{1,6})[ \t\n\r\f]?|([^\n\r\f]))/;
+
+/** An identifier cannot start with a digit; a leading `-` or an escape is fine. */
+const IDENT_START = /[a-zA-Z_\u0080-\uffff\\-]/;
+const IDENT_CHARACTER = /[a-zA-Z0-9_\u0080-\uffff-]/;
+
+/**
+ * The source span of one CSS identifier at `from`, or null if there is not one there.
+ *
+ * Escapes are part of an identifier, so the span can be longer than the name it spells:
+ * `r\65 d` is five characters of source for the three of `red`. The span rather than
+ * the name is what a consumer has to find and rewrite in the file, so both are kept --
+ * `decodeEscapes` is the other half.
+ */
+const identSpan = (value: string, from: number): string | null => {
+ if (!IDENT_START.test(value[from] ?? '')) { return null; }
+
+ let index = from;
+
+ while (index < value.length) {
+ if (value[index] === '\\') {
+ const escape = ESCAPE.exec(value.slice(index));
+ // a backslash before a newline is not an escape, and ends the identifier
+ if (escape === null) { break; }
+ index += escape[0].length;
+ continue;
+ }
+
+ if (!IDENT_CHARACTER.test(value[index])) { break; }
+ index++;
+ }
+
+ return index === from ? null : value.slice(from, index);
+};
+
+/**
+ * Decodes CSS escapes, so that the audit reads the identifier CSS reads. `r\65 d`,
+ * `re\64`, `\red` and `\72 ed` are four spellings of `red` and all four resolve to it
+ * in Chromium, so an audit that reads them as separate words is trivially bypassed.
+ */
+const decodeEscapes = (text: string): string => {
+ if (!text.includes('\\')) { return text; }
+
+ let out = '';
+ let index = 0;
+
+ while (index < text.length) {
+ const escape = text[index] === '\\' ? ESCAPE.exec(text.slice(index)) : null;
+
+ if (escape === null) {
+ out += text[index];
+ index++;
+ continue;
+ }
+
+ if (escape[1] === undefined) {
+ out += escape[2];
+ } else {
+ const point = parseInt(escape[1], 16);
+ // null, a surrogate and anything past the last plane all become the replacement
+ // character, which is what CSS says and also stops fromCodePoint throwing
+ out += point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)
+ ? '\ufffd'
+ : String.fromCodePoint(point);
+ }
+
+ index += escape[0].length;
+ }
+
+ return out;
+};
+
+/**
+ * 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[decodeEscapes(text).toLowerCase()];
+ if (named) { return fromHex(named); }
+
+ const fn = /^(rgba?)\((.*)\)$/is.exec(text);
+ if (!fn) { return null; }
+
+ const args = rgbaArgs(fn[2]);
+ if (args === null) { 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 };
+};
+
+/**
+ * The colour walk itself, over a value whose noise has already been blanked.
+ *
+ * Split from `findColors` because the two have different preconditions rather than
+ * different behaviour: this one requires a blanked value and is also how it recurses
+ * into its own arguments, where re-blanking would be wasted work.
+ */
+const scanColors = (value: string, named: boolean): FoundColor[] => {
+ const found: FoundColor[] = [];
+ let index = 0;
+
+ while (index < value.length) {
+ const rest = value.slice(index);
+
+ // the leading `-` matters: without it `-webkit-linear-gradient(...)` is not read as
+ // a call at all, and its stops are walked as if they were loose identifiers.
+ 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);
+ const fn = call[1].toLowerCase();
+
+ if (COLOR_FUNCTIONS.includes(fn)) {
+ found.push({ literal, rgba: describeColor(literal) });
+ } else {
+ // a colour-bearing container opens the gate for its arguments; anything else
+ // just passes the enclosing property's gate down unchanged.
+ found.push(...scanColors(args, named || holdsColor(fn)));
+ }
+
+ 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 ident = identSpan(value, index);
+ if (ident) {
+ const name = decodeEscapes(ident).toLowerCase();
+ if (named && NAMED_COLORS[name] && !COLOR_KEYWORDS.includes(name)) {
+ found.push({ literal: ident, rgba: describeColor(ident) });
+ }
+ index += ident.length;
+ continue;
+ }
+
+ index++;
+ }
+
+ return found;
+};
+
+/**
+ * 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. That depends on the
+ * property the value belongs to — see `takesColor` — and on whether the walk has since
+ * descended into a function whose arguments are colours whatever the property is, see
+ * `COLOR_CONTAINERS`. 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.
+ *
+ * The value is taken *raw*, as written in the source, and its noise is blanked here.
+ * Without that this took a declaration value in its doc comment and a noise-free one in
+ * fact: `url(#fff)` is a URL whose fragment is not a colour and `content: "red"` is a
+ * string, yet both were reported. `stylesheetColors` does not pay for this twice — it
+ * reads values that `declarations` has already blanked, so it walks them directly.
+ */
+export const findColors = (value: string, named: boolean): FoundColor[] =>
+ scanColors(stripNoise(value), named);
+
+/**
+ * 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 scanColors(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);