@homie-rocks/studio 0.20.0 → 0.22.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 (54) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/README.md +55 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +52 -3
  7. package/lib/art-cli.mjs +296 -0
  8. package/lib/art-tools.mjs +331 -0
  9. package/lib/asset-check.mjs +189 -0
  10. package/lib/asset-import.mjs +136 -0
  11. package/lib/asset-manifest.mjs +318 -0
  12. package/lib/build.mjs +25 -2
  13. package/lib/check.mjs +3 -1
  14. package/lib/codex.mjs +43 -1
  15. package/lib/colour.mjs +160 -0
  16. package/lib/decisions.mjs +800 -0
  17. package/lib/directory.mjs +9 -1
  18. package/lib/library.mjs +169 -0
  19. package/lib/mcp-tools.mjs +22 -4
  20. package/lib/mcp.mjs +6 -3
  21. package/lib/office.mjs +30 -8
  22. package/lib/optimise.mjs +380 -0
  23. package/lib/port-check.mjs +3 -1
  24. package/lib/progress.mjs +16 -1
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +32 -1
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +75 -0
  33. package/lib/thumb.mjs +82 -0
  34. package/mcp/ui/card.css +15 -0
  35. package/mcp/ui/cast.js +62 -0
  36. package/mcp/ui/decision.js +61 -0
  37. package/mcp/ui/lineup.js +68 -0
  38. package/mcp/ui/rights.js +30 -0
  39. package/mcp/ui/style.js +154 -0
  40. package/package.json +14 -2
  41. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  42. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  43. package/starters/gem-rush-3d/credits.json +31 -0
  44. package/starters/gem-rush-3d/game.json +11 -0
  45. package/starters/gem-rush-3d/index.html +20 -0
  46. package/starters/gem-rush-3d/lab.json +19 -0
  47. package/starters/gem-rush-3d/src/main.ts +2086 -0
  48. package/starters/gem-rush-3d/style.json +66 -0
  49. package/starters/gem-rush-3d/tunables.json +13 -0
  50. package/worker/index.mjs +5 -3
  51. package/worker/pages.mjs +13 -5
  52. package/worker/qr.mjs +11 -0
  53. package/worker/site.mjs +5 -3
  54. package/worker/version.mjs +1 -1
package/lib/codex.mjs CHANGED
@@ -42,6 +42,7 @@ import { latestFeedFor, summarize } from './feed-summary.mjs';
42
42
  import { escapeHtml as esc, renderMarkdown } from './markdown.mjs';
43
43
  import { readTheme } from './site.mjs';
44
44
  import { GAME_ID, listGames, readStudio } from './studio.mjs';
45
+ import { decisionRows, readDecisions } from './decisions.mjs';
45
46
 
46
47
  export const CODEX_FILE = 'CODEX.md';
47
48
  export const CODEX_DIR = join('.studio', 'codex');
@@ -314,6 +315,9 @@ function sectionHtml(section, emb, ctx) {
314
315
  }
315
316
  const questions = section.key === 'questions';
316
317
  let body = blocks(intro, emb, { dated: section.key === 'latest' });
318
+ // The Art direction tab is the style bible: the decisions (games/<id>/codex/decisions.json) drawn after the prose.
319
+ const decided = section.key === 'art' && ctx.decisions ? decisionsHtml(ctx.decisions, emb) : '';
320
+ if (decided) body += decided;
317
321
  if (questions) body = body.replace(/^<(ul|ol)>/, '<ol class="questions">').replace(/<\/(ul|ol)>$/, '</ol>');
318
322
  const empty = !body && !cards.length;
