@1agh/maude 0.24.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.
Files changed (84) hide show
  1. package/cli/commands/design.mjs +5 -0
  2. package/cli/lib/design-link.mjs +13 -6
  3. package/cli/lib/gitignore-block.mjs +1 -0
  4. package/cli/lib/gitignore-block.test.mjs +1 -0
  5. package/package.json +8 -8
  6. package/plugins/design/dependencies.json +30 -2
  7. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  8. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  9. package/plugins/design/dev-server/api.ts +15 -1
  10. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  12. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  13. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  14. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  15. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  16. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  17. package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
  18. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  19. package/plugins/design/dev-server/bin/draw-build.sh +48 -0
  20. package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
  21. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  22. package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
  23. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  24. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  25. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  26. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  27. package/plugins/design/dev-server/canvas-lib.tsx +110 -0
  28. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  29. package/plugins/design/dev-server/client/app.jsx +1084 -417
  30. package/plugins/design/dev-server/config.schema.json +10 -0
  31. package/plugins/design/dev-server/context.ts +9 -0
  32. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  33. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  34. package/plugins/design/dev-server/draw/brush.ts +639 -0
  35. package/plugins/design/dev-server/draw/composition.ts +229 -0
  36. package/plugins/design/dev-server/draw/geometry.ts +578 -0
  37. package/plugins/design/dev-server/draw/index.ts +28 -0
  38. package/plugins/design/dev-server/draw/layout.ts +260 -0
  39. package/plugins/design/dev-server/draw/optimize.ts +65 -0
  40. package/plugins/design/dev-server/draw/palette.ts +417 -0
  41. package/plugins/design/dev-server/draw/primitives.ts +643 -0
  42. package/plugins/design/dev-server/draw/serialize.ts +458 -0
  43. package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
  44. package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
  45. package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
  46. package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
  47. package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
  48. package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
  49. package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
  50. package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
  51. package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
  52. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  53. package/plugins/design/dev-server/exporters/html.ts +4 -1
  54. package/plugins/design/dev-server/exporters/index.ts +4 -1
  55. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  56. package/plugins/design/dev-server/exporters/png.ts +21 -2
  57. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  58. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  59. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  60. package/plugins/design/dev-server/http.ts +40 -9
  61. package/plugins/design/dev-server/input-router.tsx +9 -2
  62. package/plugins/design/dev-server/sync/index.ts +73 -17
  63. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  64. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  65. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  66. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  67. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  68. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  69. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  70. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  71. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  72. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  73. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  74. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  75. package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
  76. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  77. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  78. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  79. package/plugins/design/dev-server/tsconfig.json +8 -1
  80. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  81. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  82. package/plugins/design/templates/_shell.html +36 -9
  83. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
  84. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * @file draw/palette.ts — Phase 25 geometry-engine color layer
