@humanforest/tokens 0.2.0 → 0.3.1

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/colourEngine.ts +53 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanforest/tokens",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Forest design tokens — the OKLCH colour engine (src/colourEngine.ts) plus the CSS + JSON it generates via scripts/{scales,type,motion,deck,dataviz,logo}.ts, and the logo SVGs. The generated files are framework-agnostic; do not edit them by hand, run `bun run build`.",
5
5
  "files": [
6
6
  "glyphs",
@@ -1,14 +1,20 @@
1
1
  // colourEngine.ts — framework-free colour math: the canonical Forest colour-generation method.
2
2
  // All functions exported; consumed by the Forest colour documentation page.
3
3
 
4
+ // Colour vectors are fixed-length triples. Typed as a tuple rather than number[] so indexed
5
+ // reads are known-present: number[] makes every component `number | undefined` under
6
+ // noUncheckedIndexedAccess, which is how the apps that extend this layer typecheck it.
7
+ export type Vec3 = [number, number, number];
8
+
4
9
  // ---- OKLCH ↔ sRGB (compact port of scales.ts) ----
5
10
  export const srgbToLin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
6
11
  export const linToSrgb = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
7
- export function hexToLin(hex: string): number[] {
12
+ export function hexToLin(hex: string): Vec3 {
8
13
  const c = hex.replace('#', '');
9
- return [0, 2, 4].map((i) => srgbToLin(parseInt(c.slice(i, i + 2), 16) / 255));
14
+ const ch = (i: number) => srgbToLin(parseInt(c.slice(i, i + 2), 16) / 255);
15
+ return [ch(0), ch(2), ch(4)];
10
16
  }
11
- export function linToOklab([r, g, b]: number[]): number[] {
17
+ export function linToOklab([r, g, b]: Vec3): Vec3 {
12
18
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
13
19
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
14
20
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
@@ -18,7 +24,7 @@ export function linToOklab([r, g, b]: number[]): number[] {
18
24
  0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
19
25
  ];
20
26
  }
21
- export function oklabToLin([L, a, b]: number[]): number[] {
27
+ export function oklabToLin([L, a, b]: Vec3): Vec3 {
22
28
  const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
23
29
  const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
24
30
  const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
@@ -134,12 +140,12 @@ export const hueOf = (hex: string) => hexToOklch(hex).H;
134
140
 
135
141
  // ---- Display-P3 gamut (wider than sRGB; both D65, so a plain linear matrix maps sRGB→P3) ----
136
142
  // Lets ramps reach chroma sRGB can't show, emitted as a color(display-p3 …) primary with a hex fallback.
137
- const sRGBlinToP3lin = ([r, g, b]: number[]): number[] => [
143
+ const sRGBlinToP3lin = ([r, g, b]: Vec3): Vec3 => [
138
144
  0.8224621 * r + 0.177538 * g,
139
145
  0.0331941 * r + 0.9668058 * g,
140
146
  0.0170827 * r + 0.0723974 * g + 0.9105199 * b,
141
147
  ];
142
- export const inGamutP3 = (linsrgb: number[]) => sRGBlinToP3lin(linsrgb).every((v) => v >= -0.001 && v <= 1.001);
148
+ export const inGamutP3 = (linsrgb: Vec3) => sRGBlinToP3lin(linsrgb).every((v) => v >= -0.001 && v <= 1.001);
143
149
  export function maxChromaP3(L: number, H: number) {
144
150
  const rad = (H * Math.PI) / 180, co = Math.cos(rad), si = Math.sin(rad);
145
151
  let lo = 0, hi = 0.4;
@@ -156,13 +162,14 @@ export function oklchToP3(L: number, C: number, H: number): string {
156
162
  c = lo;
157
163
  }
158
164
  const enc = (v: number) => Math.max(0, Math.min(1, linToSrgb(v)));
159
- const [r, g, b] = sRGBlinToP3lin(oklabToLin([L, c * co, c * si])).map(enc);
165
+ const [pr, pg, pb] = sRGBlinToP3lin(oklabToLin([L, c * co, c * si]));
166
+ const r = enc(pr), g = enc(pg), b = enc(pb);
160
167
  return `color(display-p3 ${r.toFixed(4)} ${g.toFixed(4)} ${b.toFixed(4)})`;
161
168
  }
162
169
 
163
170
  // ---- Okhsl chroma model (Ottosson): smooth 3-anchor (C0 / Cmid / Cmax) rational interpolation ----
164
171
  export function okhslCuspPoint(H: number) { let bL = 0.35, bc = 0; for (let L = 0.35; L <= 0.99; L += 0.006) { const c = maxChromaAt(L, H); if (c > bc) { bc = c; bL = L; } } return { L: bL, C: bc }; }
165
- export function getSTmid(a: number, b: number) {
172
+ export function getSTmid(a: number, b: number): [number, number] {
166
173
  const S = 0.11516993 + 1 / (7.4477897 + 4.1590124 * b + a * (-2.19557347 + 1.75198401 * b + a * (-2.13704948 - 10.02301043 * b + a * (-4.24894561 + 5.38770819 * b + 4.69891013 * a))));
167
174
  const T = 0.11239642 + 1 / (1.6132032 - 0.68124379 * b + a * (0.40370612 + 0.90148123 * b + a * (-0.27087943 + 0.6122399 * b + a * (0.00299215 - 0.45399568 * b - 0.14661872 * a))));
168
175
  return [S, T];
@@ -197,7 +204,7 @@ export const dEok = (h1: string, h2: string) => { const A = labOf(h1), B = labOf
197
204
  // STRESS ~47 vs CIEDE2000 ~29 on COMBVD). Author scales with dEok; GATE shipped data-viz palettes
198
205
  // on dE00 / dE00cvd. See COLOUR-DATAVIZ-RESEARCH.md.
199
206
  // CIELAB (D65) from linear-sRGB / hex.
200
- export function linToLab([r, g, b]: number[]): number[] {
207
+ export function linToLab([r, g, b]: Vec3): Vec3 {
201
208
  const X = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
202
209
  const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
203
210
  const Z = 0.0193339 * r + 0.119192 * g + 0.9503041 * b;
@@ -207,7 +214,7 @@ export function linToLab([r, g, b]: number[]): number[] {
207
214
  }
208
215
  export const labCie = (hex: string) => linToLab(hexToLin(hex));
209
216
  // CIEDE2000. Verified against the canonical Sharma et al. reference vectors (incl. the 1.0000 hue-wrap traps).
210
- export function ciede2000([L1, a1, b1]: number[], [L2, a2, b2]: number[]): number {
217
+ export function ciede2000([L1, a1, b1]: Vec3, [L2, a2, b2]: Vec3): number {
211
218
  const rad = Math.PI / 180, deg = 180 / Math.PI;
212
219
  const C1 = Math.hypot(a1, b1), C2 = Math.hypot(a2, b2), Cb = (C1 + C2) / 2;
213
220
  const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)));
@@ -230,12 +237,16 @@ export const dE00 = (h1: string, h2: string) => Math.round(ciede2000(labCie(h1),
230
237
  // CVD simulation — Machado, Oliveira & Fernandes (2009), severity 1.0, applied in linear sRGB.
231
238
  export const CVD_TYPES = ['protan', 'deutan', 'tritan'] as const;
232
239
  export type CvdType = (typeof CVD_TYPES)[number];
233
- const CVD_M: Record<CvdType, number[][]> = {
240
+ const CVD_M: Record<CvdType, [Vec3, Vec3, Vec3]> = {
234
241
  protan: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
235
242
  deutan: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]],
236
243
  tritan: [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.3039]],
237
244
  };
238
- export const simulateCvdLin = (lin: number[], t: CvdType) => CVD_M[t].map((row) => row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2]);
245
+ export const simulateCvdLin = (lin: Vec3, t: CvdType): Vec3 => {
246
+ const [m0, m1, m2] = CVD_M[t];
247
+ const dot = (row: Vec3) => row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2];
248
+ return [dot(m0), dot(m1), dot(m2)];
249
+ };
239
250
  export const simulateCvd = (hex: string, t: CvdType) => { const l = simulateCvdLin(hexToLin(hex), t); const to = (v: number) => Math.round(Math.max(0, Math.min(1, linToSrgb(v))) * 255).toString(16).padStart(2, '0'); return '#' + to(l[0]) + to(l[1]) + to(l[2]); };
240
251
  // Worst-case ΔE00 across normal + all three dichromacies — the number to gate categorical series on.
241
252
  export const dE00cvd = (h1: string, h2: string) => {
@@ -248,7 +259,7 @@ export const dE00cvd = (h1: string, h2: string) => {
248
259
  export interface DataVizGate { minSep: number; minBrand: number; minContrast: number; bgs: string[]; brand: string[]; }
249
260
  export function validateDataViz(hexes: string[], g: DataVizGate) {
250
261
  const pairs: { a: string; b: string; dE: number }[] = [];
251
- for (let i = 0; i < hexes.length; i++) for (let j = i + 1; j < hexes.length; j++) pairs.push({ a: hexes[i], b: hexes[j], dE: dE00cvd(hexes[i], hexes[j]) });
262
+ hexes.forEach((a, i) => hexes.slice(i + 1).forEach((b) => pairs.push({ a, b, dE: dE00cvd(a, b) })));
252
263
  const minMutual = Math.min(...pairs.map((p) => p.dE));
253
264
  const brand = hexes.map((h) => ({ h, dE: Math.min(...g.brand.map((b) => dE00(h, b))) }));
254
265
  const minBrand = Math.min(...brand.map((b) => b.dE));
@@ -387,7 +398,7 @@ export const TWL_WARM: Record<number, number> = { 0: 0.99, 50: 0.97, 100: 0.935,
387
398
  export const ladderFor = (fam: string) => (fam === 'warm' ? TWL_WARM : BRIGHT.has(fam) ? TWL_BRIGHT : TWL);
388
399
  export const TWFRAC: Record<number, number> = { 0: 0.16, 50: 0.34, 100: 0.62, 200: 0.72, 300: 0.84, 400: 0.9, 500: 0.9, 600: 0.88, 700: 0.84, 800: 0.72, 900: 0.58, 950: 0.44 };
389
400
  export const MUTE = 0.8;
390
- export const nearestOn = (hex: string, ladder: Record<number, number>) => { const L = hexToOklch(hex).L; let b = BRAND_SHADES[0], bd = Infinity; for (const s of BRAND_SHADES) { const d = Math.abs(ladder[s] - L); if (d < bd) { bd = d; b = s; } } return b; };
401
+ export const nearestOn = (hex: string, ladder: Record<number, number>) => { const L = hexToOklch(hex).L; let b = BRAND_SHADES[0]!, bd = Infinity; for (const s of BRAND_SHADES) { const d = Math.abs(ladder[s]! - L); if (d < bd) { bd = d; b = s; } } return b; };
391
402
  export const CMUL: Record<string, Record<number, number>> = {
392
403
  acid: { 0: 0.4, 50: 0.45, 100: 0.5, 200: 0.65, 300: 0.82 },
393
404
  primary: { 400: 1.15, 600: 1.3, 700: 1.4, 800: 1.4, 900: 1.3 },
@@ -411,9 +422,9 @@ export const TUNE: Record<string, Record<number, { L?: number; C?: number; H?: n
411
422
  // ---- buildTW: a simple generate-then-stamp ramp (retained as a comparison/utility) ----
412
423
  export function buildTW(H: number, pins: Record<number, string> = {}, ladder: Record<number, number> = TWL, cmul?: Record<number, number>, dHue = 0, sat = 0.9) {
413
424
  const o: Record<number, string> = {};
414
- const L5 = ladder[500], Lbot = ladder[950];
425
+ const L5 = ladder[500]!, Lbot = ladder[950]!;
415
426
  for (const s of BRAND_SHADES) {
416
- const L = ladder[s];
427
+ const L = ladder[s]!;
417
428
  const hh = dHue && L < L5 ? H + dHue * ((L5 - L) / (L5 - Lbot || 1)) : H;
418
429
  o[s] = oklchToHex(L, okhslChroma(L, hh, sat) * (cmul?.[s] ?? 1), hh);
419
430
  }
@@ -422,25 +433,28 @@ export function buildTW(H: number, pins: Record<number, string> = {}, ladder: Re
422
433
  }
423
434
 
424
435
  // ---- PCHIP machinery (monotone-cubic C(L) / H(L) splines through anchors) ----
436
+ // The indexing below is genuinely dynamic — the loop bounds guarantee each read is in range but
437
+ // TypeScript cannot see that, so the reads carry `!`. Guards would be dead branches, and casting
438
+ // to number would hide a real out-of-range read. Note pchipSlopes still assumes n >= 2, as before.
425
439
  export function pchipSlopes(xs: number[], ys: number[]) {
426
440
  const n = xs.length, h: number[] = [], d: number[] = [], m: number[] = new Array(n);
427
- for (let i = 0; i < n - 1; i++) { h[i] = xs[i + 1] - xs[i]; d[i] = (ys[i + 1] - ys[i]) / h[i]; }
428
- m[0] = d[0]; m[n - 1] = d[n - 2];
441
+ for (let i = 0; i < n - 1; i++) { h[i] = xs[i + 1]! - xs[i]!; d[i] = (ys[i + 1]! - ys[i]!) / h[i]!; }
442
+ m[0] = d[0]!; m[n - 1] = d[n - 2]!;
429
443
  for (let i = 1; i < n - 1; i++) {
430
- if (d[i - 1] * d[i] <= 0) m[i] = 0;
431
- else { const w1 = 2 * h[i] + h[i - 1], w2 = h[i] + 2 * h[i - 1]; m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i]); }
444
+ if (d[i - 1]! * d[i]! <= 0) m[i] = 0;
445
+ else { const w1 = 2 * h[i]! + h[i - 1]!, w2 = h[i]! + 2 * h[i - 1]!; m[i] = (w1 + w2) / (w1 / d[i - 1]! + w2 / d[i]!); }
432
446
  }
433
447
  return m;
434
448
  }
435
449
  export function makeSpline(pts: { x: number; y: number }[]) {
436
450
  const s = [...pts].sort((a, b) => a.x - b.x);
437
451
  const xs = s.map((p) => p.x), ys = s.map((p) => p.y), m = pchipSlopes(xs, ys), n = xs.length;
438
- return (x: number) => {
439
- if (x <= xs[0]) return ys[0];
440
- if (x >= xs[n - 1]) return ys[n - 1];
441
- let i = 0; while (x > xs[i + 1]) i++;
442
- const hh = xs[i + 1] - xs[i], t = (x - xs[i]) / hh, t2 = t * t, t3 = t2 * t;
443
- return (2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * hh * m[i] + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * hh * m[i + 1];
452
+ return (x: number): number => {
453
+ if (x <= xs[0]!) return ys[0]!;
454
+ if (x >= xs[n - 1]!) return ys[n - 1]!;
455
+ let i = 0; while (x > xs[i + 1]!) i++;
456
+ const hh = xs[i + 1]! - xs[i]!, t = (x - xs[i]!) / hh, t2 = t * t, t3 = t2 * t;
457
+ return (2 * t3 - 3 * t2 + 1) * ys[i]! + (t3 - 2 * t2 + t) * hh * m[i]! + (-2 * t3 + 3 * t2) * ys[i + 1]! + (t3 - t2) * hh * m[i + 1]!;
444
458
  };
445
459
  }
446
460
  export const normH = (h: number) => ((h % 360) + 360) % 360;
@@ -489,7 +503,7 @@ export const FRAC_DARK = 0.4; // extreme-dark anchor (L0.20, below the floor). T
489
503
  // the shared dark cusp-fraction, so every family's darks stay rich. This anchor only extrapolates below 950.
490
504
  export const CHROMA_HEADROOM = 1.0; // multiplier on the comfort ceiling (1 = exact pin envelope)
491
505
  function buildBrandChromaProfile() {
492
- const allPins = Object.values(PINS).flat() as [string, string, number?][];
506
+ const allPins = Object.values(PINS).flat();
493
507
  // 1. cusp-fraction curve: pin chroma as a fraction of its own cusp (drop near-white pins — cusp too small).
494
508
  // Also drop deliberately-muted dark accents (Bark Brown: f≈0.45 at L0.30): authored low on purpose, it
495
509
  // would clash with Forest's rich dark pins (Darkest Forest rides ~0.94) and suppress the system's dark richness.
@@ -528,13 +542,13 @@ export type PchipOverrides = { sat?: number; chromaMul?: number; headroom?: numb
528
542
  // (the Lime mock), so it shouldn't shape the semantics. ----
529
543
  const HOUSE_ANCHORS = ['primary', 'warm'];
530
544
  const STEERED = new Set(['maple', 'river', 'amber']);
531
- const P3_TO_SRGBLIN = [[1.2249401, -0.2249404, 0], [-0.0420569, 1.0420571, 0], [-0.0196376, -0.0786361, 1.0982735]];
545
+ const P3_TO_SRGBLIN: [Vec3, Vec3, Vec3] = [[1.2249401, -0.2249404, 0], [-0.0420569, 1.0420571, 0], [-0.0196376, -0.0786361, 1.0982735]];
532
546
  // decode a built swatch string (hex or color(display-p3 …)) back to OKLCH.
533
547
  export function decodeOklch(s: string) {
534
548
  if (s[0] === '#') return hexToOklch(s);
535
549
  const m = s.match(/display-p3\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
536
550
  if (!m) return { L: 0, C: 0, H: 0 };
537
- const lin = [srgbToLin(+m[1]), srgbToLin(+m[2]), srgbToLin(+m[3])];
551
+ const lin: Vec3 = [srgbToLin(+m[1]!), srgbToLin(+m[2]!), srgbToLin(+m[3]!)];
538
552
  const r = P3_TO_SRGBLIN[0][0] * lin[0] + P3_TO_SRGBLIN[0][1] * lin[1] + P3_TO_SRGBLIN[0][2] * lin[2];
539
553
  const g = P3_TO_SRGBLIN[1][0] * lin[0] + P3_TO_SRGBLIN[1][1] * lin[1] + P3_TO_SRGBLIN[1][2] * lin[2];
540
554
  const b = P3_TO_SRGBLIN[2][0] * lin[0] + P3_TO_SRGBLIN[2][1] * lin[1] + P3_TO_SRGBLIN[2][2] * lin[2];
@@ -544,10 +558,11 @@ export function decodeOklch(s: string) {
544
558
  const _houseCache: Record<string, Record<number, number>> = {};
545
559
  // average chroma per level across the pinned brand anchors (Primary + Warm) — built canonically (NOT steered → no recursion), cached per gamut.
546
560
  export function houseChroma(gamut: 'srgb' | 'p3' = 'srgb'): Record<number, number> {
547
- if (_houseCache[gamut]) return _houseCache[gamut];
561
+ const cached = _houseCache[gamut];
562
+ if (cached) return cached;
548
563
  const ramps = HOUSE_ANCHORS.map((f) => buildPCHIP(f, gamut));
549
564
  const h: Record<number, number> = {};
550
- for (const s of BRAND_SHADES) { const cs = ramps.map((r) => decodeOklch(r[s]).C); h[s] = cs.reduce((a, b) => a + b, 0) / cs.length; }
565
+ for (const s of BRAND_SHADES) { const cs = ramps.map((r) => decodeOklch(r[s]!).C); h[s] = cs.reduce((a, b) => a + b, 0) / cs.length; }
551
566
  _houseCache[gamut] = h;
552
567
  return h;
553
568
  }
@@ -555,7 +570,7 @@ export function houseChroma(gamut: 'srgb' | 'p3' = 'srgb'): Record<number, numbe
555
570
  export function buildPCHIP(fam: string, gamut: 'srgb' | 'p3' = 'srgb', ov?: PchipOverrides) {
556
571
  const maxC = gamut === 'p3' ? maxChromaP3 : maxChromaAt;
557
572
  const toStr = gamut === 'p3' ? oklchToP3 : oklchToHex;
558
- const base = hexToOklch(ov?.hue ?? HUE[fam]);
573
+ const base = hexToOklch(ov?.hue ?? HUE[fam]!); // an unknown family already threw here
559
574
  const H0 = normH(base.H);
560
575
  const ladder = ov?.ladder ?? ladderFor(fam);
561
576
  const sat = ov?.sat ?? SAT[fam] ?? 0.9;
@@ -572,12 +587,12 @@ export function buildPCHIP(fam: string, gamut: 'srgb' | 'p3' = 'srgb', ov?: Pchi
572
587
  if (!anchors.some((a) => a.L < 0.34)) { const Ld = 0.24, hd = H0 + tors.d; anchors.push({ L: Ld, C: Math.min(okhslChroma(Ld, hd, sat), maxC(Ld, hd)), H: hd, hex: '' }); }
573
588
  anchors.sort((a, b) => a.L - b.L);
574
589
  // unwrap hue to dodge 0/360 wrap in the spline
575
- for (let i = 1; i < anchors.length; i++) { while (anchors[i].H - anchors[i - 1].H > 180) anchors[i].H -= 360; while (anchors[i].H - anchors[i - 1].H < -180) anchors[i].H += 360; }
590
+ for (let i = 1; i < anchors.length; i++) { const a = anchors[i]!, prev = anchors[i - 1]!; while (a.H - prev.H > 180) a.H -= 360; while (a.H - prev.H < -180) a.H += 360; }
576
591
  const hS = makeSpline(anchors.map((a) => ({ x: a.L, y: a.H })));
577
592
  // pin owns its level's L: override the ladder slot lightness with the pin's true L
578
593
  const Lad: Record<number, number> = { ...ladder };
579
- const pinHex: Record<number, string> = {};
580
- for (const [, hex, lvl] of list) { const o = hexToOklch(hex); const level = lvl ?? nearestOn(hex, ladder); Lad[level] = o.L; pinHex[level] = hex.toLowerCase(); }
594
+ const pinned: { level: number; hex: string }[] = [];
595
+ for (const [, hex, lvl] of list) { const po = hexToOklch(hex); const level = lvl ?? nearestOn(hex, ladder); Lad[level] = po.L; pinned.push({ level, hex: hex.toLowerCase() }); }
581
596
  const o: Record<number, string> = {};
582
597
  const tune = ov?.tune ?? TUNE[fam] ?? {};
583
598
  const cMul = ov?.chromaMul ?? 1; // global chroma dial — scales the derived curve (then gamut-clamped)
@@ -587,16 +602,16 @@ export function buildPCHIP(fam: string, gamut: 'srgb' | 'p3' = 'srgb', ov?: Pchi
587
602
  const house = steered ? houseChroma(gamut) : null;
588
603
  for (const s of BRAND_SHADES) {
589
604
  const tu = tune[s];
590
- const L = s === 0 ? 0.985 : (tu?.L ?? Lad[s]);
605
+ const L = s === 0 ? 0.985 : (tu?.L ?? Lad[s]!);
591
606
  const hh = tu?.H != null ? tu.H : hS(L);
592
607
  // chroma: steered families take the house curve; brand families ride the cusp-fraction + comfort ceiling. Clamp to gamut.
593
- const target = house ? house[s] : brandChroma(L, hh, gamut, headroom);
608
+ const target = house ? house[s]! : brandChroma(L, hh, gamut, headroom);
594
609
  let C = Math.min(target * cMul, maxC(L, hh));
595
610
  if (tu?.C != null) C = Math.min(tu.C, maxC(L, hh));
596
611
  o[s] = toStr(L, C, hh);
597
612
  }
598
613
  // guarantee exact pin (sRGB brand hex; in p3 mode emit its display-p3 form — same in-gamut colour)
599
- for (const [lvl, hex] of Object.entries(pinHex)) { const po = hexToOklch(hex); o[+lvl] = gamut === 'p3' ? oklchToP3(po.L, po.C, po.H) : hex; }
614
+ for (const { level, hex } of pinned) { const po = hexToOklch(hex); o[level] = gamut === 'p3' ? oklchToP3(po.L, po.C, po.H) : hex; }
600
615
  return o;
601
616
  }
602
617