319
323
  return `<section class="tab" id="${esc(section.id)}" data-key="${esc(section.key ?? '')}">
@@ -324,6 +328,29 @@ ${cards.length ? `<div class="cards">${cards.map((c) => card(c.title, c.lines, e
324
328
  </section>`;
325
329
  }
326
330
 
331
+ /* ------------------------------------------------------------------ the art direction decisions */
332
+
333
+ const STATE_WORD = { auto: 'Auto', steered: 'Steered', pinned: 'Pinned by use', locked: 'Locked' };
334
+
335
+ /**
336
+ * The decisions as the style bible: the board's directions (the engine's swatches, a painted mood image only as a
337
+ * labelled target), the golden images, then one table per phase: decision, value (with the palette's colours), why, its
338
+ * state and who set it. Pictures come from codex/board/ inside the game's folder, embedded like any codex picture.
339
+ */
340
+ function decisionsHtml(doc, emb) {
341
+ const phases = decisionRows(doc);
342
+ if (!phases.length) return '';
343
+ const swatch = (c) => `<span class="sw" style="background:${esc(c)}" title="${esc(c)}"></span>`;
344
+ const board = (doc.board?.directions ?? []).map((d) => {
345
+ const pic = d.swatch ? emb.image(d.swatch) : null;
346
+ const mood = d.mood?.path ? emb.image(d.mood.path) : null;
347
+ return `<figure class="dir${doc.board.chosen === d.id ? ' chosen' : ''}">${pic ? `<img src="${pic}" alt="Direction ${esc(d.id.toUpperCase())}, drawn by the game engine">` : ''}${mood ? `<img src="${mood}" alt="A painted mood image for direction ${esc(d.id.toUpperCase())} (a target)">` : ''}<figcaption><b>${esc(d.id.toUpperCase())}</b> ${esc(d.label)}${doc.board.chosen === d.id ? ' · chosen' : ''}${mood ? ' · the painting is a target, not what the game draws' : ''}</figcaption></figure>`;
348
+ }).join('');
349
+ const golden = (doc.golden ?? []).map((g) => { const pic = emb.image(g.path); return pic ? `<img src="${pic}" alt="Golden image">` : ''; }).join('');
350
+ const tables = phases.map((p) => `<h3 class="phase">${esc(p.label)}</h3><div class="scroll"><table class="decisions"><thead><tr><th>Decision</th><th>Value</th><th>Why</th><th>State</th></tr></thead><tbody>${p.rows.map((r) => `<tr class="${esc(r.state)}"><td>${esc(r.name)}</td><td>${r.colours ? `<span class="sws">${r.colours.map(swatch).join('')}</span> ` : ''}${esc(r.label)}${r.steer.length ? ` <em>(“${esc(r.steer.at(-1))}”)</em>` : ''}</td><td>${esc(r.why ?? '')}</td><td><span class="st ${esc(r.state)}">${esc(STATE_WORD[r.state] ?? r.state)}</span>${r.by === 'person' ? ' <small>the person</small>' : ''}${r.at ? ` <small>${esc(String(r.at).slice(0, 10))}</small>` : ''}</td></tr>`).join('')}</tbody></table></div>`).join('');
351
+ return `<div class="decided"><p class="eyebrow">The decisions</p><p class="sub">Every look decision with its why. Auto: picked from the plan; steered: nudged by the person; pinned: an asset is built on it; locked: frozen by the person. Your AI changes a locked one only after you say so, and shows what would need remaking first.</p>${board ? `<div class="board">${board}</div>` : ''}${golden ? `<p class="eyebrow">Golden images</p><div class="gallery golden">${golden}</div>` : ''}${tables}${doc.derived?.prompt?.text ? `<details class="prompt"><summary>The style prompt every generated picture starts from</summary><p>${esc(doc.derived.prompt.text)}</p></details>` : ''}</div>`;
352
+ }
353
+
327
354
  /* ------------------------------------------------------------------ the build status tab */
328
355
 
329
356
  const STATE_MARK = { done: '✓', pass: '✓', skipped: '–', skip: '–', running: '◌', failed: '✗', fail: '✗', stopped: '■', pending: '' };
@@ -432,6 +459,13 @@ code{font-family:var(--mono);font-size:.92em}pre{overflow-x:auto;background:var(
432
459
  .try{margin:16px 0;padding:14px 16px;border:1px solid var(--gold);border-radius:14px;background:color-mix(in srgb,var(--gold) 8%,transparent)}.try .eyebrow{color:var(--gold)}.try p{margin:0}
433
460
  .panel h3{margin-bottom:6px}.status .panel{margin-top:14px}
434
461
  footer{max-width:1080px;margin:0 auto;padding:18px 16px 40px;color:var(--dim);font-size:13px;border-top:1px solid var(--line)}
462
+ .decided{margin-top:22px}.decided h3.phase{margin:22px 0 4px;font:600 13px/1.4 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--accent2)}
463
+ table.decisions td:first-child{white-space:nowrap;font-weight:600}table.decisions td:nth-child(3){color:var(--dim);font-size:14px}
464
+ .sws{display:inline-flex;gap:3px;vertical-align:middle}.sw{display:inline-block;width:16px;height:16px;border-radius:4px;border:1px solid var(--line)}
465
+ .st{display:inline-block;padding:1px 8px;border-radius:999px;font:600 12px/1.6 var(--mono);border:1px solid var(--line);color:var(--dim)}
466
+ .st.locked{color:var(--gold);border-color:var(--gold)}.st.pinned{color:var(--accent2);border-color:color-mix(in srgb,var(--accent2) 60%,transparent)}.st.steered{color:var(--ink)}
467
+ .board{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:12px;margin:12px 0}.board figure{margin:0}.board figure.chosen img:first-child{outline:3px solid var(--accent);outline-offset:-3px}.board img+img{margin-top:6px}
468
+ details.prompt{margin:16px 0;color:var(--dim);font-size:14px}details.prompt summary{cursor:pointer;color:var(--accent2)}
435
469
  @media (max-width:560px){.card .art{width:72px;height:72px}ol.timeline li{grid-template-columns:1fr;gap:2px}}`;
436
470
  }
437
471
 
@@ -502,10 +536,17 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
502
536
  let studio = {};
503
537
  try { studio = readStudio(root); } catch { studio = {}; }
504
538
  const emb = new Embedder(root, game.dir);
539
+ let decisions = null;
540
+ try { decisions = readDecisions(root, id); } catch { decisions = null; }
541
+ // A palette or fonts the person steered or locked, or an asset pinned, is the game's look: the codex wears it too.
542
+ const pal = decisions?.decisions?.['style.palette'];
543
+ const ui = decisions?.decisions?.['style.ui'];
544
+ if (pal && pal.state !== 'auto') meta.palette = { ...(meta.palette ?? {}), ...Object.fromEntries(['bg', 'ink', 'accent', 'accent2', 'danger', 'good', 'gold'].filter((k) => pal.value?.[k]).map((k) => [k, pal.value[k]])) };
545
+ if (ui && ui.state !== 'auto' && ui.value?.display) meta.fonts = { ...(meta.fonts ?? {}), display: ui.value.display, body: ui.value.body ?? meta.fonts?.body };
505
546
  const look = lookOf(root, game, meta, emb);
506
547
  const title = parsed.title ?? game.name ?? id;
507
548
  const eyebrow = typeof meta.eyebrow === 'string' && meta.eyebrow.trim() ? meta.eyebrow.trim() : `${studio.name ?? 'Studio'} · ${title}`;
508
- const ctx = { eyebrow: String(eyebrow).slice(0, 80), pixel: meta.pixel === true, try: typeof meta.try === 'string' ? meta.try.slice(0, 300) : '', mode };
549
+ const ctx = { eyebrow: String(eyebrow).slice(0, 80), pixel: meta.pixel === true, try: typeof meta.try === 'string' ? meta.try.slice(0, 300) : '', mode, decisions };
509
550
  const s = summarize(feed === undefined ? latestFeedFor(root, id) : feed);
510
551
  const sections = parsed.sections.filter((x) => x.body.join('\n').trim() || x.key);
511
552
  const tabs = sections.map((x) => `<a href="#${esc(x.id)}">${esc(x.title)}</a>`);
@@ -519,6 +560,7 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
519
560
  const start = s?.state === 'running' ? 'build-status' : (sections[0]?.id ?? 'build-status');
520
561
  const tagline = typeof meta.tagline === 'string' ? meta.tagline : '';
521
562
  const gaps = codexGaps(parsed);
563
+ if (decisions) gaps.missing = gaps.missing.filter((x) => x !== 'Art direction');
522
564
  const html = `<!doctype html>
523
565
  <html lang="en">
524
566
  <head>
package/lib/colour.mjs ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * COLOUR ARITHMETIC for art direction: hex and RGB, HSL nudges (a palette steered "warmer" or "less saturated"),
3
+ * CIE Lab and the CIEDE2000 difference (is an asset's albedo still in the locked palette?), and a small k-means for
4
+ * the dominant colours of a picture. No dependencies; numbers, never a file.
5
+ */
6
+
7
+ const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
8
+ export const HEX = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
9
+
10
+ /** "#ffcf5a" (or "#fc5") as [r, g, b] in 0..255; null when it is not a hex colour. */
11
+ export function hexToRgb(hex) {
12
+ const m = HEX.exec(String(hex ?? '').trim());
13
+ if (!m) return null;
14
+ const h = m[1].length === 3 ? m[1].replace(/./g, '$&$&') : m[1];
15
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
16
+ }
17
+
18
+ export function rgbToHex([r, g, b]) {
19
+ return `#${[r, g, b].map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')).join('')}`;
20
+ }
21
+
22
+ export function rgbToHsl([r, g, b]) {
23
+ const R = r / 255; const G = g / 255; const B = b / 255;
24
+ const max = Math.max(R, G, B); const min = Math.min(R, G, B);
25
+ const l = (max + min) / 2;
26
+ if (max === min) return [0, 0, l];
27
+ const d = max - min;
28
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
29
+ let h = max === R ? (G - B) / d + (G < B ? 6 : 0) : max === G ? (B - R) / d + 2 : (R - G) / d + 4;
30
+ h *= 60;
31
+ return [h, s, l];
32
+ }
33
+
34
+ export function hslToRgb([h, s, l]) {
35
+ const H = (((h % 360) + 360) % 360) / 360;
36
+ if (s === 0) return [l * 255, l * 255, l * 255];
37
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
38
+ const p = 2 * l - q;
39
+ const f = (t) => { let x = t; if (x < 0) x += 1; if (x > 1) x -= 1; if (x < 1 / 6) return p + (q - p) * 6 * x; if (x < 1 / 2) return q; if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6; return p; };
40
+ return [f(H + 1 / 3) * 255, f(H) * 255, f(H - 1 / 3) * 255];
41
+ }
42
+
43
+ /** The shortest signed angle from hue a to hue b, in degrees. */
44
+ const hueStep = (a, b) => ((((b - a) % 360) + 540) % 360) - 180;
45
+
46
+ /**
47
+ * One colour nudged: { hue: degrees toward `toward` (or a plain rotation), sat: multiply, light: add (-1..1),
48
+ * warm: 0..1 (toward orange) or cool (toward blue) }. Greys stay grey.
49
+ */
50
+ export function nudge(hex, { warm = 0, cool = 0, sat = 1, light = 0, hue = 0 } = {}) {
51
+ const rgb = hexToRgb(hex);
52
+ if (!rgb) return hex;
53
+ let [h, s, l] = rgbToHsl(rgb);
54
+ if (s > 0.06) {
55
+ if (warm) h += hueStep(h, 32) * clamp(warm);
56
+ if (cool) h += hueStep(h, 215) * clamp(cool);
57
+ h += hue;
58
+ }
59
+ s = clamp(s * sat);
60
+ l = clamp(l + light, 0.02, 0.98);
61
+ return rgbToHex(hslToRgb([h, s, l]));
62
+ }
63
+
64
+ /* ------------------------------------------------------------------ Lab and CIEDE2000 */
65
+
66
+ function srgbToLinear(c) { const v = c / 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }
67
+
68
+ /** sRGB [0..255] to CIE L*a*b* (D65). */
69
+ export function rgbToLab([r, g, b]) {
70
+ const R = srgbToLinear(r); const G = srgbToLinear(g); const B = srgbToLinear(b);
71
+ const X = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
72
+ const Y = R * 0.2126 + G * 0.7152 + B * 0.0722;
73
+ const Z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
74
+ const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
75
+ const fx = f(X); const fy = f(Y); const fz = f(Z);
76
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
77
+ }
78
+
79
+ /** CIEDE2000 between two Lab colours (Sharma, Wu and Dalal 2005). About 2.3 is "just noticeable". */
80
+ export function deltaE2000([L1, a1, b1], [L2, a2, b2]) {
81
+ const rad = Math.PI / 180;
82
+ const C1 = Math.hypot(a1, b1); const C2 = Math.hypot(a2, b2);
83
+ const Cb = (C1 + C2) / 2;
84
+ const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)));
85
+ const ap1 = (1 + G) * a1; const ap2 = (1 + G) * a2;
86
+ const Cp1 = Math.hypot(ap1, b1); const Cp2 = Math.hypot(ap2, b2);
87
+ const hp = (b, ap) => { if (b === 0 && ap === 0) return 0; const h = Math.atan2(b, ap) / rad; return h >= 0 ? h : h + 360; };
88
+ const hp1 = hp(b1, ap1); const hp2 = hp(b2, ap2);
89
+ const dL = L2 - L1; const dC = Cp2 - Cp1;
90
+ let dh = 0;
91
+ if (Cp1 * Cp2 !== 0) { dh = hp2 - hp1; if (dh > 180) dh -= 360; else if (dh < -180) dh += 360; }
92
+ const dH = 2 * Math.sqrt(Cp1 * Cp2) * Math.sin((dh / 2) * rad);
93
+ const Lb = (L1 + L2) / 2; const Cpb = (Cp1 + Cp2) / 2;
94
+ let hb = hp1 + hp2;
95
+ if (Cp1 * Cp2 !== 0) { hb = Math.abs(hp1 - hp2) <= 180 ? (hp1 + hp2) / 2 : hp1 + hp2 < 360 ? (hp1 + hp2 + 360) / 2 : (hp1 + hp2 - 360) / 2; }
96
+ const T = 1 - 0.17 * Math.cos((hb - 30) * rad) + 0.24 * Math.cos(2 * hb * rad) + 0.32 * Math.cos((3 * hb + 6) * rad) - 0.2 * Math.cos((4 * hb - 63) * rad);
97
+ const dTheta = 30 * Math.exp(-(((hb - 275) / 25) ** 2));
98
+ const Rc = 2 * Math.sqrt(Cpb ** 7 / (Cpb ** 7 + 25 ** 7));
99
+ const Sl = 1 + (0.015 * (Lb - 50) ** 2) / Math.sqrt(20 + (Lb - 50) ** 2);
100
+ const Sc = 1 + 0.045 * Cpb; const Sh = 1 + 0.015 * Cpb * T;
101
+ const Rt = -Math.sin(2 * dTheta * rad) * Rc;
102
+ return Math.sqrt((dL / Sl) ** 2 + (dC / Sc) ** 2 + (dH / Sh) ** 2 + Rt * (dC / Sc) * (dH / Sh));
103
+ }
104
+
105
+ /** ΔE2000 between two hex colours. */
106
+ export const deltaHex = (a, b) => { const x = hexToRgb(a); const y = hexToRgb(b); return x && y ? deltaE2000(rgbToLab(x), rgbToLab(y)) : Infinity; };
107
+
108
+ /** The nearest palette colour to `hex`: { hex, delta }. */
109
+ export function nearest(hex, palette) {
110
+ let best = { hex: null, delta: Infinity };
111
+ for (const p of palette) { const d = deltaHex(hex, p); if (d < best.delta) best = { hex: p, delta: d }; }
112
+ return best;
113
+ }
114
+
115
+ /**
116
+ * Dominant colours of RGB samples ([[r,g,b], ...], or a flat Uint8Array of RGB with `weights`), by k-means in Lab:
117
+ * [{ hex, share }] biggest share first. Deterministic: the first centres are the most distant samples.
118
+ */
119
+ export function dominant(samples, k = 5, { iterations = 12 } = {}) {
120
+ const pts = samples.map((s) => ({ rgb: s.rgb ?? s, w: s.w ?? 1, lab: rgbToLab(s.rgb ?? s) }));
121
+ if (!pts.length) return [];
122
+ const K = Math.min(k, pts.length);
123
+ // Farthest-point seeding from the heaviest sample.
124
+ const centres = [pts.slice().sort((a, b) => b.w - a.w)[0].lab];
125
+ while (centres.length < K) {
126
+ let far = null; let fd = -1;
127
+ for (const p of pts) { const d = Math.min(...centres.map((c) => (c[0] - p.lab[0]) ** 2 + (c[1] - p.lab[1]) ** 2 + (c[2] - p.lab[2]) ** 2)); if (d > fd) { fd = d; far = p; } }
128
+ centres.push(far.lab);
129
+ }
130
+ let assign = new Array(pts.length).fill(0);
131
+ for (let it = 0; it < iterations; it++) {
132
+ assign = pts.map((p) => { let bi = 0; let bd = Infinity; centres.forEach((c, i) => { const d = (c[0] - p.lab[0]) ** 2 + (c[1] - p.lab[1]) ** 2 + (c[2] - p.lab[2]) ** 2; if (d < bd) { bd = d; bi = i; } }); return bi; });
133
+ for (let i = 0; i < K; i++) {
134
+ let w = 0; const s = [0, 0, 0];
135
+ pts.forEach((p, j) => { if (assign[j] === i) { w += p.w; s[0] += p.lab[0] * p.w; s[1] += p.lab[1] * p.w; s[2] += p.lab[2] * p.w; } });
136
+ if (w) centres[i] = [s[0] / w, s[1] / w, s[2] / w];
137
+ }
138
+ }
139
+ const total = pts.reduce((n, p) => n + p.w, 0);
140
+ return centres.map((c, i) => {
141
+ let w = 0; const rgb = [0, 0, 0];
142
+ pts.forEach((p, j) => { if (assign[j] === i) { w += p.w; rgb[0] += p.rgb[0] * p.w; rgb[1] += p.rgb[1] * p.w; rgb[2] += p.rgb[2] * p.w; } });
143
+ return w ? { hex: rgbToHex(rgb.map((x) => x / w)), share: +(w / total).toFixed(3) } : null;
144
+ }).filter(Boolean).sort((a, b) => b.share - a.share);
145
+ }
146
+
147
+ /** Relative luminance (0..1) of a hex colour, for "is this a light palette". */
148
+ export function luminance(hex) {
149
+ const rgb = hexToRgb(hex);
150
+ if (!rgb) return 0;
151
+ const [r, g, b] = rgb.map(srgbToLinear);
152
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
153
+ }
154
+
155
+ /** Interpolate two hex colours (t 0..1). */
156
+ export function mix(a, b, t) {
157
+ const x = hexToRgb(a); const y = hexToRgb(b);
158
+ if (!x || !y) return a;
159
+ return rgbToHex(x.map((v, i) => v + (y[i] - v) * t));
160
+ }