@homie-rocks/studio 0.21.0 → 0.23.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 (70) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +92 -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 +81 -2
  7. package/chat/CHAT.md +101 -0
  8. package/chat/OWNERS.md +78 -0
  9. package/lib/art-cli.mjs +296 -0
  10. package/lib/art-tools.mjs +331 -0
  11. package/lib/asset-check.mjs +189 -0
  12. package/lib/asset-import.mjs +136 -0
  13. package/lib/asset-manifest.mjs +318 -0
  14. package/lib/build.mjs +32 -2
  15. package/lib/chat-cli.mjs +89 -0
  16. package/lib/cloudflare.mjs +5 -2
  17. package/lib/codex.mjs +43 -1
  18. package/lib/colour.mjs +160 -0
  19. package/lib/decisions.mjs +800 -0
  20. package/lib/directory.mjs +9 -1
  21. package/lib/library.mjs +169 -0
  22. package/lib/mcp-tools.mjs +22 -4
  23. package/lib/mcp.mjs +6 -3
  24. package/lib/optimise.mjs +380 -0
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +39 -2
  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 +76 -0
  33. package/mcp/ui/card.css +15 -0
  34. package/mcp/ui/cast.js +62 -0
  35. package/mcp/ui/decision.js +61 -0
  36. package/mcp/ui/lineup.js +68 -0
  37. package/mcp/ui/rights.js +30 -0
  38. package/mcp/ui/style.js +154 -0
  39. package/netplay/NETPLAY.md +191 -3
  40. package/netplay/netplay.ts +150 -3
  41. package/package.json +15 -2
  42. package/port/index.ts +3 -2
  43. package/port/view.ts +238 -0
  44. package/site/SITE.md +11 -1
  45. package/starters/ember-vale/src/main.ts +22 -2
  46. package/starters/gem-rush/src/main.ts +21 -1
  47. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  48. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  49. package/starters/gem-rush-3d/credits.json +31 -0
  50. package/starters/gem-rush-3d/game.json +11 -0
  51. package/starters/gem-rush-3d/index.html +20 -0
  52. package/starters/gem-rush-3d/lab.json +19 -0
  53. package/starters/gem-rush-3d/src/main.ts +2107 -0
  54. package/starters/gem-rush-3d/style.json +66 -0
  55. package/starters/gem-rush-3d/tunables.json +13 -0
  56. package/worker/agents.mjs +3 -3
  57. package/worker/chat-page.mjs +556 -0
  58. package/worker/chat-store.mjs +161 -0
  59. package/worker/chat-words.mjs +46 -0
  60. package/worker/chat.mjs +419 -0
  61. package/worker/index.mjs +153 -8
  62. package/worker/office-page.mjs +115 -0
  63. package/worker/office.mjs +137 -6
  64. package/worker/pages.mjs +32 -9
  65. package/worker/qr.mjs +11 -0
  66. package/worker/room.mjs +288 -10
  67. package/worker/servers.mjs +5 -1
  68. package/worker/site.mjs +9 -4
  69. package/worker/stats.mjs +4 -1
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `homie-studio chat` — a studio's room chat from the studio folder (worker/chat.mjs says what it is; NETPLAY.md
3
+ * section 19 the rules and the wire). The office key it uses is the same as `office`'s: minted with the studio's own
4
+ * Cloudflare login, ten minutes long, dropped as soon as the answer is in.
5
+ *
6
+ * homie-studio chat [--game <id>] each game's chat rules, every live room's last minutes, the
7
+ * reports, and the review's day (neurons used of its budget)
8
+ * homie-studio chat rules <game> [--server <id>] [--mode off|emoji|lines|text] [--who anyone|signed-in|members]
9
+ * [--react …] [--slow <s>] [--max <n>] [--links block|allow] [--swears block|allow]
10
+ * [--ai on|off] [--bubbles on|off] [--watchers on|off] [--hub on|off] [--block "a,b"] [--reset]
11
+ * homie-studio chat remove <game> <room> <line id>|--all
12
+ * homie-studio chat budget <neurons> the review's day for the whole studio (default 2,000)
13
+ * homie-studio chat words the built-in word list the floor holds (worker/chat-words.mjs)
14
+ *
15
+ * A change that opens chat up (a wider mode or audience, less slow mode, links or swears allowed, the review off) or
16
+ * a bigger budget is only ASKED for here: the answer is the owner's one-tap link, as `office kick` gives.
17
+ */
18
+ import { builtinWords } from '../worker/chat.mjs';
19
+ import { askedFor, withKey } from './office.mjs';
20
+
21
+ const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
22
+ const onOff = (v) => (v === undefined ? undefined : v === true || v === 'on' || v === 'true' || v === 'yes');
23
+
24
+ /** The flags `chat rules` reads, as the office API's fields. */
25
+ export function chatFields(flags) {
26
+ const f = {};
27
+ const get = (k) => (flags.has(k) ? flags.get(k) : undefined);
28
+ for (const k of ['mode', 'who', 'react', 'links', 'swears']) if (get(k) !== undefined) f[k] = String(get(k));
29
+ for (const k of ['slow', 'max']) if (get(k) !== undefined) f[k] = Number(get(k));
30
+ for (const k of ['ai', 'bubbles', 'watchers', 'hub']) if (get(k) !== undefined) f[k] = onOff(get(k));
31
+ if (get('block') !== undefined) f.block = String(get('block')).split(',').map((w) => w.trim()).filter(Boolean);
32
+ if (get('allow') !== undefined) f.allow = String(get('allow')).split(',').map((w) => w.trim()).filter(Boolean);
33
+ return f;
34
+ }
35
+
36
+ export async function chatShow(root, { url, game } = {}) {
37
+ const r = await withKey(root, url, (call) => call(`/_studio/api/chat${game ? `?game=${encodeURIComponent(game)}` : ''}`));
38
+ if (!r.ok) return { ok: false, command: 'chat', why: r.why ?? r.message ?? 'the office did not answer' };
39
+ return { command: 'chat', ...r, ok: true };
40
+ }
41
+
42
+ export async function chatRulesSet(root, game, flags, { url } = {}) {
43
+ if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'chat rules', why: 'usage: homie-studio chat rules <game> [--server <id>] --mode off|emoji|lines|text [--who …] [--slow <s>] … | --reset' };
44
+ const server = flags.get('server') ?? '';
45
+ const body = flags.has('reset') ? { game, server, reset: true } : { game, server, ...chatFields(flags) };
46
+ if (!body.reset && Object.keys(body).length <= 2) return { ok: false, command: 'chat rules', why: 'say what to change: --mode, --who, --react, --slow, --max, --links, --swears, --ai, --bubbles, --watchers, --hub, --block, --allow; or --reset' };
47
+ const r = await withKey(root, url, (call) => call('/_studio/api/chat/rules', body));
48
+ return askedFor(root, url, r, 'chat rules');
49
+ }
50
+
51
+ export async function chatRemove(root, game, room, id, { url, all = false } = {}) {
52
+ if (!GAME.test(String(game ?? '')) || !room || (!id && !all)) return { ok: false, command: 'chat remove', why: 'usage: homie-studio chat remove <game> <room> <line id> | --all (the ids are in `homie-studio chat`)' };
53
+ const r = await withKey(root, url, (call) => call('/_studio/api/chat/remove', { game, room, ...(all ? { all: true } : { id }) }));
54
+ return r.ok ? { ok: true, command: 'chat remove', removed: r.removed ?? 0, message: `Took ${r.removed ?? 0} message${r.removed === 1 ? '' : 's'} down in ${room}, on every screen.` } : { ok: false, command: 'chat remove', why: r.message ?? r.why };
55
+ }
56
+
57
+ export async function chatBudget(root, neurons, { url } = {}) {
58
+ const n = Math.floor(Number(neurons));
59
+ if (!(n >= 0 && n <= 1e7)) return { ok: false, command: 'chat budget', why: 'usage: homie-studio chat budget <neurons a day> (default 2000; Workers AI gives an account 10,000 a day free)' };
60
+ const r = await withKey(root, url, (call) => call('/_studio/api/chat/budget', { neurons: n }));
61
+ return askedFor(root, url, r, 'chat budget');
62
+ }
63
+
64
+ /** The built-in list, by group: what the floor always holds (swears only while a game holds swears). */
65
+ export function chatWords() {
66
+ const w = builtinWords();
67
+ return {
68
+ ok: true, command: 'chat words',
69
+ groups: {
70
+ slurs: [...w.slurs], slursInsideWords: w.slursAnywhere, sexual: [...w.sexual], sexualInsideWords: w.sexualAnywhere,
71
+ swears: [...w.swears], swearsInsideWords: w.swearsAnywhere, harm: w.harm, contact: w.contact,
72
+ },
73
+ note: 'Whole words unless "inside words". Shown as the floor reads them (lower case, letters only). A studio adds its own with chat rules --block, and lets one through with --allow.',
74
+ };
75
+ }
76
+
77
+ export function chatLines(r) {
78
+ const out = [];
79
+ const d = r.day;
80
+ if (d) out.push(`Review today: ${d.ai ? `${Math.round(d.used.neurons)} of ${d.budget.neurons} neurons, ${d.used.reviews} reviewed, ${d.used.held} held${d.used.errors ? `, ${d.used.errors} unanswered (the word list decided)` : ''}` : 'no Workers AI binding yet (npm run deploy binds it); the word list decides'}.`);
81
+ for (const g of r.games ?? []) {
82
+ const c = g.chat?.rules;
83
+ out.push(`${g.name} (${g.id}): ${c ? `${c.mode}${c.mode === 'text' ? `, typing ${c.who}` : ''}, emoji ${c.react}, slow ${c.slow} s${c.ai ? ', reviewed' : ''}${c.capped ? ` (capped: ${c.capped})` : ''}` : 'no rules read'} · from ${g.chat?.from ?? 'default'}`);
84
+ for (const sv of g.chat?.servers ?? []) if (sv.office) out.push(` server ${sv.id}: ${sv.rules.mode}, typing ${sv.rules.who}`);
85
+ for (const rm of g.rooms ?? []) for (const l of (rm.chat?.lines ?? []).slice(-8)) out.push(` ${rm.room} ${l.id} ${l.by === 'studio' ? 'Studio' : l.name}: ${l.kind === 'react' ? l.glyph : l.text}`);
86
+ for (const p of g.chat?.reports ?? []) out.push(` report ${p.id}: "${p.text}" by ${p.name} (${p.reasonText}), ${p.room}`);
87
+ }
88
+ return out;
89
+ }
@@ -218,7 +218,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
218
218
  // Workers AI (0.17.0): bound only when a server's AI guides think with it.
