@humanforest/tokens 0.2.0 → 0.3.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/package.json +1 -1
- package/src/colourEngine.ts +53 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanforest/tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
package/src/colourEngine.ts
CHANGED
|
@@ -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):
|
|
12
|
+
export function hexToLin(hex: string): Vec3 {
|
|
8
13
|
const c = hex.replace('#', '');
|
|
9
|
-
|
|
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]:
|
|
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]:
|
|
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]:
|
|
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:
|
|
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 [
|
|
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]:
|
|
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]:
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
428
|
-
m[0] = d[0]
|
|
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]
|
|
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]
|
|
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()
|
|
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
|
-
|
|
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++) {
|
|
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
|
|
580
|
-
for (const [, hex, lvl] of list) { const
|
|
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
|
|
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
|
|