dembrandt 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -249,7 +249,7 @@ dembrandt dembrandt.com --brand-guide
249
249
  The official action wraps extract → compare → gate into one step: it installs a matching Chromium, runs a pinned CLI version, fails the job on drift, and renders the drifted tokens as inline annotations on the PR.
250
250
 
251
251
  ```yaml
252
- - uses: dembrandt/dembrandt@v0.25.0
252
+ - uses: dembrandt/dembrandt@v0.26.0
253
253
  with:
254
254
  url: https://preview.example.com
255
255
  baseline: .dembrandt/baseline.json
@@ -267,7 +267,7 @@ jobs:
267
267
  runs-on: ubuntu-latest
268
268
  steps:
269
269
  - uses: actions/checkout@v4
270
- - uses: dembrandt/dembrandt@v0.25.0
270
+ - uses: dembrandt/dembrandt@v0.26.0
271
271
  with:
272
272
  url: ${{ github.event.deployment_status.environment_url }}
273
273
  baseline: .dembrandt/baseline.json
@@ -0,0 +1,36 @@
1
+ import type { DriftReport, DriftCategory } from './drift.js';
2
+ /**
3
+ * Declarative drift assertions (DEM-81), lighthouserc model: .dembrandtrc
4
+ * declares per-metric limits with error/warn severity, and the gate fails only
5
+ * on error-level breaches. Evaluation is pure — the report in, verdicts out —
6
+ * so the whole layer is unit-testable without a browser or a baseline file.
7
+ *
8
+ * "assertions": {
9
+ * "score": ["error", { "max": 10 }],
10
+ * "color": ["error", { "max": 0.2 }],
11
+ * "typography": ["warn", { "max": 0.3 }],
12
+ * "added": ["warn", { "max": 5 }],
13
+ * "removed": ["error", { "max": 0 }]
14
+ * }
15
+ */
16
+ export type AssertionSeverity = 'error' | 'warn' | 'off';
17
+ /** score = overall 0..100; categories = per-category 0..1; counts = summary. */
18
+ export type AssertionMetric = 'score' | 'added' | 'changed' | 'removed' | DriftCategory;
19
+ export type AssertionSpec = [AssertionSeverity, {
20
+ max: number;
21
+ }];
22
+ export type Assertions = Partial<Record<AssertionMetric, AssertionSpec>>;
23
+ export interface AssertionResult {
24
+ metric: AssertionMetric;
25
+ severity: 'error' | 'warn';
26
+ actual: number;
27
+ max: number;
28
+ }
29
+ /** Malformed specs are collected, not thrown: a typo in .dembrandtrc must
30
+ * surface as a config warning, never crash a CI run mid-gate. */
31
+ export interface AssertionEvaluation {
32
+ failures: AssertionResult[];
33
+ warnings: AssertionResult[];
34
+ invalid: string[];
35
+ }
36
+ export declare function evaluateAssertions(report: DriftReport, assertions: Assertions): AssertionEvaluation;
@@ -0,0 +1,46 @@
1
+ const CATEGORIES = new Set(['color', 'typography', 'spacing', 'radius', 'shadow']);
2
+ function metricValue(report, metric) {
3
+ if (metric === 'score')
4
+ return report.score;
5
+ if (metric === 'added' || metric === 'changed' || metric === 'removed')
6
+ return report.summary[metric];
7
+ if (CATEGORIES.has(metric)) {
8
+ const cat = report.categories.find((c) => c.category === metric);
9
+ return cat ? cat.score : null; // category degraded out of the report — nothing to assert
10
+ }
11
+ return null;
12
+ }
13
+ export function evaluateAssertions(report, assertions) {
14
+ const failures = [];
15
+ const warnings = [];
16
+ const invalid = [];
17
+ for (const [metric, spec] of Object.entries(assertions)) {
18
+ if (!Array.isArray(spec) || spec.length !== 2) {
19
+ invalid.push(String(metric));
20
+ continue;
21
+ }
22
+ const [severity, limits] = spec;
23
+ if (severity !== 'error' && severity !== 'warn' && severity !== 'off') {
24
+ invalid.push(String(metric));
25
+ continue;
26
+ }
27
+ if (severity === 'off')
28
+ continue;
29
+ const max = limits?.max;
30
+ if (typeof max !== 'number' || Number.isNaN(max)) {
31
+ invalid.push(String(metric));
32
+ continue;
33
+ }
34
+ const actual = metricValue(report, metric);
35
+ if (actual === null) {
36
+ if (!CATEGORIES.has(metric))
37
+ invalid.push(String(metric));
38
+ continue;
39
+ }
40
+ if (actual > max) {
41
+ (severity === 'error' ? failures : warnings).push({ metric, severity, actual, max });
42
+ }
43
+ }
44
+ return { failures, warnings, invalid };
45
+ }
46
+ //# sourceMappingURL=assertions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assertions.js","sourceRoot":"","sources":["../../lib/assertions.ts"],"names":[],"mappings":"AAgCA,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC;AAExG,SAAS,WAAW,CAAC,MAAmB,EAAE,MAAuB;IAC/D,IAAI,MAAM,KAAK,OAAO;QAAE,OAAO,MAAM,CAAC,KAAK,CAAC;IAC5C,IAAI,MAAM,KAAK,OAAO,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACtG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QAC3B,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC,CAAC;QACjE,OAAO,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,0DAA0D;IAC3F,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAUD,MAAM,UAAU,kBAAkB,CAAC,MAAmB,EAAE,UAAsB;IAC5E,MAAM,QAAQ,GAAsB,EAAE,CAAC;IACvC,MAAM,QAAQ,GAAsB,EAAE,CAAC;IACvC,MAAM,OAAO,GAAa,EAAE,CAAC;IAE7B,KAAK,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,UAAU,CAAiC,EAAE,CAAC;QACxF,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;YAAC,SAAS;QAAC,CAAC;QAC1F,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,IAA0B,CAAC;QACtD,IAAI,QAAQ,KAAK,OAAO,IAAI,QAAQ,KAAK,MAAM,IAAI,QAAQ,KAAK,KAAK,EAAE,CAAC;YAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;YAAC,SAAS;QAAC,CAAC;QAClH,IAAI,QAAQ,KAAK,KAAK;YAAE,SAAS;QACjC,MAAM,GAAG,GAAI,MAA4B,EAAE,GAAG,CAAC;QAC/C,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;YAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;YAAC,SAAS;QAAC,CAAC;QAE7F,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC3C,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YACpB,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC;gBAAE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;YAC1D,SAAS;QACX,CAAC;QACD,IAAI,MAAM,GAAG,GAAG,EAAE,CAAC;YACjB,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QACvF,CAAC;IACH,CAAC;IACD,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;AACzC,CAAC"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Spec-complete CSS color parser (CSS Color Level 4).
3
+ *
4
+ * One entry point, parseCssColor(), accepts every color notation a stylesheet
5
+ * or computed style can contain — named colors, hex (3/4/6/8), legacy
6
+ * comma-separated rgb()/rgba()/hsl()/hsla(), modern space-separated syntax
7
+ * with slash alpha, hwb(), lab()/lch() (D50), oklab()/oklch(), and color()
8
+ * with the common predefined spaces — and resolves it to sRGB 8-bit channels
9
+ * plus float alpha. Out-of-gamut values are mapped by the CSS Color 4
10
+ * chroma-reduction algorithm, not channel clipping.
11
+ *
12
+ * Kept dependency-free and strict-clean; the browser-context mirror in
13
+ * lib/extractors/colors.ts must stay in sync with this module.
14
+ */
15
+ export interface RgbaColor {
16
+ r: number;
17
+ g: number;
18
+ b: number;
19
+ a: number;
20
+ }
21
+ /**
22
+ * Parse any CSS color string to sRGB 8-bit channels + float alpha.
23
+ * Returns null for anything that is not an absolute color (currentcolor,
24
+ * inherit, var() references, malformed input).
25
+ */
26
+ export declare function parseCssColor(input: string): RgbaColor | null;
27
+ /** #rrggbb for opaque colors, #rrggbbaa when alpha < 1. */
28
+ export declare function serializeHex(c: RgbaColor): string;
29
+ /** Legacy rgb()/rgba() serialisation, the canonical interchange form in the extractor. */
30
+ export declare function serializeRgb(c: RgbaColor): string;
31
+ /**
32
+ * Convenience for extractor injection paths: any CSS color string to its
33
+ * opaque 6-hex identity plus a legacy serialisation that preserves alpha.
34
+ */
35
+ export declare function normalizeCssColor(input: string): {
36
+ hex: string;
37
+ legacy: string;
38
+ alpha: number;
39
+ } | null;
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Spec-complete CSS color parser (CSS Color Level 4).
3
+ *
4
+ * One entry point, parseCssColor(), accepts every color notation a stylesheet
5
+ * or computed style can contain — named colors, hex (3/4/6/8), legacy
6
+ * comma-separated rgb()/rgba()/hsl()/hsla(), modern space-separated syntax
7
+ * with slash alpha, hwb(), lab()/lch() (D50), oklab()/oklch(), and color()
8
+ * with the common predefined spaces — and resolves it to sRGB 8-bit channels
9
+ * plus float alpha. Out-of-gamut values are mapped by the CSS Color 4
10
+ * chroma-reduction algorithm, not channel clipping.
11
+ *
12
+ * Kept dependency-free and strict-clean; the browser-context mirror in
13
+ * lib/extractors/colors.ts must stay in sync with this module.
14
+ */
15
+ // ---------------------------------------------------------------------------
16
+ // Matrices and transfer functions
17
+ // ---------------------------------------------------------------------------
18
+ const XYZ_D65_TO_LINEAR_SRGB = [
19
+ [3.2404542, -1.5371385, -0.4985314],
20
+ [-0.9692660, 1.8760108, 0.0415560],
21
+ [0.0556434, -0.2040259, 1.0572252],
22
+ ];
23
+ const LINEAR_P3_TO_XYZ_D65 = [
24
+ [0.4865709486482162, 0.26566769316909306, 0.19821728523436247],
25
+ [0.2289745640697488, 0.6917385218365064, 0.079286914093745],
26
+ [0.0, 0.04511338185890264, 1.043944368900976],
27
+ ];
28
+ // Bradford chromatic adaptation. CSS lab()/lch() are specified at D50 while
29
+ // sRGB lives at D65, so parsing adapts D50 → D65.
30
+ const XYZ_D50_TO_D65 = [
31
+ [0.9554734527042182, -0.023098536874261423, 0.0632593086610217],
32
+ [-0.028369706963208136, 1.0099954580058226, 0.021041398966943008],
33
+ [0.012314001688319899, -0.020507696433477912, 1.3303659366080753],
34
+ ];
35
+ const D50_WHITE = { x: 0.96422, y: 1.0, z: 0.82521 };
36
+ function applyMatrix(m, v) {
37
+ return {
38
+ x: m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z,
39
+ y: m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z,
40
+ z: m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z,
41
+ };
42
+ }
43
+ function srgbToLinear(c) {
44
+ const abs = Math.abs(c);
45
+ const sign = c < 0 ? -1 : 1;
46
+ return abs <= 0.04045 ? c / 12.92 : sign * Math.pow((abs + 0.055) / 1.055, 2.4);
47
+ }
48
+ function linearToSrgb(c) {
49
+ const abs = Math.abs(c);
50
+ const sign = c < 0 ? -1 : 1;
51
+ return abs <= 0.0031308 ? c * 12.92 : sign * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055);
52
+ }
53
+ function linearSrgbToOklab(r, g, b) {
54
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
55
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
56
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
57
+ return {
58
+ l: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
59
+ a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
60
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
61
+ };
62
+ }
63
+ function oklabToLinearSrgb(L, a, b) {
64
+ const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
65
+ const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
66
+ const s = Math.pow(L - 0.0894841775 * a - 1.2914855480 * b, 3);
67
+ return {
68
+ r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
69
+ g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
70
+ b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
71
+ };
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Gamut mapping (CSS Color 4 §13.2: chroma reduction in OKLCH with local
75
+ // clipping when the clipped candidate is within a just-noticeable difference)
76
+ // ---------------------------------------------------------------------------
77
+ const GAMUT_EPS = 0.000075;
78
+ const JND = 0.02;
79
+ function inGamut(rgb) {
80
+ return (rgb.r >= -GAMUT_EPS && rgb.r <= 1 + GAMUT_EPS &&
81
+ rgb.g >= -GAMUT_EPS && rgb.g <= 1 + GAMUT_EPS &&
82
+ rgb.b >= -GAMUT_EPS && rgb.b <= 1 + GAMUT_EPS);
83
+ }
84
+ function clamp01(v) {
85
+ return Math.min(1, Math.max(0, v));
86
+ }
87
+ function deltaEok(c1, c2) {
88
+ return Math.sqrt((c1.l - c2.l) ** 2 + (c1.a - c2.a) ** 2 + (c1.b - c2.b) ** 2);
89
+ }
90
+ /** Map a (possibly out-of-gamut) linear sRGB triple into gamut, return 8-bit channels. */
91
+ function gamutMapLinear(lin) {
92
+ const encode = (rgb) => ({
93
+ r: Math.round(clamp01(linearToSrgb(rgb.r)) * 255),
94
+ g: Math.round(clamp01(linearToSrgb(rgb.g)) * 255),
95
+ b: Math.round(clamp01(linearToSrgb(rgb.b)) * 255),
96
+ });
97
+ if (inGamut(lin))
98
+ return encode(lin);
99
+ const ok = linearSrgbToOklab(lin.r, lin.g, lin.b);
100
+ if (ok.l >= 1)
101
+ return { r: 255, g: 255, b: 255 };
102
+ if (ok.l <= 0)
103
+ return { r: 0, g: 0, b: 0 };
104
+ const chroma = Math.sqrt(ok.a * ok.a + ok.b * ok.b);
105
+ const hueA = chroma === 0 ? 0 : ok.a / chroma;
106
+ const hueB = chroma === 0 ? 0 : ok.b / chroma;
107
+ const EPSILON = 0.0001;
108
+ let min = 0;
109
+ let max = chroma;
110
+ let minInGamut = true;
111
+ let current = lin;
112
+ while (max - min > EPSILON) {
113
+ const mid = (min + max) / 2;
114
+ current = oklabToLinearSrgb(ok.l, hueA * mid, hueB * mid);
115
+ if (minInGamut && inGamut(current)) {
116
+ min = mid;
117
+ }
118
+ else {
119
+ const clipped = { r: clamp01(current.r), g: clamp01(current.g), b: clamp01(current.b) };
120
+ const clippedOk = linearSrgbToOklab(clipped.r, clipped.g, clipped.b);
121
+ const dE = deltaEok(clippedOk, { l: ok.l, a: hueA * mid, b: hueB * mid });
122
+ if (dE < JND) {
123
+ if (JND - dE < EPSILON)
124
+ return encode(clipped);
125
+ minInGamut = false;
126
+ min = mid;
127
+ }
128
+ else {
129
+ max = mid;
130
+ }
131
+ }
132
+ }
133
+ return encode({ r: clamp01(current.r), g: clamp01(current.g), b: clamp01(current.b) });
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // Component tokenization
137
+ // ---------------------------------------------------------------------------
138
+ const NUMBER_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
139
+ /** Parse a number or percentage token; percentRef is what 100% maps to. 'none' is 0. */
140
+ function parseNumeric(token, percentRef) {
141
+ if (token === 'none')
142
+ return 0;
143
+ if (token.endsWith('%')) {
144
+ const v = token.slice(0, -1);
145
+ if (!NUMBER_RE.test(v))
146
+ return null;
147
+ return (parseFloat(v) / 100) * percentRef;
148
+ }
149
+ if (!NUMBER_RE.test(token))
150
+ return null;
151
+ return parseFloat(token);
152
+ }
153
+ /** Parse a hue token with optional angle unit, returned in degrees. 'none' is 0. */
154
+ function parseHue(token) {
155
+ if (token === 'none')
156
+ return 0;
157
+ const m = token.match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(deg|rad|grad|turn)?$/i);
158
+ if (!m)
159
+ return null;
160
+ const v = parseFloat(m[1]);
161
+ switch ((m[2] || 'deg').toLowerCase()) {
162
+ case 'rad': return (v * 180) / Math.PI;
163
+ case 'grad': return v * 0.9;
164
+ case 'turn': return v * 360;
165
+ default: return v;
166
+ }
167
+ }
168
+ function parseAlpha(token) {
169
+ if (token === undefined)
170
+ return 1;
171
+ const v = parseNumeric(token, 1);
172
+ if (v === null)
173
+ return null;
174
+ return clamp01(v);
175
+ }
176
+ /**
177
+ * Split a color function body into channel tokens + optional alpha, accepting
178
+ * both the legacy comma grammar (alpha as 4th comma argument) and the modern
179
+ * space grammar (alpha after a slash).
180
+ */
181
+ function splitArgs(body) {
182
+ const trimmed = body.trim();
183
+ if (trimmed.includes(',')) {
184
+ const parts = trimmed.split(',').map((p) => p.trim());
185
+ if (parts.some((p) => p === ''))
186
+ return null;
187
+ if (parts.length === 4)
188
+ return { channels: parts.slice(0, 3), alpha: parts[3] };
189
+ if (parts.length === 3)
190
+ return { channels: parts, alpha: undefined };
191
+ return null;
192
+ }
193
+ const slash = trimmed.split('/');
194
+ if (slash.length > 2)
195
+ return null;
196
+ const channels = slash[0].trim().split(/\s+/);
197
+ const alpha = slash.length === 2 ? slash[1].trim() : undefined;
198
+ if (alpha === '')
199
+ return null;
200
+ return { channels, alpha };
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // Per-notation parsers
204
+ // ---------------------------------------------------------------------------
205
+ function fromHex(input) {
206
+ const m = input.match(/^#([0-9a-f]{3,8})$/i);
207
+ if (!m)
208
+ return null;
209
+ const h = m[1];
210
+ const dup = (c) => parseInt(c + c, 16);
211
+ if (h.length === 3)
212
+ return { r: dup(h[0]), g: dup(h[1]), b: dup(h[2]), a: 1 };
213
+ if (h.length === 4)
214
+ return { r: dup(h[0]), g: dup(h[1]), b: dup(h[2]), a: dup(h[3]) / 255 };
215
+ const pair = (i) => parseInt(h.slice(i, i + 2), 16);
216
+ if (h.length === 6)
217
+ return { r: pair(0), g: pair(2), b: pair(4), a: 1 };
218
+ if (h.length === 8)
219
+ return { r: pair(0), g: pair(2), b: pair(4), a: pair(6) / 255 };
220
+ return null;
221
+ }
222
+ function fromRgb(args) {
223
+ const channel = (t) => parseNumeric(t, 255);
224
+ const r = channel(args.channels[0]);
225
+ const g = channel(args.channels[1]);
226
+ const b = channel(args.channels[2]);
227
+ const a = parseAlpha(args.alpha);
228
+ if (r === null || g === null || b === null || a === null)
229
+ return null;
230
+ const clamp255 = (v) => Math.min(255, Math.max(0, Math.round(v)));
231
+ return { r: clamp255(r), g: clamp255(g), b: clamp255(b), a };
232
+ }
233
+ function hslChannels(h, s, l) {
234
+ const sat = clamp01(s);
235
+ const light = clamp01(l);
236
+ const hue = ((h % 360) + 360) % 360;
237
+ const c = (1 - Math.abs(2 * light - 1)) * sat;
238
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
239
+ const m = light - c / 2;
240
+ let rgb;
241
+ if (hue < 60)
242
+ rgb = [c, x, 0];
243
+ else if (hue < 120)
244
+ rgb = [x, c, 0];
245
+ else if (hue < 180)
246
+ rgb = [0, c, x];
247
+ else if (hue < 240)
248
+ rgb = [0, x, c];
249
+ else if (hue < 300)
250
+ rgb = [x, 0, c];
251
+ else
252
+ rgb = [c, 0, x];
253
+ return {
254
+ r: Math.round((rgb[0] + m) * 255),
255
+ g: Math.round((rgb[1] + m) * 255),
256
+ b: Math.round((rgb[2] + m) * 255),
257
+ };
258
+ }
259
+ function fromHsl(args) {
260
+ const h = parseHue(args.channels[0]);
261
+ const s = parseNumeric(args.channels[1], 1);
262
+ const l = parseNumeric(args.channels[2], 1);
263
+ const a = parseAlpha(args.alpha);
264
+ if (h === null || s === null || l === null || a === null)
265
+ return null;
266
+ // Bare numbers for s/l are percentages in disguise (hsl(120 100 50) is valid
267
+ // modern syntax): a token without '%' still means 0-100.
268
+ const norm = (raw, v) => (raw.endsWith('%') || raw === 'none' ? v : v / 100);
269
+ return { ...hslChannels(h, norm(args.channels[1], s), norm(args.channels[2], l)), a };
270
+ }
271
+ function fromHwb(args) {
272
+ const h = parseHue(args.channels[0]);
273
+ const wRaw = parseNumeric(args.channels[1], 1);
274
+ const bRaw = parseNumeric(args.channels[2], 1);
275
+ const a = parseAlpha(args.alpha);
276
+ if (h === null || wRaw === null || bRaw === null || a === null)
277
+ return null;
278
+ const norm = (raw, v) => (raw.endsWith('%') || raw === 'none' ? v : v / 100);
279
+ const w = clamp01(norm(args.channels[1], wRaw));
280
+ const blk = clamp01(norm(args.channels[2], bRaw));
281
+ if (w + blk >= 1) {
282
+ const gray = Math.round((w / (w + blk)) * 255);
283
+ return { r: gray, g: gray, b: gray, a };
284
+ }
285
+ const base = hslChannels(h, 1, 0.5);
286
+ const mix = (c) => Math.round(((c / 255) * (1 - w - blk) + w) * 255);
287
+ return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a };
288
+ }
289
+ function fromOklab(args) {
290
+ const L = parseNumeric(args.channels[0], 1);
291
+ const aCh = parseNumeric(args.channels[1], 0.4);
292
+ const bCh = parseNumeric(args.channels[2], 0.4);
293
+ const alpha = parseAlpha(args.alpha);
294
+ if (L === null || aCh === null || bCh === null || alpha === null)
295
+ return null;
296
+ const lin = oklabToLinearSrgb(clamp01(L), aCh, bCh);
297
+ return { ...gamutMapLinear(lin), a: alpha };
298
+ }
299
+ function fromOklch(args) {
300
+ const L = parseNumeric(args.channels[0], 1);
301
+ const C = parseNumeric(args.channels[1], 0.4);
302
+ const H = parseHue(args.channels[2]);
303
+ const alpha = parseAlpha(args.alpha);
304
+ if (L === null || C === null || H === null || alpha === null)
305
+ return null;
306
+ const hRad = (H * Math.PI) / 180;
307
+ const lin = oklabToLinearSrgb(clamp01(L), Math.max(0, C) * Math.cos(hRad), Math.max(0, C) * Math.sin(hRad));
308
+ return { ...gamutMapLinear(lin), a: alpha };
309
+ }
310
+ function labD50ToLinearSrgb(l, a, b) {
311
+ const fy = (l + 16) / 116;
312
+ const fx = fy + a / 500;
313
+ const fz = fy - b / 200;
314
+ const finv = (t) => {
315
+ const t3 = t * t * t;
316
+ return t3 > 0.008856 ? t3 : (116 * t - 16) / 903.3;
317
+ };
318
+ const xyzD50 = {
319
+ x: finv(fx) * D50_WHITE.x,
320
+ y: finv(fy) * D50_WHITE.y,
321
+ z: finv(fz) * D50_WHITE.z,
322
+ };
323
+ const xyzD65 = applyMatrix(XYZ_D50_TO_D65, xyzD50);
324
+ const lin = applyMatrix(XYZ_D65_TO_LINEAR_SRGB, xyzD65);
325
+ return { r: lin.x, g: lin.y, b: lin.z };
326
+ }
327
+ function fromLab(args) {
328
+ const L = parseNumeric(args.channels[0], 100);
329
+ const aCh = parseNumeric(args.channels[1], 125);
330
+ const bCh = parseNumeric(args.channels[2], 125);
331
+ const alpha = parseAlpha(args.alpha);
332
+ if (L === null || aCh === null || bCh === null || alpha === null)
333
+ return null;
334
+ const lin = labD50ToLinearSrgb(Math.min(100, Math.max(0, L)), aCh, bCh);
335
+ return { ...gamutMapLinear(lin), a: alpha };
336
+ }
337
+ function fromLch(args) {
338
+ const L = parseNumeric(args.channels[0], 100);
339
+ const C = parseNumeric(args.channels[1], 150);
340
+ const H = parseHue(args.channels[2]);
341
+ const alpha = parseAlpha(args.alpha);
342
+ if (L === null || C === null || H === null || alpha === null)
343
+ return null;
344
+ const hRad = (H * Math.PI) / 180;
345
+ const lin = labD50ToLinearSrgb(Math.min(100, Math.max(0, L)), Math.max(0, C) * Math.cos(hRad), Math.max(0, C) * Math.sin(hRad));
346
+ return { ...gamutMapLinear(lin), a: alpha };
347
+ }
348
+ function fromColorFunction(args) {
349
+ const [space, ...rest] = args.channels;
350
+ if (!space || rest.length !== 3)
351
+ return null;
352
+ const c1 = parseNumeric(rest[0], 1);
353
+ const c2 = parseNumeric(rest[1], 1);
354
+ const c3 = parseNumeric(rest[2], 1);
355
+ const alpha = parseAlpha(args.alpha);
356
+ if (c1 === null || c2 === null || c3 === null || alpha === null)
357
+ return null;
358
+ let lin;
359
+ switch (space) {
360
+ case 'srgb':
361
+ lin = { r: srgbToLinear(c1), g: srgbToLinear(c2), b: srgbToLinear(c3) };
362
+ break;
363
+ case 'srgb-linear':
364
+ lin = { r: c1, g: c2, b: c3 };
365
+ break;
366
+ case 'display-p3': {
367
+ const p3 = { x: srgbToLinear(c1), y: srgbToLinear(c2), z: srgbToLinear(c3) };
368
+ const xyz = applyMatrix(LINEAR_P3_TO_XYZ_D65, p3);
369
+ const s = applyMatrix(XYZ_D65_TO_LINEAR_SRGB, xyz);
370
+ lin = { r: s.x, g: s.y, b: s.z };
371
+ break;
372
+ }
373
+ case 'xyz':
374
+ case 'xyz-d65': {
375
+ const s = applyMatrix(XYZ_D65_TO_LINEAR_SRGB, { x: c1, y: c2, z: c3 });
376
+ lin = { r: s.x, g: s.y, b: s.z };
377
+ break;
378
+ }
379
+ case 'xyz-d50': {
380
+ const d65 = applyMatrix(XYZ_D50_TO_D65, { x: c1, y: c2, z: c3 });
381
+ const s = applyMatrix(XYZ_D65_TO_LINEAR_SRGB, d65);
382
+ lin = { r: s.x, g: s.y, b: s.z };
383
+ break;
384
+ }
385
+ default:
386
+ return null;
387
+ }
388
+ return { ...gamutMapLinear(lin), a: alpha };
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+ // Named colors (CSS Color 4 §6.1, full table + transparent)
392
+ // ---------------------------------------------------------------------------
393
+ const NAMED_COLORS = {
394
+ aliceblue: 'f0f8ff', antiquewhite: 'faebd7', aqua: '00ffff', aquamarine: '7fffd4',
395
+ azure: 'f0ffff', beige: 'f5f5dc', bisque: 'ffe4c4', black: '000000',
396
+ blanchedalmond: 'ffebcd', blue: '0000ff', blueviolet: '8a2be2', brown: 'a52a2a',
397
+ burlywood: 'deb887', cadetblue: '5f9ea0', chartreuse: '7fff00', chocolate: 'd2691e',
398
+ coral: 'ff7f50', cornflowerblue: '6495ed', cornsilk: 'fff8dc', crimson: 'dc143c',
399
+ cyan: '00ffff', darkblue: '00008b', darkcyan: '008b8b', darkgoldenrod: 'b8860b',
400
+ darkgray: 'a9a9a9', darkgreen: '006400', darkgrey: 'a9a9a9', darkkhaki: 'bdb76b',
401
+ darkmagenta: '8b008b', darkolivegreen: '556b2f', darkorange: 'ff8c00', darkorchid: '9932cc',
402
+ darkred: '8b0000', darksalmon: 'e9967a', darkseagreen: '8fbc8f', darkslateblue: '483d8b',
403
+ darkslategray: '2f4f4f', darkslategrey: '2f4f4f', darkturquoise: '00ced1', darkviolet: '9400d3',
404
+ deeppink: 'ff1493', deepskyblue: '00bfff', dimgray: '696969', dimgrey: '696969',
405
+ dodgerblue: '1e90ff', firebrick: 'b22222', floralwhite: 'fffaf0', forestgreen: '228b22',
406
+ fuchsia: 'ff00ff', gainsboro: 'dcdcdc', ghostwhite: 'f8f8ff', gold: 'ffd700',
407
+ goldenrod: 'daa520', gray: '808080', green: '008000', greenyellow: 'adff2f',
408
+ grey: '808080', honeydew: 'f0fff0', hotpink: 'ff69b4', indianred: 'cd5c5c',
409
+ indigo: '4b0082', ivory: 'fffff0', khaki: 'f0e68c', lavender: 'e6e6fa',
410
+ lavenderblush: 'fff0f5', lawngreen: '7cfc00', lemonchiffon: 'fffacd', lightblue: 'add8e6',
411
+ lightcoral: 'f08080', lightcyan: 'e0ffff', lightgoldenrodyellow: 'fafad2', lightgray: 'd3d3d3',
412
+ lightgreen: '90ee90', lightgrey: 'd3d3d3', lightpink: 'ffb6c1', lightsalmon: 'ffa07a',
413
+ lightseagreen: '20b2aa', lightskyblue: '87cefa', lightslategray: '778899', lightslategrey: '778899',
414
+ lightsteelblue: 'b0c4de', lightyellow: 'ffffe0', lime: '00ff00', limegreen: '32cd32',
415
+ linen: 'faf0e6', magenta: 'ff00ff', maroon: '800000', mediumaquamarine: '66cdaa',
416
+ mediumblue: '0000cd', mediumorchid: 'ba55d3', mediumpurple: '9370db', mediumseagreen: '3cb371',
417
+ mediumslateblue: '7b68ee', mediumspringgreen: '00fa9a', mediumturquoise: '48d1cc', mediumvioletred: 'c71585',
418
+ midnightblue: '191970', mintcream: 'f5fffa', mistyrose: 'ffe4e1', moccasin: 'ffe4b5',
419
+ navajowhite: 'ffdead', navy: '000080', oldlace: 'fdf5e6', olive: '808000',
420
+ olivedrab: '6b8e23', orange: 'ffa500', orangered: 'ff4500', orchid: 'da70d6',
421
+ palegoldenrod: 'eee8aa', palegreen: '98fb98', paleturquoise: 'afeeee', palevioletred: 'db7093',
422
+ papayawhip: 'ffefd5', peachpuff: 'ffdab9', peru: 'cd853f', pink: 'ffc0cb',
423
+ plum: 'dda0dd', powderblue: 'b0e0e6', purple: '800080', rebeccapurple: '663399',
424
+ red: 'ff0000', rosybrown: 'bc8f8f', royalblue: '4169e1', saddlebrown: '8b4513',
425
+ salmon: 'fa8072', sandybrown: 'f4a460', seagreen: '2e8b57', seashell: 'fff5ee',
426
+ sienna: 'a0522d', silver: 'c0c0c0', skyblue: '87ceeb', slateblue: '6a5acd',
427
+ slategray: '708090', slategrey: '708090', snow: 'fffafa', springgreen: '00ff7f',
428
+ steelblue: '4682b4', tan: 'd2b48c', teal: '008080', thistle: 'd8bfd8',
429
+ tomato: 'ff6347', turquoise: '40e0d0', violet: 'ee82ee', wheat: 'f5deb3',
430
+ white: 'ffffff', whitesmoke: 'f5f5f5', yellow: 'ffff00', yellowgreen: '9acd32',
431
+ };
432
+ // ---------------------------------------------------------------------------
433
+ // Public API
434
+ // ---------------------------------------------------------------------------
435
+ /**
436
+ * Parse any CSS color string to sRGB 8-bit channels + float alpha.
437
+ * Returns null for anything that is not an absolute color (currentcolor,
438
+ * inherit, var() references, malformed input).
439
+ */
440
+ export function parseCssColor(input) {
441
+ if (typeof input !== 'string')
442
+ return null;
443
+ const str = input.trim();
444
+ if (str === '')
445
+ return null;
446
+ if (str.startsWith('#'))
447
+ return fromHex(str);
448
+ const lower = str.toLowerCase();
449
+ if (lower === 'transparent')
450
+ return { r: 0, g: 0, b: 0, a: 0 };
451
+ const named = NAMED_COLORS[lower];
452
+ if (named)
453
+ return fromHex(`#${named}`);
454
+ const fn = lower.match(/^([a-z-]+)\(\s*([^)]*)\s*\)$/);
455
+ if (!fn)
456
+ return null;
457
+ const args = splitArgs(fn[2]);
458
+ if (!args || args.channels.length < 3)
459
+ return null;
460
+ switch (fn[1]) {
461
+ case 'rgb':
462
+ case 'rgba':
463
+ return args.channels.length === 3 ? fromRgb(args) : null;
464
+ case 'hsl':
465
+ case 'hsla':
466
+ return args.channels.length === 3 ? fromHsl(args) : null;
467
+ case 'hwb':
468
+ return args.channels.length === 3 ? fromHwb(args) : null;
469
+ case 'oklab':
470
+ return args.channels.length === 3 ? fromOklab(args) : null;
471
+ case 'oklch':
472
+ return args.channels.length === 3 ? fromOklch(args) : null;
473
+ case 'lab':
474
+ return args.channels.length === 3 ? fromLab(args) : null;
475
+ case 'lch':
476
+ return args.channels.length === 3 ? fromLch(args) : null;
477
+ case 'color':
478
+ return fromColorFunction(args);
479
+ default:
480
+ return null;
481
+ }
482
+ }
483
+ /** #rrggbb for opaque colors, #rrggbbaa when alpha < 1. */
484
+ export function serializeHex(c) {
485
+ const pair = (v) => Math.round(v).toString(16).padStart(2, '0');
486
+ const base = `#${pair(c.r)}${pair(c.g)}${pair(c.b)}`;
487
+ if (c.a >= 1)
488
+ return base;
489
+ return `${base}${pair(clamp01(c.a) * 255)}`;
490
+ }
491
+ /** Legacy rgb()/rgba() serialisation, the canonical interchange form in the extractor. */
492
+ export function serializeRgb(c) {
493
+ if (c.a >= 1)
494
+ return `rgb(${c.r}, ${c.g}, ${c.b})`;
495
+ const alpha = Math.round(clamp01(c.a) * 10000) / 10000;
496
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${alpha})`;
497
+ }
498
+ /**
499
+ * Convenience for extractor injection paths: any CSS color string to its
500
+ * opaque 6-hex identity plus a legacy serialisation that preserves alpha.
501
+ */
502
+ export function normalizeCssColor(input) {
503
+ const c = parseCssColor(input);
504
+ if (!c)
505
+ return null;
506
+ return { hex: serializeHex({ ...c, a: 1 }), legacy: serializeRgb(c), alpha: c.a };
507
+ }
508
+ //# sourceMappingURL=color-parse.js.map