219
219
  if (needsWorkersAi(root, w, cf.d1)) {
220
220
  writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true }));
221
- step('Workers AI bound (AI): a server\'s AI guides think with it, within the day\'s budget (free allocation: 10,000 neurons a day)');
221
+ step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
222
222
  }
223
223
 
224
224
  const started = Date.now();
@@ -271,6 +271,9 @@ export function needsWorkersAi(root, w, db) {
271
271
  try {
272
272
  const cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8'));
273
273
  if ((cat.games ?? []).some((g) => (g.servers ?? []).some((s) => s.brain === 'workers-ai'))) return true;
274
+ // Room chat (0.23.0): a game whose players may type has its messages reviewed by the studio's own Workers AI
275
+ // (Cloudflare's Clef decision model, within a day's budget of the free allocation) unless its game.json says no.
276
+ if ((cat.games ?? []).some((g) => g.chat !== false && (g.chat?.mode ?? 'text') === 'text' && g.chat?.ai !== false)) return true;
274
277
  } catch { /* not built */ }
275
278
  const r = w(['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"]);
276
279
  if (r.code !== 0) return false;
@@ -343,7 +346,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
343
346
  try {
344
347
  const json = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''));
345
348
  if (!json.ai) { json.ai = { binding: 'AI' }; writeFileSync(configPath(root), `${JSON.stringify(json, null, 2)}\n`); }
346
- step('Workers AI bound (AI) for the AI guides');
349
+ step('Workers AI bound (AI): the AI guides and the review of typed room chat');
347
350
  } catch { step('could not add the Workers AI binding to wrangler.jsonc: the guides answer from the game\'s script'); }
348
351
  }
349
352
  const started = Date.now();
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
+ }