ng-hub-ui-utils 22.11.0 → 22.12.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
@@ -591,6 +591,30 @@ These functions back the i18n system and are exported for direct use:
591
591
  - `reflow(element: HTMLElement): DOMRect` - Forces browser reflow
592
592
  - `getActiveElement(root?: Document | ShadowRoot): Element | null` - Gets active element including Shadow DOM
593
593
 
594
+ ### Colour Functions
595
+
596
+ - `parseColor(value): HubRgb | null` - Parses hex (3/4/6/8), `rgb()`, `hsl()`, `oklch()`, `oklab()`, the 148 CSS named colours and `transparent`, in modern and legacy syntax. No DOM, so it runs under SSR. Returns `null` — never throws — for anything it cannot resolve, `var()` and `currentColor` included
597
+ - `toRgb(color): HubRgb | null` - Normalises a string or parsed colour to channels
598
+ - `toHex(color): string | null` - Renders as `#rrggbb`, or `#rrggbbaa` when translucent
599
+ - `isValidColor(value): boolean` - Whether the parser can resolve the string
600
+ - `HUB_NAMED_COLORS: Readonly<Record<string, string>>` - The 148 CSS named colours
601
+
602
+ ### Contrast Functions
603
+
604
+ - `relativeLuminance(color): number | null` - WCAG 2 relative luminance, 0 to 1
605
+ - `contrastRatio(a, b): number | null` - WCAG 2 contrast ratio, 1 to 21
606
+ - `contrastAPCA(text, background): number | null` - APCA lightness contrast, polarity-aware
607
+ - `compositeOver(foreground, background): HubColor` - Blends translucent over opaque
608
+ - `readableOn(background, metric?): string` - Black or white, whichever reads better. Defaults to `'lightness'`, the same decision `--hub-sys-color-*-on` makes in CSS; `'apca'` and `'wcag'` are also available
609
+ - `HUB_INK_LIGHTNESS_THRESHOLD: number` - The OKLCh lightness above which a surface takes dark ink
610
+
611
+ ### OKLCh Functions
612
+
613
+ - `rgbToOklch(color): HubOklch` / `oklchToRgb(color): HubRgb` - Conversions in the space the design system mixes in
614
+ - `maxSrgbChroma(l, h): number` - Highest in-gamut chroma for a hue at a lightness. The sRGB gamut is not a cylinder — at L 0.578 blue reaches 0.232 and amber only 0.119 — so a palette cannot give every hue the same absolute chroma
615
+ - `isInSrgbGamut(color): boolean` - Whether the colour survives the trip to sRGB
616
+ - `clampToSrgbGamut(color): HubOklch` - Reduces chroma until it fits, preserving lightness and hue
617
+
594
618
  ### Focus Functions
595
619
 
596
620
  - `getFocusableBoundaryElements(element: HTMLElement): HTMLElement[]` - Gets first and last focusable elements
@@ -29,6 +29,633 @@ function resolveHubAccent(value) {
29
29
  return /^[a-zA-Z][\w-]*$/.test(color) ? `var(--hub-sys-color-${color}, ${color})` : color;
30
30
  }
31
31
 