3
+ * @scope plugins/design/dev-server/draw/palette.ts
4
+ * @purpose Color correctness the draw-agent verifies from SOURCE, never from
5
+ * the vision model (VLMs misread color confidently):
6
+ * • WCAG 2.1 relative-luminance + contrast ratio (4.5 / 3 / 7);
7
+ * • OKLCH → sRGB conversion (Björn Ottosson constants) so ramps
8
+ * are generated in a perceptually uniform space;
9
+ * • evenly-spaced lightness ramps (rubric check 16);
10
+ * • 60-30-10 area-distribution check (accent ≤ ~15%).
11
+ * Pure + deterministic; React-free (DDR-067).
12
+ */
13
+
14
+ export const CURRENT_COLOR = 'currentColor';
15
+
16
+ export interface Rgb {
17
+ /** 0–255 */
18
+ r: number;
19
+ g: number;
20
+ b: number;
21
+ }
22
+
23
+ export interface Oklch {
24
+ /** Lightness 0–1. */
25
+ l: number;
26
+ /** Chroma (≈0–0.4). */
27
+ c: number;
28
+ /** Hue in degrees. */
29
+ h: number;
30
+ }
31
+
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ // Parsing
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Parse a hex (`#rgb`, `#rrggbb`, `#rrggbbaa`) or `rgb()/rgba()` string to
38
+ * 0–255 channels. Throws on anything else (use {@link oklchToRgb} for OKLCH).
39
+ */
40
+ export function parseColor(input: string): Rgb {
41
+ const s = input.trim();
42
+ if (s.startsWith('#')) {
43
+ let hex = s.slice(1);
44
+ if (hex.length === 3 || hex.length === 4) {
45
+ hex = hex
46
+ .split('')
47
+ .map((ch) => ch + ch)
48
+ .join('');
49
+ }
50
+ if (hex.length !== 6 && hex.length !== 8) throw new Error(`parseColor: bad hex "${input}"`);
51
+ const r = Number.parseInt(hex.slice(0, 2), 16);
52
+ const g = Number.parseInt(hex.slice(2, 4), 16);
53
+ const b = Number.parseInt(hex.slice(4, 6), 16);
54
+ if ([r, g, b].some((n) => Number.isNaN(n))) throw new Error(`parseColor: bad hex "${input}"`);
55
+ return { r, g, b };
56
+ }
57
+ const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(s);
58
+ if (m) return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
59
+ throw new Error(`parseColor: unsupported color "${input}" (hex or rgb() only)`);
60
+ }
61
+
62
+ /** Parse an `oklch(L C H)` string (L as `%` or 0–1) into an {@link Oklch}. */
63
+ export function parseOklch(input: string): Oklch {
64
+ const m = /^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/i.exec(input.trim());
65
+ if (!m) throw new Error(`parseOklch: bad oklch "${input}"`);
66
+ const lraw = m[1];
67
+ const l = lraw.endsWith('%') ? Number(lraw.slice(0, -1)) / 100 : Number(lraw);
68
+ return { l, c: Number(m[2]), h: Number(m[3]) };
69
+ }
70
+
71
+ export function toHex({ r, g, b }: Rgb): string {
72
+ const h = (n: number) =>
73
+ Math.max(0, Math.min(255, Math.round(n)))
74
+ .toString(16)
75
+ .padStart(2, '0');
76
+ return `#${h(r)}${h(g)}${h(b)}`;
77
+ }
78
+
79
+ // ─────────────────────────────────────────────────────────────────────────────
80
+ // WCAG contrast
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+
83
+ function srgbToLinear(c8: number): number {
84
+ const c = c8 / 255;
85
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
86
+ }
87
+
88
+ /** WCAG 2.1 relative luminance (0 = black, 1 = white). */
89
+ export function relativeLuminance(color: Rgb | string): number {
90
+ const { r, g, b } = typeof color === 'string' ? parseColor(color) : color;
91
+ return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
92
+ }
93
+
94
+ /** WCAG contrast ratio between two colors (1–21). Order-independent. */
95
+ export function contrastRatio(a: Rgb | string, b: Rgb | string): number {
96
+ const la = relativeLuminance(a);
97
+ const lb = relativeLuminance(b);
98
+ const hi = Math.max(la, lb);
99
+ const lo = Math.min(la, lb);
100
+ return (hi + 0.05) / (lo + 0.05);
101
+ }
102
+
103
+ export type WcagLevel = 'AA' | 'AAA';
104
+
105
+ /**
106
+ * Does `ratio` clear the WCAG bar? `large` = ≥18.66px bold / ≥24px regular
107
+ * (3:1 at AA). `nonText` = UI components / graphical objects (always 3:1).
108
+ */
109
+ export function meetsWcag(
110
+ ratio: number,
111
+ opts: { level?: WcagLevel; large?: boolean; nonText?: boolean } = {}
112
+ ): boolean {
113
+ const { level = 'AA', large = false, nonText = false } = opts;
114
+ if (nonText) return ratio >= 3;
115
+ if (level === 'AAA') return ratio >= (large ? 4.5 : 7);
116
+ return ratio >= (large ? 3 : 4.5);
117
+ }
118
+
119
+ // ─────────────────────────────────────────────────────────────────────────────
120
+ // OKLCH → sRGB (Björn Ottosson)
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+
123
+ function linearToSrgb8(c: number): number {
124
+ const v = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
125
+ return Math.max(0, Math.min(255, Math.round(v * 255)));
126
+ }
127
+
128
+ /** Convert OKLCH (L 0–1, C, H°) to clamped sRGB 0–255. */
129
+ export function oklchToRgb({ l, c, h }: Oklch): Rgb {
130
+ const hr = (h * Math.PI) / 180;
131
+ const a = c * Math.cos(hr);
132
+ const b = c * Math.sin(hr);
133
+
134
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
135
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
136
+ const s_ = l - 0.0894841775 * a - 1.291485548 * b;
137
+
138
+ const lc = l_ * l_ * l_;
139
+ const mc = m_ * m_ * m_;
140
+ const sc = s_ * s_ * s_;
141
+
142
+ const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
143
+ const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
144
+ const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc;
145
+
146
+ return { r: linearToSrgb8(rLin), g: linearToSrgb8(gLin), b: linearToSrgb8(bLin) };
147
+ }
148
+
149
+ export function oklchToHex(o: Oklch): string {
150
+ return toHex(oklchToRgb(o));
151
+ }
152
+
153
+ // ─────────────────────────────────────────────────────────────────────────────
154
+ // Ramp generation
155
+ // ─────────────────────────────────────────────────────────────────────────────
156
+
157
+ export interface RampOpts {
158
+ hue: number;
159
+ chroma: number;
160
+ /** Number of tiers (default 5). */
161
+ count?: number;
162
+ /** Lightest / darkest tier lightness (default 0.95 / 0.2). */
163
+ lMax?: number;
164
+ lMin?: number;
165
+ }
166
+
167
+ /**
168
+ * An OKLCH tonal ramp with evenly spaced lightness — perceptually uniform by
169
+ * construction (rubric check 16). Each tier holds chroma + hue constant; only
170
+ * lightness steps, lightest first. Returns OKLCH tiers (convert with
171
+ * {@link oklchToHex} as needed).
172
+ */
173
+ export function oklchRamp(opts: RampOpts): Oklch[] {
174
+ const { hue, chroma, count = 5, lMax = 0.95, lMin = 0.2 } = opts;
175
+ if (count < 1) return [];
176
+ if (count === 1) return [{ l: (lMax + lMin) / 2, c: chroma, h: hue }];
177
+ const step = (lMax - lMin) / (count - 1);
178
+ const out: Oklch[] = [];
179
+ for (let i = 0; i < count; i++) {
180
+ out.push({ l: lMax - step * i, c: chroma, h: hue });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * Are the lightness values of a ramp evenly spaced (within `tol`)? The
187
+ * machine-checkable form of "perceptually even ramp". Needs ≥3 tiers.
188
+ */
189
+ export function isPerceptuallyEven(ramp: Oklch[], tol = 0.01): boolean {
190
+ if (ramp.length < 3) return true;
191
+ const deltas: number[] = [];
192
+ for (let i = 1; i < ramp.length; i++) deltas.push(ramp[i].l - ramp[i - 1].l);
193
+ const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
194
+ return deltas.every((d) => Math.abs(d - mean) <= tol);
195
+ }
196
+
197
+ // ─────────────────────────────────────────────────────────────────────────────
198
+ // 60-30-10 distribution
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+
201
+ export interface AreaShare {
202
+ role: string;
203
+ area: number;
204
+ }
205
+
206
+ export interface DistributionResult {
207
+ byRole: Record<string, number>;
208
+ accentRatio: number;
209
+ dominantRole: string | null;
210
+ /** True when an accent role exists and stays ≤ 15% of total area. */
211
+ ok: boolean;
212
+ }
213
+
214
+ /**
215
+ * Check a composition's color area distribution against the 60-30-10 guideline.
216
+ * Roles whose name contains `accent` are summed as the accent budget; the check
217
+ * passes when that budget is ≤ 15% of the total painted area (rubric check 14).
218
+ */
219
+ export function colorDistribution(shares: AreaShare[]): DistributionResult {
220
+ const total = shares.reduce((a, s) => a + Math.max(0, s.area), 0);
221
+ const byRole: Record<string, number> = {};
222
+ let accent = 0;
223
+ let dominantRole: string | null = null;
224
+ let dominantArea = -1;
225
+ for (const s of shares) {
226
+ const ratio = total > 0 ? s.area / total : 0;
227
+ byRole[s.role] = (byRole[s.role] ?? 0) + ratio;
228
+ if (/accent/i.test(s.role)) accent += ratio;
229
+ if (s.area > dominantArea) {
230
+ dominantArea = s.area;
231
+ dominantRole = s.role;
232
+ }
233
+ }
234
+ return { byRole, accentRatio: accent, dominantRole, ok: accent > 0 && accent <= 0.15 };
235
+ }
236
+
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ // Color harmony — Cohen-Or et al. eight hue-wheel templates (SIGGRAPH 2006)
239
+ // ─────────────────────────────────────────────────────────────────────────────
240
+
241
+ /** One wedge of a harmonic template: a sector centered at `center`° (relative to
242
+ * the template's rotation) spanning `arc`° of the hue wheel. */
243
+ export interface HarmonyWedge {
244
+ center: number;
245
+ arc: number;
246
+ }
247
+
248
+ /**
249
+ * The eight Cohen-Or harmonic templates (`i V L I T Y X` + achromatic `N`),
250
+ * expressed as hue-wheel wedges relative to a rotation. These encode the classic
251
+ * schemes: `i` = monochromatic, `I` = complementary, `V`/`Y` = analogous /
252
+ * split, `X` = double-complementary, `T` = a 180° span. Used BOTH to generate a
253
+ * harmonious palette (snap hues into the wedges) AND to score harmony (distance
254
+ * of a palette's hues to the nearest template).
255
+ */
256
+ export const HARMONY_TEMPLATES: Record<string, HarmonyWedge[]> = {
257
+ i: [{ center: 0, arc: 18 }],
258
+ V: [{ center: 0, arc: 93.6 }],
259
+ L: [
260
+ { center: 0, arc: 18 },
261
+ { center: 90, arc: 79.2 },
262
+ ],
263
+ I: [
264
+ { center: 0, arc: 18 },
265
+ { center: 180, arc: 18 },
266
+ ],
267
+ T: [{ center: 0, arc: 180 }],
268
+ Y: [
269
+ { center: 0, arc: 93.6 },
270
+ { center: 180, arc: 18 },
271
+ ],
272
+ X: [
273
+ { center: 0, arc: 93.6 },
274
+ { center: 180, arc: 93.6 },
275
+ ],
276
+ };
277
+
278
+ /** Smallest absolute angular difference between two hues (degrees, 0–180). */
279
+ export function hueDelta(a: number, b: number): number {
280
+ const d = Math.abs(((a - b) % 360) + 360) % 360;
281
+ return d > 180 ? 360 - d : d;
282
+ }
283
+
284
+ /** Angular distance from hue `h` to the nearest edge of a wedge (0 if inside). */
285
+ function distToWedge(h: number, w: HarmonyWedge, rotation: number): number {
286
+ const d = hueDelta(h, w.center + rotation);
287
+ return Math.max(0, d - w.arc / 2);
288
+ }
289
+
290
+ function minDistToTemplate(h: number, tmpl: HarmonyWedge[], rotation: number): number {
291
+ let best = Number.POSITIVE_INFINITY;
292
+ for (const w of tmpl) best = Math.min(best, distToWedge(h, w, rotation));
293
+ return best;
294
+ }
295
+
296
+ /**
297
+ * Saturation-weighted distance of a hue set to a harmonic template at a given
298
+ * rotation — the Cohen-Or harmony metric. 0 = every hue inside a wedge
299
+ * (harmonious); larger = more disharmony. `sats` (0–1, default 1) weight vivid
300
+ * colors more (a gray hue barely matters).
301
+ */
302
+ export function harmonyDistance(
303
+ hues: number[],
304
+ template: keyof typeof HARMONY_TEMPLATES,
305
+ rotation: number,
306
+ sats?: number[]
307
+ ): number {
308
+ const tmpl = HARMONY_TEMPLATES[template];
309
+ let sum = 0;
310
+ for (let i = 0; i < hues.length; i++) {
311
+ const s = sats?.[i] ?? 1;
312
+ sum += s * minDistToTemplate(hues[i], tmpl, rotation);
313
+ }
314
+ return sum;
315
+ }
316
+
317
+ export interface HarmonyFit {
318
+ template: keyof typeof HARMONY_TEMPLATES;
319
+ rotation: number;
320
+ distance: number;
321
+ }
322
+
323
+ /**
324
+ * Find the best-fitting harmonic template + rotation for a hue set (brute search
325
+ * over templates × rotations at 2° steps). The returned `distance` is the
326
+ * discriminating critic metric: ≈0 = harmonious, large = "fighting colors".
327
+ */
328
+ export function bestHarmony(hues: number[], sats?: number[]): HarmonyFit {
329
+ let best: HarmonyFit = { template: 'i', rotation: 0, distance: Number.POSITIVE_INFINITY };
330
+ for (const template of Object.keys(HARMONY_TEMPLATES) as (keyof typeof HARMONY_TEMPLATES)[]) {
331
+ for (let rotation = 0; rotation < 360; rotation += 2) {
332
+ const distance = harmonyDistance(hues, template, rotation, sats);
333
+ if (distance < best.distance) best = { template, rotation, distance };
334
+ }
335
+ }
336
+ return best;
337
+ }
338
+
339
+ /**
340
+ * Snap each hue into the nearest wedge of a template (rotated) — generation-side
341
+ * harmonization. Hues already inside a wedge are unchanged; outliers clamp to the
342
+ * nearest wedge edge. Returns the harmonized hues (use these to build the palette).
343
+ */
344
+ export function harmonize(
345
+ hues: number[],
346
+ template: keyof typeof HARMONY_TEMPLATES,
347
+ rotation = 0
348
+ ): number[] {
349
+ const tmpl = HARMONY_TEMPLATES[template];
350
+ return hues.map((h) => {
351
+ let best = h;
352
+ let bestD = Number.POSITIVE_INFINITY;
353
+ for (const w of tmpl) {
354
+ const lo = w.center + rotation - w.arc / 2;
355
+ const hi = w.center + rotation + w.arc / 2;
356
+ const inside = distToWedge(h, w, rotation) === 0;
357
+ if (inside) return ((h % 360) + 360) % 360;
358
+ // distance to each edge; clamp to the closer one
359
+ const dLo = hueDelta(h, lo);
360
+ const dHi = hueDelta(h, hi);
361
+ const edge = dLo < dHi ? lo : hi;
362
+ const d = Math.min(dLo, dHi);
363
+ if (d < bestD) {
364
+ bestD = d;
365
+ best = edge;
366
+ }
367
+ }
368
+ return ((best % 360) + 360) % 360;
369
+ });
370
+ }
371
+
372
+ // ─────────────────────────────────────────────────────────────────────────────
373
+ // Value / contrast — value does the work, color gets the credit
374
+ // ─────────────────────────────────────────────────────────────────────────────
375
+
376
+ /**
377
+ * The WCAG-luminance value range (max − min) across a color set, 0–1. The
378
+ * "muddy / washed-out" detector: a composition whose colors differ in hue but
379
+ * cluster in a narrow value band reads as flat soup (exactly the failure that
380
+ * sank the pastel + the neon-overlap backgrounds). A premium composition needs a
381
+ * value range that actually spans (rule of thumb: ≥ ~0.35 for a scene with depth).
382
+ */
383
+ export function valueRange(colors: Array<Rgb | string>): number {
384
+ if (colors.length === 0) return 0;
385
+ const ls = colors.map((c) => relativeLuminance(c));
386
+ return Math.max(...ls) - Math.min(...ls);
387
+ }
388
+
389
+ function apcaLum(color: Rgb | string): number {
390
+ const { r, g, b } = typeof color === 'string' ? parseColor(color) : color;
391
+ // APCA uses a straight 2.4 power (not the WCAG piecewise linearization).
392
+ const y = 0.2126 * (r / 255) ** 2.4 + 0.7152 * (g / 255) ** 2.4 + 0.0722 * (b / 255) ** 2.4;
393
+ // biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA-0.98G blkClmp exponent (1.414) for the 0.022 black-soft-clamp — a defined spec constant, NOT √2.
394
+ return y < 0.022 ? y + (0.022 - y) ** 1.414 : y;
395
+ }
396
+
397
+ /**
398
+ * APCA lightness contrast (Lc) between text and background — the perceptual
399
+ * contrast metric WCAG 3 is built on (value does the perceptual work). Returns
400
+ * |Lc| 0–~108. Thresholds (text): Lc 90 body-preferred, 75 body-min, 60 content,
401
+ * 45 large/heavy; Lc 15 is the floor for ANY visible element. Implements the
402
+ * APCA-0.98G constants; pin the version before treating a number as standard.
403
+ */
404
+ export function apcaLc(text: Rgb | string, bg: Rgb | string): number {
405
+ const ytxt = apcaLum(text);
406
+ const ybg = apcaLum(bg);
407
+ let sapc: number;
408
+ let out: number;
409
+ if (ybg > ytxt) {
410
+ sapc = (ybg ** 0.56 - ytxt ** 0.57) * 1.14;
411
+ out = sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
412
+ } else {
413
+ sapc = (ybg ** 0.65 - ytxt ** 0.62) * 1.14;
414
+ out = sapc > -0.1 ? 0 : (sapc + 0.027) * 100;
415
+ }
416
+ return Math.abs(out);
417
+ }