32
+ /**
33
+ * Conversions between sRGB and OKLab / OKLCh, using Björn Ottosson's matrices.
34
+ *
35
+ * @see https://bottosson.github.io/posts/oklab/
36
+ */
37
+ /** Expands an sRGB channel (0-255) to its linear-light value (0-1). */
38
+ function toLinear(channel) {
39
+ const v = channel / 255;
40
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
41
+ }
42
+ /** Compresses a linear-light value (0-1) back to an sRGB channel (0-255), unclamped. */
43
+ function fromLinear(value) {
44
+ const v = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
45
+ return v * 255;
46
+ }
47
+ /** Converts an sRGB colour to OKLCh. Alpha is carried through untouched. */
48
+ function rgbToOklch({ r, g, b, a }) {
49
+ const lr = toLinear(r);
50
+ const lg = toLinear(g);
51
+ const lb = toLinear(b);
52
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
53
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
54
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
55
+ const okL = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
56
+ const okA = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
57
+ const okB = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
58
+ const hue = (Math.atan2(okB, okA) * 180) / Math.PI;
59
+ return { l: okL, c: Math.hypot(okA, okB), h: hue < 0 ? hue + 360 : hue, a };
60
+ }
61
+ /**
62
+ * Converts OKLCh to sRGB **without** gamut mapping: channels outside 0-255 are returned
63
+ * as-is so callers can detect the overflow. Use {@link isInSrgbGamut} to test, or
64
+ * {@link clampToSrgbGamut} to reduce chroma until the colour fits.
65
+ */
66
+ function oklchToRgb({ l, c, h, a }) {
67
+ const rad = (h * Math.PI) / 180;
68
+ const okA = c * Math.cos(rad);
69
+ const okB = c * Math.sin(rad);
70
+ const lCube = Math.pow(l + 0.3963377774 * okA + 0.2158037573 * okB, 3);
71
+ const mCube = Math.pow(l - 0.1055613458 * okA - 0.0638541728 * okB, 3);
72
+ const sCube = Math.pow(l - 0.0894841775 * okA - 1.291485548 * okB, 3);
73
+ return {
74
+ r: fromLinear(4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube),
75
+ g: fromLinear(-1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube),
76
+ b: fromLinear(-0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube),
77
+ a
78
+ };
79
+ }
80
+ /** Whether an OKLCh colour survives the trip to sRGB without any channel overflowing. */
81
+ function isInSrgbGamut(color) {
82
+ const { r, g, b } = oklchToRgb(color);
83
+ return [r, g, b].every((channel) => channel >= -0.02 && channel <= 255.02);
84
+ }
85
+ /**
86
+ * The highest chroma the given hue can reach at the given lightness inside sRGB.
87
+ *
88
+ * The gamut is not a cylinder — at L 0.578 blue reaches ~0.23 while amber stops at ~0.12 —
89
+ * so a palette that assigns every role the same absolute chroma cannot be built. Callers
90
+ * scale against this ceiling instead.
91
+ *
92
+ * @param l Perceptual lightness, 0-1.
93
+ * @param h Hue angle in degrees.
94
+ * @returns The maximum in-gamut chroma, found by bisection.
95
+ */
96
+ function maxSrgbChroma(l, h) {
97
+ let low = 0;
98
+ let high = 0.4;
99
+ for (let i = 0; i < 32; i++) {
100
+ const mid = (low + high) / 2;
101
+ if (isInSrgbGamut({ l, c: mid, h, a: 1 })) {
102
+ low = mid;
103
+ }
104
+ else {
105
+ high = mid;
106
+ }
107
+ }
108
+ return low;
109
+ }
110
+ /**
111
+ * Reduces chroma until the colour fits in sRGB, preserving lightness and hue.
112
+ *
113
+ * Preferred over clipping the RGB channels, which shifts both hue and lightness in ways
114
+ * that are visible precisely on the saturated brand colours this is most often used on.
115
+ */
116
+ function clampToSrgbGamut(color) {
117
+ if (isInSrgbGamut(color)) {
118
+ return color;
119
+ }
120
+ return { ...color, c: maxSrgbChroma(color.l, color.h) };
121
+ }
122
+
123
+ /**
124
+ * The 148 CSS named colours, as defined by CSS Color Module Level 4.
125
+ *
126
+ * Kept as a plain hex map rather than parsed at load time: the table is the single
127
+ * reason a bareword such as `rebeccapurple` can be resolved without a DOM, which is what
128
+ * makes colour parsing work under server-side rendering.
129
+ *
130
+ * @see https://www.w3.org/TR/css-color-4/#named-colors
131
+ */
132
+ const HUB_NAMED_COLORS = {
133
+ aliceblue: '#f0f8ff',
134
+ antiquewhite: '#faebd7',
135
+ aqua: '#00ffff',
136
+ aquamarine: '#7fffd4',
137
+ azure: '#f0ffff',
138
+ beige: '#f5f5dc',
139
+ bisque: '#ffe4c4',
140
+ black: '#000000',
141
+ blanchedalmond: '#ffebcd',
142
+ blue: '#0000ff',
143
+ blueviolet: '#8a2be2',
144
+ brown: '#a52a2a',
145
+ burlywood: '#deb887',
146
+ cadetblue: '#5f9ea0',
147
+ chartreuse: '#7fff00',
148
+ chocolate: '#d2691e',
149
+ coral: '#ff7f50',
150
+ cornflowerblue: '#6495ed',
151
+ cornsilk: '#fff8dc',
152
+ crimson: '#dc143c',
153
+ cyan: '#00ffff',
154
+ darkblue: '#00008b',
155
+ darkcyan: '#008b8b',
156
+ darkgoldenrod: '#b8860b',
157
+ darkgray: '#a9a9a9',
158
+ darkgreen: '#006400',
159
+ darkgrey: '#a9a9a9',
160
+ darkkhaki: '#bdb76b',
161
+ darkmagenta: '#8b008b',
162
+ darkolivegreen: '#556b2f',
163
+ darkorange: '#ff8c00',
164
+ darkorchid: '#9932cc',
165
+ darkred: '#8b0000',
166
+ darksalmon: '#e9967a',
167
+ darkseagreen: '#8fbc8f',
168
+ darkslateblue: '#483d8b',
169
+ darkslategray: '#2f4f4f',
170
+ darkslategrey: '#2f4f4f',
171
+ darkturquoise: '#00ced1',
172
+ darkviolet: '#9400d3',
173
+ deeppink: '#ff1493',
174
+ deepskyblue: '#00bfff',
175
+ dimgray: '#696969',
176
+ dimgrey: '#696969',
177
+ dodgerblue: '#1e90ff',
178
+ firebrick: '#b22222',
179
+ floralwhite: '#fffaf0',
180
+ forestgreen: '#228b22',
181
+ fuchsia: '#ff00ff',
182
+ gainsboro: '#dcdcdc',
183
+ ghostwhite: '#f8f8ff',
184
+ gold: '#ffd700',
185
+ goldenrod: '#daa520',
186
+ gray: '#808080',
187
+ green: '#008000',
188
+ greenyellow: '#adff2f',
189
+ grey: '#808080',
190
+ honeydew: '#f0fff0',
191
+ hotpink: '#ff69b4',
192
+ indianred: '#cd5c5c',
193
+ indigo: '#4b0082',
194
+ ivory: '#fffff0',
195
+ khaki: '#f0e68c',
196
+ lavender: '#e6e6fa',
197
+ lavenderblush: '#fff0f5',
198
+ lawngreen: '#7cfc00',
199
+ lemonchiffon: '#fffacd',
200
+ lightblue: '#add8e6',
201
+ lightcoral: '#f08080',
202
+ lightcyan: '#e0ffff',
203
+ lightgoldenrodyellow: '#fafad2',
204
+ lightgray: '#d3d3d3',
205
+ lightgreen: '#90ee90',
206
+ lightgrey: '#d3d3d3',
207
+ lightpink: '#ffb6c1',
208
+ lightsalmon: '#ffa07a',
209
+ lightseagreen: '#20b2aa',
210
+ lightskyblue: '#87cefa',
211
+ lightslategray: '#778899',
212
+ lightslategrey: '#778899',
213
+ lightsteelblue: '#b0c4de',
214
+ lightyellow: '#ffffe0',
215
+ lime: '#00ff00',
216
+ limegreen: '#32cd32',
217
+ linen: '#faf0e6',
218
+ magenta: '#ff00ff',
219
+ maroon: '#800000',
220
+ mediumaquamarine: '#66cdaa',
221
+ mediumblue: '#0000cd',
222
+ mediumorchid: '#ba55d3',
223
+ mediumpurple: '#9370db',
224
+ mediumseagreen: '#3cb371',
225
+ mediumslateblue: '#7b68ee',
226
+ mediumspringgreen: '#00fa9a',
227
+ mediumturquoise: '#48d1cc',
228
+ mediumvioletred: '#c71585',
229
+ midnightblue: '#191970',
230
+ mintcream: '#f5fffa',
231
+ mistyrose: '#ffe4e1',
232
+ moccasin: '#ffe4b5',
233
+ navajowhite: '#ffdead',
234
+ navy: '#000080',
235
+ oldlace: '#fdf5e6',
236
+ olive: '#808000',
237
+ olivedrab: '#6b8e23',
238
+ orange: '#ffa500',
239
+ orangered: '#ff4500',
240
+ orchid: '#da70d6',
241
+ palegoldenrod: '#eee8aa',
242
+ palegreen: '#98fb98',
243
+ paleturquoise: '#afeeee',
244
+ palevioletred: '#db7093',
245
+ papayawhip: '#ffefd5',
246
+ peachpuff: '#ffdab9',
247
+ peru: '#cd853f',
248
+ pink: '#ffc0cb',
249
+ plum: '#dda0dd',
250
+ powderblue: '#b0e0e6',
251
+ purple: '#800080',
252
+ rebeccapurple: '#663399',
253
+ red: '#ff0000',
254
+ rosybrown: '#bc8f8f',
255
+ royalblue: '#4169e1',
256
+ saddlebrown: '#8b4513',
257
+ salmon: '#fa8072',
258
+ sandybrown: '#f4a460',
259
+ seagreen: '#2e8b57',
260
+ seashell: '#fff5ee',
261
+ sienna: '#a0522d',
262
+ silver: '#c0c0c0',
263
+ skyblue: '#87ceeb',
264
+ slateblue: '#6a5acd',
265
+ slategray: '#708090',
266
+ slategrey: '#708090',
267
+ snow: '#fffafa',
268
+ springgreen: '#00ff7f',
269
+ steelblue: '#4682b4',
270
+ tan: '#d2b48c',
271
+ teal: '#008080',
272
+ thistle: '#d8bfd8',
273
+ tomato: '#ff6347',
274
+ turquoise: '#40e0d0',
275
+ violet: '#ee82ee',
276
+ wheat: '#f5deb3',
277
+ white: '#ffffff',
278
+ whitesmoke: '#f5f5f5',
279
+ yellow: '#ffff00',
280
+ yellowgreen: '#9acd32'
281
+ };
282
+
283
+ const HEX_PATTERN = /^#([0-9a-f]{3,8})$/;
284
+ const FUNCTION_PATTERN = /^([a-z]+)\((.*)\)$/;
285
+ /** Constrains a value to a range, used on the way out so callers never see an out-of-range channel. */
286
+ function clamp$1(value, min, max) {
287
+ return Math.min(Math.max(value, min), max);
288
+ }
289
+ /**
290
+ * Reads one numeric component.
291
+ *
292
+ * `none` resolves to 0 per CSS Color 4, and a percentage is scaled by `percentBase` —
293
+ * which differs per channel, so the caller supplies it (255 for rgb, 100 for hsl, 1 for
294
+ * an OKLCh lightness).
295
+ *
296
+ * @returns The parsed number, or `null` when the token is not a number at all.
297
+ */
298
+ function readNumber(token, percentBase) {
299
+ if (token === 'none') {
300
+ return 0;
301
+ }
302
+ const isPercentage = token.endsWith('%');
303
+ const parsed = Number.parseFloat(isPercentage ? token.slice(0, -1) : token);
304
+ if (Number.isNaN(parsed)) {
305
+ return null;
306
+ }
307
+ return isPercentage ? (parsed / 100) * percentBase : parsed;
308
+ }
309
+ /** Reads a hue angle, honouring the four CSS angle units and normalising to 0-360 degrees. */
310
+ function readHue(token) {
311
+ if (token === 'none') {
312
+ return 0;
313
+ }
314
+ const match = /^(-?[\d.]+)(deg|rad|turn|grad)?$/.exec(token);
315
+ if (!match) {
316
+ return null;
317
+ }
318
+ const value = Number.parseFloat(match[1]);
319
+ const degrees = match[2] === 'rad'
320
+ ? (value * 180) / Math.PI
321
+ : match[2] === 'turn'
322
+ ? value * 360
323
+ : match[2] === 'grad'
324
+ ? value * 0.9
325
+ : value;
326
+ return ((degrees % 360) + 360) % 360;
327
+ }
328
+ /** Reads an alpha component, accepting both `0.5` and `50%`. Missing alpha means opaque. */
329
+ function readAlpha(token) {
330
+ if (token === undefined) {
331
+ return 1;
332
+ }
333
+ const parsed = readNumber(token, 1);
334
+ return parsed === null ? null : clamp$1(parsed, 0, 1);
335
+ }
336
+ /**
337
+ * Splits a colour function's argument list into components plus an optional alpha.
338
+ *
339
+ * Handles the modern (`0 0 0 / 50%`) and legacy (`0, 0, 0, 0.5`) syntaxes in one pass, so
340
+ * every colour function below is spared knowing which of the two it was given.
341
+ */
342
+ function splitComponents(body) {
343
+ const [values, slashAlpha] = body.split('/');
344
+ const components = values
345
+ .trim()
346
+ .split(/[\s,]+/)
347
+ .filter(Boolean);
348
+ if (slashAlpha !== undefined) {
349
+ return { components, alpha: slashAlpha.trim() };
350
+ }
351
+ // Legacy `rgba()` / `hsla()` carry alpha as a fourth comma-separated component.
352
+ return components.length === 4
353
+ ? { components: components.slice(0, 3), alpha: components[3] }
354
+ : { components, alpha: undefined };
355
+ }
356
+ /** Converts an HSL triplet (hue in degrees, saturation and lightness 0-1) to sRGB channels. */
357
+ function hslToRgbChannels(h, s, l) {
358
+ const chroma = (1 - Math.abs(2 * l - 1)) * s;
359
+ const sector = h / 60;
360
+ const secondary = chroma * (1 - Math.abs((sector % 2) - 1));
361
+ const offset = l - chroma / 2;
362
+ const rgb = sector < 1
363
+ ? [chroma, secondary, 0]
364
+ : sector < 2
365
+ ? [secondary, chroma, 0]
366
+ : sector < 3
367
+ ? [0, chroma, secondary]
368
+ : sector < 4
369
+ ? [0, secondary, chroma]
370
+ : sector < 5
371
+ ? [secondary, 0, chroma]
372
+ : [chroma, 0, secondary];
373
+ return [(rgb[0] + offset) * 255, (rgb[1] + offset) * 255, (rgb[2] + offset) * 255];
374
+ }
375
+ /** Expands `#rgb`, `#rgba`, `#rrggbb` and `#rrggbbaa` into channels. */
376
+ function parseHex(digits) {
377
+ const expanded = digits.length === 3 || digits.length === 4
378
+ ? digits
379
+ .split('')
380
+ .map((digit) => digit + digit)
381
+ .join('')
382
+ : digits;
383
+ if (expanded.length !== 6 && expanded.length !== 8) {
384
+ return null;
385
+ }
386
+ const channel = (index) => Number.parseInt(expanded.slice(index * 2, index * 2 + 2), 16);
387
+ return {
388
+ r: channel(0),
389
+ g: channel(1),
390
+ b: channel(2),
391
+ a: expanded.length === 8 ? channel(3) / 255 : 1
392
+ };
393
+ }
394
+ /**
395
+ * Parses any CSS colour string into sRGB channels, with no DOM involved.
396
+ *
397
+ * Accepts hex (3/4/6/8 digits), `rgb()`/`rgba()`, `hsl()`/`hsla()`, `oklch()`, `oklab()`,
398
+ * the 148 CSS named colours and `transparent`, in both modern and legacy syntax. Anything
399
+ * else — including `var(...)`, `currentColor` and CIE `lab()`/`lch()` — returns `null`
400
+ * rather than throwing, because every caller here has a sensible fallback and none of them
401
+ * can act on an exception.
402
+ *
403
+ * Colours outside the sRGB gamut (an `oklch()` with excess chroma) come back clamped per
404
+ * channel; use the OKLCh helpers directly when the overflow itself matters.
405
+ *
406
+ * @param value The CSS colour string.
407
+ * @returns The parsed colour, or `null` when the string is not a resolvable colour.
408
+ */
409
+ function parseColor(value) {
410
+ const input = value?.trim().toLowerCase();
411
+ if (!input) {
412
+ return null;
413
+ }
414
+ if (input === 'transparent') {
415
+ return { r: 0, g: 0, b: 0, a: 0 };
416
+ }
417
+ const named = HUB_NAMED_COLORS[input];
418
+ if (named) {
419
+ return parseHex(named.slice(1));
420
+ }
421
+ const hex = HEX_PATTERN.exec(input);
422
+ if (hex) {
423
+ return parseHex(hex[1]);
424
+ }
425
+ const fn = FUNCTION_PATTERN.exec(input);
426
+ if (!fn) {
427
+ return null;
428
+ }
429
+ const [, name, body] = fn;
430
+ const { components, alpha: rawAlpha } = splitComponents(body);
431
+ const alpha = readAlpha(rawAlpha);
432
+ if (alpha === null || components.length !== 3) {
433
+ return null;
434
+ }
435
+ if (name === 'rgb' || name === 'rgba') {
436
+ const channels = components.map((component) => readNumber(component, 255));
437
+ if (channels.some((channel) => channel === null)) {
438
+ return null;
439
+ }
440
+ const [r, g, b] = channels;
441
+ return { r: clamp$1(r, 0, 255), g: clamp$1(g, 0, 255), b: clamp$1(b, 0, 255), a: alpha };
442
+ }
443
+ if (name === 'hsl' || name === 'hsla') {
444
+ const h = readHue(components[0]);
445
+ const s = readNumber(components[1], 1);
446
+ const l = readNumber(components[2], 1);
447
+ if (h === null || s === null || l === null) {
448
+ return null;
449
+ }
450
+ const [r, g, b] = hslToRgbChannels(h, clamp$1(s, 0, 1), clamp$1(l, 0, 1));
451
+ return { r, g, b, a: alpha };
452
+ }
453
+ if (name === 'oklch' || name === 'oklab') {
454
+ // Percentages on the chroma and a/b axes are relative to a 0.4 reference per CSS Color 4.
455
+ const l = readNumber(components[0], 1);
456
+ const second = readNumber(components[1], 0.4);
457
+ const third = name === 'oklch' ? readHue(components[2]) : readNumber(components[2], 0.4);
458
+ if (l === null || second === null || third === null) {
459
+ return null;
460
+ }
461
+ const polar = name === 'oklch'
462
+ ? { c: second, h: third }
463
+ : { c: Math.hypot(second, third), h: ((Math.atan2(third, second) * 180) / Math.PI + 360) % 360 };
464
+ const { r, g, b } = oklchToRgb({ l, c: polar.c, h: polar.h, a: alpha });
465
+ return { r: clamp$1(r, 0, 255), g: clamp$1(g, 0, 255), b: clamp$1(b, 0, 255), a: alpha };
466
+ }
467
+ return null;
468
+ }
469
+ /**
470
+ * Normalises any accepted colour to a {@link HubRgb}, so helpers can take strings or
471
+ * already-parsed colours without each of them repeating the check.
472
+ */
473
+ function toRgb(color) {
474
+ return typeof color === 'string' ? parseColor(color) : color;
475
+ }
476
+ /**
477
+ * Renders a colour as a `#rrggbb` hex string, or `#rrggbbaa` when it is translucent.
478
+ *
479
+ * @param color A CSS colour string or parsed colour.
480
+ * @returns The hex string, or `null` when the input could not be parsed.
481
+ */
482
+ function toHex(color) {
483
+ const rgb = toRgb(color);
484
+ if (!rgb) {
485
+ return null;
486
+ }
487
+ const pair = (channel) => Math.round(clamp$1(channel, 0, 255))
488
+ .toString(16)
489
+ .padStart(2, '0');
490
+ const alpha = rgb.a < 1 ? pair(rgb.a * 255) : '';
491
+ return `#${pair(rgb.r)}${pair(rgb.g)}${pair(rgb.b)}${alpha}`;
492
+ }
493
+ /**
494
+ * Whether a string is a colour this parser can resolve.
495
+ *
496
+ * Note this is narrower than "valid CSS": `var(--x)` and `currentColor` are valid in a
497
+ * stylesheet but have no value outside the cascade, so they are reported as invalid here.
498
+ */
499
+ function isValidColor(value) {
500
+ return parseColor(value) !== null;
501
+ }
502
+
503
+ /**
504
+ * Contrast and readability helpers.
505
+ *
506
+ * The WCAG 2 relative-luminance and contrast-ratio formulas, and the APCA implementation,
507
+ * follow chroma.js (BSD-3-Clause, Copyright (c) 2011-2025 Gregor Aisch). APCA itself is
508
+ * specified by Myndex.
509
+ *
510
+ * @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef
511
+ * @see https://readtech.org/ARC/
512
+ */
513
+ /** Linearises one sRGB channel for the WCAG luminance sum. */
514
+ function linearise(channel) {
515
+ const v = channel / 255;
516
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
517
+ }
518
+ /**
519
+ * WCAG 2 relative luminance, 0 (black) to 1 (white).
520
+ *
521
+ * Alpha is ignored: a translucent colour has no luminance of its own until it is composited,
522
+ * so blend it with {@link compositeOver} first when that matters.
523
+ *
524
+ * @param color A CSS colour string or parsed colour.
525
+ * @returns The relative luminance, or `null` when the colour could not be parsed.
526
+ */
527
+ function relativeLuminance(color) {
528
+ const rgb = toRgb(color);
529
+ if (!rgb) {
530
+ return null;
531
+ }
532
+ return 0.2126 * linearise(rgb.r) + 0.7152 * linearise(rgb.g) + 0.0722 * linearise(rgb.b);
533
+ }
534
+ /**
535
+ * Composites a translucent foreground over an opaque background, in sRGB.
536
+ *
537
+ * Kept simple on purpose: it matches what a browser does for a plain `background-color`
538
+ * stack, which is the only case the contrast helpers need.
539
+ */
540
+ function compositeOver(foreground, background) {
541
+ const fg = toRgb(foreground);
542
+ const bg = toRgb(background);
543
+ if (!fg || !bg) {
544
+ return foreground;
545
+ }
546
+ if (fg.a >= 1) {
547
+ return fg;
548
+ }
549
+ return {
550
+ r: bg.r + (fg.r - bg.r) * fg.a,
551
+ g: bg.g + (fg.g - bg.g) * fg.a,
552
+ b: bg.b + (fg.b - bg.b) * fg.a,
553
+ a: 1
554
+ };
555
+ }
556
+ /**
557
+ * WCAG 2 contrast ratio between two colours, from 1 (identical) to 21 (black on white).
558
+ *
559
+ * The thresholds that matter: 4.5 for body text at AA, 3 for large text and UI components,
560
+ * 7 for AAA.
561
+ *
562
+ * @param a First colour.
563
+ * @param b Second colour.
564
+ * @returns The ratio, or `null` when either colour could not be parsed.
565
+ */
566
+ function contrastRatio(a, b) {
567
+ const first = relativeLuminance(a);
568
+ const second = relativeLuminance(b);
569
+ if (first === null || second === null) {
570
+ return null;
571
+ }
572
+ return first > second ? (first + 0.05) / (second + 0.05) : (second + 0.05) / (first + 0.05);
573
+ }
574
+ const APCA_W_OFFSET = 0.027;
575
+ const APCA_P_IN = 0.0005;
576
+ const APCA_P_OUT = 0.1;
577
+ const APCA_R_SCALE = 1.14;
578
+ const APCA_B_THRESHOLD = 0.022;
579
+ const APCA_B_EXP = 1.414;
580
+ /** APCA's own luminance, which differs from WCAG's in both exponent and channel handling. */
581
+ function apcaLuminance(r, g, b) {
582
+ return 0.2126729 * Math.pow(r / 255, 2.4) + 0.7151522 * Math.pow(g / 255, 2.4) + 0.072175 * Math.pow(b / 255, 2.4);
583
+ }
584
+ /**
585
+ * APCA (Accessible Perceptual Contrast Algorithm) lightness contrast, roughly -108 to 106.
586
+ *
587
+ * Unlike the WCAG ratio it is polarity-aware — dark-on-light and light-on-dark of the same
588
+ * pair score differently — which is why it predicts real readability better, particularly
589
+ * for the mid-lightness accents this design system is full of. The sign carries the
590
+ * polarity; compare `Math.abs()` against a threshold (60 is a common floor for body text).
591
+ *
592
+ * APCA is still a draft, so treat the number as guidance rather than as a conformance claim.
593
+ *
594
+ * @param text The text colour. A translucent value is composited over `background` first.
595
+ * @param background The background colour.
596
+ * @returns The APCA score, or `null` when either colour could not be parsed.
597
+ */
598
+ function contrastAPCA(text, background) {
599
+ const bg = toRgb(background);
600
+ if (!bg) {
601
+ return null;
602
+ }
603
+ const fg = toRgb(compositeOver(text, bg));
604
+ if (!fg) {
605
+ return null;
606
+ }
607
+ const rawText = apcaLuminance(fg.r, fg.g, fg.b);
608
+ const rawBg = apcaLuminance(bg.r, bg.g, bg.b);
609
+ // Soft-clamp near-black levels, where the power curve would otherwise overstate contrast.
610
+ const yText = rawText >= APCA_B_THRESHOLD ? rawText : rawText + Math.pow(APCA_B_THRESHOLD - rawText, APCA_B_EXP);
611
+ const yBg = rawBg >= APCA_B_THRESHOLD ? rawBg : rawBg + Math.pow(APCA_B_THRESHOLD - rawBg, APCA_B_EXP);
612
+ const normalPolarity = Math.pow(yBg, 0.56) - Math.pow(yText, 0.57);
613
+ const reversePolarity = Math.pow(yBg, 0.65) - Math.pow(yText, 0.62);
614
+ const contrast = Math.abs(yBg - yText) < APCA_P_IN ? 0 : yText < yBg ? normalPolarity * APCA_R_SCALE : reversePolarity * APCA_R_SCALE;
615
+ const scaled = Math.abs(contrast) < APCA_P_OUT ? 0 : contrast > 0 ? contrast - APCA_W_OFFSET : contrast + APCA_W_OFFSET;
616
+ return scaled * 100;
617
+ }
618
+ /** The two ends every readable-foreground decision picks between. */
619
+ const INK = '#000000';
620
+ const PAPER = '#ffffff';
621
+ /**
622
+ * The perceptual lightness above which a surface takes dark ink.
623
+ *
624
+ * Must stay in step with the `--hub-sys-color-*-on` token, which computes the same decision
625
+ * in CSS as `oklch(from … clamp(0, (0.62 - l) * 1000, 1) 0 h)`. If the two ever diverge, the
626
+ * same accent gets one ink colour from the stylesheet and another from TypeScript.
627
+ */
628
+ const HUB_INK_LIGHTNESS_THRESHOLD = 0.62;
629
+ /**
630
+ * Picks black or white — whichever reads better on the given background.
631
+ *
632
+ * Defaults to `'lightness'`: a threshold on OKLCh perceptual lightness, which is exactly what
633
+ * the `--hub-sys-color-*-on` token computes in CSS. Choosing it over the WCAG ratio is not a
634
+ * preference but a measured decision — maximising the WCAG 2 ratio puts **black** text on the
635
+ * design system's own blue, green and red accents, because that formula underweights blue at
636
+ * mid lightness. APCA agrees with the lightness rule on all nine semantic roles; WCAG disagrees
637
+ * on three of them.
638
+ *
639
+ * Unparseable input yields black, the safer default on the light surfaces this library assumes.
640
+ *
641
+ * @param background The surface the text will sit on.
642
+ * @param metric `'lightness'` (default) matches the design-system token; `'apca'` maximises the
643
+ * APCA score; `'wcag'` maximises the WCAG 2 ratio — correct for conformance arithmetic, but a
644
+ * poor predictor of what actually reads well on a saturated accent.
645
+ * @returns `'#000000'` or `'#ffffff'`.
646
+ */
647
+ function readableOn(background, metric = 'lightness') {
648
+ const rgb = toRgb(background);
649
+ if (!rgb) {
650
+ return INK;
651
+ }
652
+ if (metric === 'lightness') {
653
+ return rgbToOklch(rgb).l >= HUB_INK_LIGHTNESS_THRESHOLD ? INK : PAPER;
654
+ }
655
+ const score = (foreground) => metric === 'apca' ? Math.abs(contrastAPCA(foreground, rgb) ?? 0) : (contrastRatio(foreground, rgb) ?? 0);
656
+ return score(PAPER) > score(INK) ? PAPER : INK;
657
+ }
658
+
32
659
  /**
33
660
  * Clamps a value into the `[0, max]` range.
34
661
  *
@@ -2518,5 +3145,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
2518
3145
  * Generated bundle index. Do not edit.
2519
3146
  */
2520
3147
 
2521
- export { ContentRef, FOCUSABLE_ELEMENTS_SELECTOR, GetPipe, HUB_DROPDOWN_POSITIONS, HUB_TOOLTIP_ADAPTER, HUB_TRANSLATION_CONFIG, HUB_TRANSLATION_PREFIX, HUB_TRANSLATION_SOURCE, HubDragDropService, HubOverflowTooltipDirective, HubTooltipController, HubTooltipDirective, HubTranslationService, IsObjectPipe, IsObservablePipe, IsStringPipe, OverlayPosition, OverlayRef, OverlayService, PopupService, ScrollBar, TooltipDirective, TranslatePipe, UcfirstPipe, UnwrapAsyncPipe, clamp, closest, computeTargetIndex, containsNode, copyArrayItem, createNativeDragImage, createPointerDragSession, debouncedSignal, equals, generateUniqueId, getActiveElement, getFocusableBoundaryElements, getValue, getValueInRange, hubCompleteTransition, hubFocusTrap, hubRunTransition, hubTooltipAdapter, interpolateString, isDefined, isInteger, isNumber, isObject, isPromise, isString, mergeDeep, moveItemInArray, padNumber, provideHubTooltip, provideHubTranslation, provideHubTranslationAdapter, reflow, regExpEscape, registerOverlayKeydown, removeAccents, resolveDropPosition, resolveHubAccent, runInZone, toAbsoluteIndex, toInteger, toString, transferArrayItem };
3148
+ export { ContentRef, FOCUSABLE_ELEMENTS_SELECTOR, GetPipe, HUB_DROPDOWN_POSITIONS, HUB_INK_LIGHTNESS_THRESHOLD, HUB_NAMED_COLORS, HUB_TOOLTIP_ADAPTER, HUB_TRANSLATION_CONFIG, HUB_TRANSLATION_PREFIX, HUB_TRANSLATION_SOURCE, HubDragDropService, HubOverflowTooltipDirective, HubTooltipController, HubTooltipDirective, HubTranslationService, IsObjectPipe, IsObservablePipe, IsStringPipe, OverlayPosition, OverlayRef, OverlayService, PopupService, ScrollBar, TooltipDirective, TranslatePipe, UcfirstPipe, UnwrapAsyncPipe, clamp, clampToSrgbGamut, closest, compositeOver, computeTargetIndex, containsNode, contrastAPCA, contrastRatio, copyArrayItem, createNativeDragImage, createPointerDragSession, debouncedSignal, equals, generateUniqueId, getActiveElement, getFocusableBoundaryElements, getValue, getValueInRange, hubCompleteTransition, hubFocusTrap, hubRunTransition, hubTooltipAdapter, interpolateString, isDefined, isInSrgbGamut, isInteger, isNumber, isObject, isPromise, isString, isValidColor, maxSrgbChroma, mergeDeep, moveItemInArray, oklchToRgb, padNumber, parseColor, provideHubTooltip, provideHubTranslation, provideHubTranslationAdapter, readableOn, reflow, regExpEscape, registerOverlayKeydown, relativeLuminance, removeAccents, resolveDropPosition, resolveHubAccent, rgbToOklch, runInZone, toAbsoluteIndex, toHex, toInteger, toRgb, toString, transferArrayItem };
2522
3149
  //# sourceMappingURL=ng-hub-ui-utils.mjs.map