@homie-rocks/studio 0.51.1 → 0.51.3

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 (52) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +15 -4
  3. package/app-starters/gathering/app.json +2 -44
  4. package/app-starters/gathering/index.html +1 -1
  5. package/app-starters/gathering/src/view.ts +6 -19
  6. package/apps/APPS.md +55 -1
  7. package/bin/homie-studio.mjs +12 -8
  8. package/guides/app/SKILL.md +22 -2
  9. package/guides/office/SKILL.md +9 -0
  10. package/guides/shop/SKILL.md +8 -0
  11. package/guides/studio-setup/SKILL.md +9 -1
  12. package/lib/app-needs.mjs +17 -2
  13. package/lib/app-preset-history.json +840 -0
  14. package/lib/app-ready.mjs +1 -0
  15. package/lib/art-cli.mjs +2 -2
  16. package/lib/art-tools.mjs +2 -2
  17. package/lib/build.mjs +4 -2
  18. package/lib/check.mjs +2 -0
  19. package/lib/cloudflare.mjs +19 -10
  20. package/lib/decisions.mjs +9 -5
  21. package/lib/dev.mjs +4 -1
  22. package/lib/directory.mjs +22 -5
  23. package/lib/giving-preset.mjs +9 -0
  24. package/lib/mcp-tools.mjs +1 -1
  25. package/lib/office.mjs +2 -1
  26. package/lib/scaffold.mjs +7 -4
  27. package/lib/shop-links.mjs +4 -2
  28. package/lib/shop-local.mjs +31 -0
  29. package/lib/shop.mjs +2 -1
  30. package/lib/style-presets.mjs +1 -0
  31. package/lib/template-history.json +92 -2
  32. package/lib/upgrade-app-presets.mjs +49 -0
  33. package/lib/upgrade.mjs +2 -1
  34. package/mcp/ui/build.js +3 -2
  35. package/package.json +1 -1
  36. package/shop/SHOP.md +6 -0
  37. package/site/SITE.md +13 -0
  38. package/tools/TOOLS.md +9 -0
  39. package/worker/app-records.mjs +3 -3
  40. package/worker/arrival.mjs +7 -5
  41. package/worker/build-id.mjs +1 -1
  42. package/worker/directory-choice.mjs +9 -0
  43. package/worker/index.mjs +25 -9
  44. package/worker/mcp.mjs +2 -2
  45. package/worker/pages.mjs +12 -5
  46. package/worker/records-page.mjs +1 -1
  47. package/worker/shop-page.mjs +2 -2
  48. package/worker/shop-subscriptions.mjs +42 -0
  49. package/worker/signin-target.mjs +8 -0
  50. package/worker/site.mjs +11 -7
  51. package/worker/stats-page.mjs +6 -3
  52. package/worker/version.mjs +1 -1
package/lib/app-ready.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  /** One arrival barrier for baseline proof, needs reloads and rebuilt frames. */
2
2
  export async function appReady(surface, {timeout=60000}={}) {
3
+ await surface.page.bringToFront?.();
3
4
  const end=Date.now()+timeout;
4
5
  let error;
5
6
  do {
package/lib/art-cli.mjs CHANGED
@@ -151,13 +151,13 @@ export async function styleCommand(root, sub, positional, flags, { log = () => {
151
151
  } else if (verb === 'set') {
152
152
  if (!args[1] || args[2] === undefined) return { ok: false, command: 'style set', why: `usage: homie-studio style set <id> <decision> <value> (decisions: ${CATALOGUE_IDS.slice(0, 10).join(', ')}, …)` };
153
153
  r = setDecision(root, id, args[1], args.slice(2).join(' '), { by: flags.get('by') === 'person' ? 'person' : 'ai', why: flags.get('why') ?? null, words: flags.get('words') ?? null, unlock: flags.has('unlock'), reason: flags.get('reason') ?? null, confirm: flags.has('confirm'), manifest: manifest() });
154
- } else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest() });
154
+ } else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest(), by: flags.get('by') === 'person' ? 'person' : 'ai' });
155
155
  else if (verb === 'lock') r = lockDecision(root, id, args[1] ?? 'style', { by: 'person', words: flags.get('words') ?? null });
156
156
  else if (verb === 'unlock') r = unlockDecision(root, id, args[1], { reason: flags.get('reason') ?? null, by: 'person' });
157
157
  else if (verb === 'board') { const { styleBoard } = await import('./style-board.mjs'); r = await styleBoard(root, id, { prompt: String(flags.get('prompt') ?? ''), log, library: !flags.has('no-library') }); }
158
158
  else if (verb === 'pick') {
159
159
  const mixes = Object.fromEntries(list(flags.get('mix')).map((x) => x.split('=')).filter(([k, v]) => k && v));
160
- r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest() });
160
+ r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest(), by: flags.get('by') === 'person' ? 'person' : 'ai' });
161
161
  } else if (verb === 'mood') { const { recordMood } = await import('./style-board.mjs'); r = await recordMood(root, id, args[1], resolve(String(flags.get('image') ?? '')), { usd: flags.get('usd') ?? null, receipt: flags.get('receipt') ?? null, model: flags.get('model') ?? null }); }
162
162
  else if (verb === 'golden') {
163
163
  if (args[1] === 'add') { const { addGolden } = await import('./style-board.mjs'); r = await addGolden(root, id, resolve(String(args[2] ?? flags.get('image') ?? '')), { from: flags.get('from') ?? null }); }
package/lib/art-tools.mjs CHANGED
@@ -223,11 +223,11 @@ export function artToolDefs(ctx, h) {
223
223
  const by = a.by === 'person' ? 'person' : 'ai';
224
224
  let r; let action;
225
225
  try {
226
- if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest }); }
226
+ if (a.pick || a.mix) { action = 'pick'; r = pickDirection(root, game, a.pick ? String(a.pick).toLowerCase() : null, { mixes: a.mix && typeof a.mix === 'object' ? a.mix : {}, manifest, by }); }
227
227
  else if (!a.decision) return fail('name the decision (decision: "style.palette"), or pick a direction (pick: "b")');
228
228
  else if (a.lock) { action = 'lock'; r = lockDecision(root, game, a.lock === true ? a.decision : String(a.lock), { by: 'person', words: a.words ?? (by === 'person' ? 'tapped Lock' : null) }); }
229
229
  else if (a.unlock && a.value === undefined && a.steer === undefined) { action = 'unlock'; r = unlockDecision(root, game, a.decision, { reason: a.reason ?? null, by: 'person' }); }
230
- else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest }); }
230
+ else if (a.steer) { action = 'steer'; r = steerDecision(root, game, a.decision, String(a.steer), { manifest, by }); }
231
231
  else if (a.value !== undefined) { action = 'set'; r = setDecision(root, game, a.decision, a.value, { by, why: null, words: a.words ?? null, unlock: a.unlock === true, reason: a.reason ?? null, confirm: a.confirm === true, manifest }); }
232
232
  else return fail('say what to do: value, steer, lock, unlock, pick or mix');
233
233
  } catch (error) { return fail(error.message); }
package/lib/build.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { directoryUrl } from '../worker/directory-choice.mjs';
2
+ import { domainOrigin } from './studio.mjs';
1
3
  import { appProblems } from '../worker/app-format.mjs';
2
4
  /**
3
5
  * `homie-studio build` — every game in games/ bundled into site/dist, plus the
@@ -707,8 +709,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
707
709
  ...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
708
710
  // The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
709
711
  // HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
710
- directory: /^https:\/\/[a-z0-9.-]+$/i.test(process.env.HOMIE_DIRECTORY ?? '') ? process.env.HOMIE_DIRECTORY
711
- : studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
712
+ directory: directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
713
+ canonical: domainOrigin(studio.cloudflare?.domain) ?? domainOrigin(studio.cloudflare?.url),
712
714
  // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
713
715
  ...(studio.template === true ? { template: true } : {}),
714
716
  // studio.json audience is a studio label; shop policies are optional.
package/lib/check.mjs CHANGED
@@ -387,6 +387,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
387
387
  for(const name of ['wall','phone','phone-two'])await openSurface(name);
388
388
  const textOf = (f) => f.$eval(proof.observe, (e) => e.textContent);
389
389
  const actor = surfaces[1];
390
+ await appReady(actor);
390
391
  await waitInApp(actor,proof.action,f=>f.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action));
391
392
  before = await waitInApp(actor,proof.observe,textOf);
392
393
  await actor.frame.click(proof.action);
@@ -406,6 +407,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
406
407
  report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
407
408
  }
408
409
  for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
410
+ if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
409
411
  const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
410
412
  if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
411
413
  }
@@ -1,3 +1,4 @@
1
+ import { directoryUrl } from '../worker/directory-choice.mjs';
1
2
  import { roomLayout } from '../worker/gate.mjs';
2
3
  import {workerEntry} from './worker-build.mjs';
3
4
  import { seatCount } from '../worker/seats.mjs';
@@ -51,6 +52,11 @@ import { projectsCloudflareEnv } from './projects-env.mjs';
51
52
 
52
53
  const ANSI = /\u001b\[[0-9;]*m/g;
53
54
 
55
+ export function experienceAddresses(root, id, url) {
56
+ const app = listGames(root).find(g => g.id === id)?.kind === 'app';
57
+ return { [app ? 'open' : 'play']: url ? `${url}/${id}/${app ? 'open' : 'play'}` : null, wall: url ? `${url}/${id}/tv` : null };
58
+ }
59
+
54
60
  export function wranglerBin(root) {
55
61
  const local = join(root, 'node_modules', '.bin', 'wrangler');
56
62
  return existsSync(local) ? local : null;
@@ -65,6 +71,8 @@ export function runner(root, env) {
65
71
  const bin = wranglerBin(root);
66
72
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
67
73
  return (args, { cwd = workerDir(root), input } = {}) => {
74
+ const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
75
+ if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
68
76
  const res = spawnSync(bin, args, {
69
77
  cwd, env: { ...process.env, ...projectsCloudflareEnv(root), ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
70
78
  input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
@@ -211,11 +219,13 @@ export function deployPlan(root) {
211
219
  ? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
212
220
  : { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
213
221
  ],
214
- cost: 'Account subscription not queried. Cloudflare bills usage under the account’s current plan; estimates are not a billing quote.',
222
+ cost: null,
215
223
  login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
216
224
  address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
217
225
  directory: {
218
- site: studio.homie?.directory || 'https://homie.rocks',
226
+ site: directoryUrl(studio.homie?.directory),
227
+ requested: Boolean(directoryUrl(studio.homie?.directory)),
228
+ choice: 'A deploy claims a listing only when homie.directory is true or a directory URL. Existing claims stay honoured; studios that never asked are not listed.',
219
229
  stores: 'the site\'s address and a claim token, the studio\'s name and slug, the @homie-rocks/studio version, and each game\'s id, name, blurb and Play/page links. Never code, media, keys or accounts.',
220
230
  },
221
231
  never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
@@ -302,8 +312,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
302
312
  const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
303
313
  announced.push(
304
314
  `First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
305
- 'Account subscription not queried; usage is billed under the account’s current Cloudflare plan.',
306
- `The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
315
+ deployPlan(root).directory.choice,
307
316
  );
308
317
  for (const line of announced) log(line);
309
318
  }
@@ -425,7 +434,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
425
434
  if (!synced.ok) return refuse('Site deployed, but open part Checkouts could not be closed. Retry deploy: ' + (synced.message ?? synced.why ?? 'no answer'));
426
435
  step(`closed ${synced.expired} obsolete part Checkouts`);
427
436
  }
428
- const directory = homie || studio.homie?.directory || 'https://homie.rocks';
437
+ const directory = directoryUrl(homie ?? studio.homie?.directory);
429
438
  recordDeploy(root, builtNow);
430
439
  let claim = null;
431
440
  if (url) {
@@ -441,10 +450,10 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
441
450
  } else if (read.state === 'unreachable') {
442
451
  // This computer could not ask (its own name lookup, no connection): a network preflight failure, never a
443
452
  // verdict on the site that was just deployed.
444
- const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'} The site claims itself when the directory first reads it.`;
453
+ const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'} Listing follows the studio’s directory choice.`;
445
454
  notes.push(said);
446
455
  step(said, { preflight: read.preflight ?? 'network' });
447
- } else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
456
+ } else step(directory ? 'the requested directory claim is not there yet' : 'no new listing requested; existing claims are honoured');
448
457
  }
449
458
 
450
459
  // The deploy before this one came from a toolkit that still had remix, and left games open to be taken whole. No
@@ -454,7 +463,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
454
463
  let reread = null;
455
464
  if (url && delta.withdrawn.length) {
456
465
  const which = delta.withdrawn.join(', ');
457
- const dir = String(directory).replace(/\/+$/, '');
466
+ const dir = directory ?? readLocal(root).listed?.directory;
458
467
  if (listedHere(root, { site: url, directory: dir })) {
459
468
  const again = await publish(root, { homie: dir, site: url, ...(fetchFn ? { fetchFn } : {}) });
460
469
  reread = { ok: again.ok, games: delta.withdrawn, ...(again.ok ? {} : { why: again.why }), ...(again.publishes ? { publishes: again.publishes } : {}) };
@@ -474,7 +483,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
474
483
  return {
475
484
  ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
476
485
  ...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
477
- games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
486
+ games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
478
487
  ...(delta.removed.length ? { removed: delta.removed } : {}),
479
488
  ...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
480
489
  ...(zone ? { zone: zoneView(zone) } : {}),
@@ -643,7 +652,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
643
652
  ok: true, command: 'deploy', ci: true, url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
644
653
  commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
645
654
  // A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
646
- games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
655
+ games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
647
656
  claim: Boolean(claim),
648
657
  };
649
658
  }
package/lib/decisions.mjs CHANGED
@@ -130,7 +130,7 @@ export const CATALOGUE = Object.freeze([
130
130
  ['style.render', 'style', 'How is the world drawn?', [], (ctx) => {
131
131
  if (ctx.drawn?.render && RENDERS[ctx.drawn.render]) return { value: ctx.drawn.render, label: RENDERS[ctx.drawn.render].label, why: 'what the game draws now (its style.json)', options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
132
132
  const hit = best(RENDERS, ctx);
133
- const key = hit?.[0] ?? 'lowpoly-flat';
133
+ const key = hit?.[0] ?? (ctx.kind === 'app' ? 'illustrated' : 'lowpoly-flat');
134
134
  return { value: key, label: RENDERS[key].label, why: hit ? `your words ${said(ctx, RENDERS[key].words)}: ${RENDERS[key].note}` : `nothing named a look: ${RENDERS[key].note}`, options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
135
135
  }],
136
136
  ['style.palette', 'style', 'Which colours, and what is each one for?', ['style.render', 'style.light'], (ctx) => {
@@ -141,6 +141,10 @@ export const CATALOGUE = Object.freeze([
141
141
  v.ramp = rampFrom(v);
142
142
  return { value: v, label: 'The codex\'s own colours', why: 'the Game Codex already names them', options: paletteOptions(ctx) };
143
143
  }
144
+ if(ctx.kind === 'app' && !best(PALETTES,ctx)) {
145
+ const value=paletteValue('custom',{bg:ctx.theme.bg??'#f6f3ec',ink:ctx.theme.fg??'#243d36',accent:ctx.theme.accent??'#226b59',accent2:'#b87935'});
146
+ value.ramp=rampFrom(value);return {value,label:'The place’s colours',why:'a readable starting palette for the app; refine it to the organisation',options:paletteOptions(ctx)};
147
+ }
144
148
  const hit = best(PALETTES, ctx);
145
149
  const key = hit?.[0] ?? ({ gather: 'meadow-morning', brawl: 'candy-pop', race: 'desert-noon', rpg: 'heroic-dawn', shooter: 'space-ink', platformer: 'meadow-morning', party: 'candy-pop', puzzle: 'candy-pop', sports: 'meadow-morning' })[ctx.genre] ?? 'meadow-morning';
146
150
  return { value: paletteValue(key), label: paletteLabel({ name: key }), why: hit ? `your words ${said(ctx, PALETTES[key].words)}` : `a ${ctx.genre} game reads best in bright, separate colours`, options: paletteOptions(ctx) };
@@ -479,7 +483,7 @@ export function setDecision(root, id, did, input, { by = 'ai', why = null, words
479
483
  * Steer: the person's words, applied where the vocabulary knows them ("warmer", "less saturated", "golden hour",
480
484
  * "closer", "chunkier", "add outlines", a hex colour), and always recorded. The decision becomes steered.
481
485
  */
482
- export function steerDecision(root, id, did, words, { manifest = null } = {}) {
486
+ export function steerDecision(root, id, did, words, { manifest = null, by = 'person' } = {}) {
483
487
  const doc = readDecisions(root, id);
484
488
  if (!doc) throw new Error(`games/${id} has no decisions yet: homie-studio style init ${id}`);
485
489
  const rec = doc.decisions[did];
@@ -489,7 +493,7 @@ export function steerDecision(root, id, did, words, { manifest = null } = {}) {
489
493
  if (rec.state === 'locked') return { ok: false, command: 'style steer', id, decision: did, locked: true, why: `${did} is locked: unlock it (with a reason, after the blast radius) before steering it` };
490
494
  const { value, applied } = applySteer(did, rec.value, w, doc);
491
495
  const steer = [...(rec.steer ?? []), w].slice(-10);
492
- change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : 'steered', why: `steered by the person: "${w}"` }, { by: 'person', words: w });
496
+ change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : by === 'person' ? 'steered' : rec.state, why: `refined by ${by === 'person' ? 'the person' : 'the AI'}: "${w}"` }, { by, words: w });
493
497
  if (did === 'style.palette' && doc.decisions['style.light'] && doc.decisions['style.light'].state !== 'locked') {
494
498
  const L = doc.decisions['style.light'];
495
499
  L.value = { ...L.value, ...lightColours(LIGHTS[L.value.time] ?? LIGHTS.morning, value) };
@@ -762,7 +766,7 @@ export function directionsFor(root, id, { prompt = '' } = {}) {
762
766
  }
763
767
 
764
768
  /** Pick a direction (all its style values) or Mix rows from several: { 'style.palette': 'b', … }. By the person. */
765
- export function pickDirection(root, id, choice, { mixes = {}, manifest = null } = {}) {
769
+ export function pickDirection(root, id, choice, { mixes = {}, manifest = null, by: actor = 'person' } = {}) {
766
770
  const doc = readDecisions(root, id);
767
771
  if (!doc?.board?.directions?.length) throw new Error(`games/${id} has no style board yet: homie-studio style board ${id}`);
768
772
  const by = Object.fromEntries(doc.board.directions.map((d) => [d.id, d]));
@@ -778,7 +782,7 @@ export function pickDirection(root, id, choice, { mixes = {}, manifest = null }
778
782
  const value = from.values[did];
779
783
  if (value === undefined || JSON.stringify(rec.value) === JSON.stringify(value)) continue;
780
784
  if (rec.state === 'locked') { refused.push(did); continue; }
781
- change(rec, { value, label: labelOf(did, value), why: `the person picked direction ${from.id.toUpperCase()} on the style board`, state: rec.state === 'pinned' ? 'pinned' : 'steered' }, { by: 'person' });
785
+ change(rec, { value, label: labelOf(did, value), why: `${actor === 'person' ? 'the person' : 'the AI'} picked direction ${from.id.toUpperCase()} on the style board`, state: rec.state === 'pinned' ? 'pinned' : actor === 'person' ? 'steered' : rec.state }, { by: actor });
782
786
  changed.push(did);
783
787
  }
784
788
  doc.board.chosen = choice ?? doc.board.chosen ?? null;
package/lib/dev.mjs CHANGED
@@ -197,7 +197,7 @@ export function devConfig(root, remoteAi) {
197
197
  const routed = ROUTE_KEYS.some((k) => k in json);
198
198
  const aiNote = remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null;
199
199
  const entry=workerEntry(root);
200
- if (!entry && Boolean(json.ai) === remoteAi && !routed) return { args: [], note: aiNote, stripped: [] };
200
+ // Always retain the exact local binding configuration for CLI commands after a deploy.
201
201
  const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
202
202
  delete json.$schema;
203
203
  json.main = entry ?? abs(json.main);
@@ -209,6 +209,9 @@ export function devConfig(root, remoteAi) {
209
209
  const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
210
210
  mkdirSync(dirname(copy), { recursive: true });
211
211
  writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
212
+ const localSecrets=join(root,'.dev.vars'), generatedSecrets=join(dirname(copy),'.dev.vars');
213
+ if(existsSync(localSecrets))writeFileSync(generatedSecrets,readFileSync(localSecrets),{mode:0o600});
214
+ else rmSync(generatedSecrets,{force:true});
212
215
  const notes = [
213
216
  ...(routed ? [`Local dev leaves out the production route${stripped.length === 1 ? '' : 's'} in wrangler.jsonc (${stripped.join(', ') || 'route keys'}): rooms here use this computer's own address, never the live site's. wrangler.jsonc is unchanged.`] : []),
214
217
  ...(aiChanged ? [remoteAi ? aiNote : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.'] : aiNote ? [aiNote] : []),
package/lib/directory.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  * claim the directory gave it (`homie-studio deploy` stores it in D1), so
5
5
  * nobody can list games under someone else's site.
6
6
  */
7
+ import { directoryUrl } from '../worker/directory-choice.mjs';
7
8
  import { request } from './net.mjs';
8
- import { listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
9
+ import { domainOrigin, listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
9
10
  import { licenceProblems, readManifest } from './asset-manifest.mjs';
10
11
  import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
11
12
 
@@ -103,8 +104,8 @@ function licenceRefusals(root) {
103
104
  */
104
105
  export async function publishBefore(root, { homie, site, log = () => {}, fetchFn } = {}) {
105
106
  const studio = readStudio(root);
106
- const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
107
- const url = site || siteUrl(root, studio);
107
+ const directory = directoryUrl(homie ?? studio.homie?.directory) ?? 'https://homie.rocks';
108
+ const url = domainOrigin(studio.cloudflare?.domain) || site || siteUrl(root, studio);
108
109
  const refused = licenceRefusals(root);
109
110
  const parts = partsForListing(root);
110
111
  const local = publishesSoFar(root);
@@ -140,8 +141,8 @@ export async function publishBefore(root, { homie, site, log = () => {}, fetchFn
140
141
 
141
142
  export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
142
143
  const studio = readStudio(root);
143
- const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
144
- const url = site || siteUrl(root, studio);
144
+ const directory = directoryUrl(homie ?? studio.homie?.directory) ?? 'https://homie.rocks';
145
+ const url = domainOrigin(studio.cloudflare?.domain) || site || siteUrl(root, studio);
145
146
  // Licences first: a public game may ship only assets with a licence record that allows it.
146
147
  const refused = licenceRefusals(root);
147
148
  if (refused.length) return { ok: false, command: 'publish', refused, why: `not listed: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'}:\n${refused.map((p) => ` ${p.game}/${p.asset}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).join('\n')}\nhomie-studio assets check <id> says the same; fix them, deploy, then publish again.` };
@@ -149,6 +150,22 @@ export async function publish(root, { homie, site, log = () => {}, fetchFn } = {
149
150
  // Parts from other studios in the games about to be listed: their licences and credits, and plainly when two
150
151
  // cannot be combined, in the same lines the deploy plan gave (lib/parts-build.mjs). Said before anything is sent.
151
152
  // It does not stop the listing: the person decides, having been told.
153
+ // An explicit publish is itself the choice to list. Seed the same D1 claim the
154
+ // Worker uses, so a studio deployed without opt-in does not need another deploy.
155
+ if (!directoryUrl(studio.homie?.directory)) {
156
+ let origin;
157
+ try { origin = new URL(url).origin; } catch { return {ok:false,command:'publish',why:'Invalid studio address'}; }
158
+ const claimed = await request(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {}, {timeout:15_000,...(fetchFn?{fetchFn}:{})});
159
+ const token = claimed.body?.claim;
160
+ if (!claimed.ok || !/^[a-f0-9]{16,128}$/.test(token ?? '')) return {ok:false,command:'publish',why:claimed.why ?? 'The directory did not return a claim; nothing was listed.'};
161
+ const {runner} = await import('./cloudflare.mjs');
162
+ const {loopback} = await import('./stats.mjs');
163
+ const local = loopback(origin);
164
+ const w = runner(root, !local && studio.cloudflare?.accountId ? {CLOUDFLARE_ACCOUNT_ID:studio.cloudflare.accountId} : {});
165
+ const saved = w(['d1','execute',studio.cloudflare.d1,local?'--local':'--remote','--command',`INSERT OR REPLACE INTO meta (key,value) VALUES ('homie_claim:${origin.replaceAll("'", "''")}','${token}');`]);
166
+ if(saved.code!==0)return {ok:false,command:'publish',why:'Could not store the directory claim in the studio database: '+saved.out};
167
+ writeStudio(root,{...studio,homie:{...studio.homie,directory}});
168
+ }
152
169
  const parts = partsForListing(root);
153
170
  for (const line of partsPlanLines(parts, { at: 'publish' })) log(line);
154
171
  const before = publishesSoFar(root);
@@ -0,0 +1,9 @@
1
+ /** Defaults shared by shop init and the upgrade of an untouched earlier preset. */
2
+ export function givingItems(amounts = [1000, 2000, 2500, 5000]) {
3
+ return amounts.flatMap(amount => [false, true].map(monthly => ({
4
+ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation',
5
+ name: monthly ? 'Monthly gift' : 'One-time gift', price: amount,
6
+ ...(monthly ? { interval: 'month' } : {}), gives: [],
7
+ blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.',
8
+ })));
9
+ }
package/lib/mcp-tools.mjs CHANGED
@@ -1171,7 +1171,7 @@ export function toolDefs(ctx, avail = {}) {
1171
1171
  if (!r.ended) return stillRunning(r.job, 'The deploy plan');
1172
1172
  if (r.job.code !== 0) return fail(whyOf(r.job));
1173
1173
  const p = r.result;
1174
- return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1174
+ return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), ...(p.cost ? [`Cost: ${p.cost}`] : []), `Sign-in: ${p.login}`, ...(p.directory?.choice ? [p.directory.choice] : []), ...(p.directory?.site ? [`The directory stores: ${p.directory.stores}`] : []), ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1175
1175
  }
1176
1176
  const studio = readStudio(root);
1177
1177
  return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
package/lib/office.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { signinTarget } from '../worker/signin-target.mjs';
1
2
  /**
2
3
  * `homie-studio office` — the studio's back office from the studio folder (worker/office.mjs says what it is).
3
4
  *
@@ -83,7 +84,7 @@ export async function officeShow(root, { url } = {}) {
83
84
  }
84
85
 
85
86
  export function officeLink(root, { url, to } = {}) {
86
- const target = to && /^\/[A-Za-z0-9_/-]{1,80}$/.test(String(to)) ? String(to) : '/_studio/office';
87
+ const target = signinTarget(to, '/_studio/office');
87
88
  const r = signinTo(root, url, target);
88
89
  if (!r.ok) return { ok: false, command: 'office link', why: r.why };
89
90
  return {
package/lib/scaffold.mjs CHANGED
@@ -245,8 +245,8 @@ studio's pinned copy, never a registry lookup of the bare name.
245
245
  - **Workers Builds** (Cloudflare's own CI, set up by the "Deploy to Cloudflare" button or in the dashboard): on
246
246
  every push to \`main\` it runs \`npm run build\` and \`npm run deploy\`, which in Workers Builds only applies
247
247
  the D1 migrations and deploys (it never creates or refuses anything); on every other branch it runs
248
- \`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. The live site claims itself
249
- in the homie.rocks directory the first time it is read, so nothing is stored by hand.
248
+ \`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. A live site requests a directory claim only with
249
+ studio.json \`homie.directory: true\` (homie.rocks) or a directory URL. Existing claims stay honoured.
250
250
  - \`npx --no-install homie-studio storage add\` — only when the studio has songs or videos: an R2
251
251
  bucket on the studio's own Cloudflare account. Cloudflare asks for a payment method on the account
252
252
  before R2 works, so this is a separate step the person agrees to; nothing else needs it. R2 has no
@@ -1085,14 +1085,17 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
1085
1085
  if (real) {
1086
1086
  if (!statSync(real).isDirectory()) throw new Error(`${real} is a file. Nothing was written.`);
1087
1087
  const entries = readdirSync(real).filter((n) => n !== '.DS_Store' && n !== '.git');
1088
- if (entries.length) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
1088
+ let resume = false;
1089
+ try { const existing = JSON.parse(readFileSync(join(real, 'studio.json'), 'utf8')); resume = existing.name === String(name).trim() && existing.slug === slugify(askedSlug || name) && Boolean(existing.homie?.studio); } catch {}
1090
+ if (entries.length && !resume) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
1089
1091
  }
1090
1092
  const slug = slugify(askedSlug || name);
1091
- const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks', template, kind });
1093
+ const files = studioFiles({ name: String(name).trim(), slug, homie: homie ?? null, template, kind });
1092
1094
  mkdirSync(dir, { recursive: true });
1093
1095
  const wrote = [];
1094
1096
  for (const [rel, content] of Object.entries(files)) {
1095
1097
  const path = join(dir, rel);
1098
+ if (existsSync(path)) continue;
1096
1099
  mkdirSync(dirname(path), { recursive: true });
1097
1100
  writeFileSync(path, content);
1098
1101
  wrote.push(rel);
@@ -6,7 +6,7 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
6
6
  import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
7
7
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
8
  const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
9
- export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [] }) {
9
+ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [], local = false }) {
10
10
  const list = async (path, params = {}) => {
11
11
  const all = []; let cursor;
12
12
  do {
@@ -19,7 +19,8 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
19
19
  } while (cursor);
20
20
  return all;
21
21
  };
22
- const secrets = await w(['secret', 'list']);
22
+ if(local && mode!=='test')throw new Error('Local checkout requires Stripe test mode');
23
+ const secrets = local ? {code:0,stdout:'[]'} : await w(['secret', 'list']);
23
24
  if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
24
25
  const names = JSON.parse(secrets.stdout ?? secrets.out);
25
26
  const has = (name) => names.some((s) => s.name === name);
@@ -134,6 +135,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
134
135
  portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
135
136
  config.portal=portal.login_page?.url;
136
137
  }
138
+ if(local)return {ok:true,command:'shop connect',local:true,mode,config};
137
139
  const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
138
140
  const purchaseSecrets = {};
139
141
  if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
@@ -0,0 +1,31 @@
1
+ /** Real Stripe test links, delivered by the owner's Stripe CLI listener. */
2
+ import {readFileSync,writeFileSync,existsSync} from 'node:fs';
3
+ import {join} from 'node:path';
4
+ import {createHash} from 'node:crypto';
5
+ import {stripeProcess} from './stripe-connect.mjs';
6
+ import {syncPaymentLinks} from './shop-links.mjs';
7
+ import {checkShop} from '../worker/shop-rules.mjs';
8
+ import {formEncode,redactStripe} from '../worker/stripe.mjs';
9
+ export async function shopConnectLocal(root,{url,profile,exec=stripeProcess}={}) {
10
+ const origin=new URL(url??'http://localhost:8787');
11
+ if(origin.protocol!=='http:' || !/^(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/.test(origin.hostname))throw new Error('Local Stripe needs an HTTP localhost or Wi-Fi address');
12
+ if(!profile)throw new Error('Name the existing Stripe sandbox CLI profile with --profile');
13
+ const checked=checkShop(JSON.parse(readFileSync(join(root,'shop.json'),'utf8')));
14
+ if(!checked.ok)throw new Error('Fix shop.json before connecting Stripe');
15
+ const api=async(method,path,params={},idempotency)=>{
16
+ const args=['--project-name',profile,method.toLowerCase(),path];
17
+ for(const [k,v] of formEncode(params))args.push('-d',`${k}=${v}`);
18
+ if(idempotency)args.push('--idempotency',idempotency);
19
+ const r=await exec(args,{cwd:root});let body;try{body=JSON.parse(r.stdout);}catch{}
20
+ if(r.code!==0||body?.error||!body)throw new Error('Stripe test request failed: '+redactStripe(body?.error?.message??r.stderr??'no response').slice(0,500));
21
+ if(body.livemode===true||body.data?.some(o=>o.livemode===true))throw new Error('Local checkout refuses live Stripe objects');
22
+ return body;
23
+ };
24
+ const slug='local-'+createHash('sha256').update(root).digest('hex').slice(0,16);
25
+ const r=await syncPaymentLinks({api,shop:checked.shop,slug,site:origin.origin,mode:'test',local:true});
26
+ const file=join(root,'.dev.vars');let vars=existsSync(file)?readFileSync(file,'utf8'):'';
27
+ vars=vars.replace(/^STRIPE_(?:SHOP_LINKS|CHECKOUT_MODE)=.*\n?/gm,'');
28
+ vars+=`\nSTRIPE_CHECKOUT_MODE=links\nSTRIPE_SHOP_LINKS=base64:${Buffer.from(JSON.stringify(r.config)).toString('base64')}\n`;
29
+ writeFileSync(file,vars,{mode:0o600});
30
+ return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`stripe --project-name ${profile} listen --all-snapshot --forward-to http://localhost:${origin.port||80}/api/shop/hook`,'Put the listener signing secret in root .dev.vars as STRIPE_WEBHOOK_SECRET, then start/restart homie-studio dev --lan. Keep the listener running.','Open the same Wi-Fi address on phones; complete Stripe test checkout and verify the paid order and app event.']};
31
+ }
package/lib/shop.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {givingItems} from './giving-preset.mjs';
1
2
  import { paidReleases } from './parts-upload.mjs';
2
3
  /**
3
4
  * `homie-studio shop` — a studio's shop from the studio folder (@homie-rocks/studio 0.24.0; worker/shop.mjs has the
@@ -115,7 +116,7 @@ export function shopInit(root, { supporter = false, giving = false, currency = n
115
116
  const shop = {
116
117
  ...(giving ? { giving: { receiptText: '' } } : {}),
117
118
  till: managed ? 'stripe-managed' : 'stripe', currency: String(currency ?? studio.currency ?? 'usd').toLowerCase(),
118
- items: giving ? [1000, 2000, 2500, 5000].flatMap(amount => [false, true].map(monthly => ({ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation', name: monthly ? 'Monthly gift' : 'One-time gift', price: amount, ...(monthly ? { interval: 'month' } : {}), gives: [], blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.' }))) : supporter ? [supporterItem({ price: Number(price) })] : [],
119
+ items: giving ? givingItems() : supporter ? [supporterItem({ price: Number(price) })] : [],
119
120
  };
120
121
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
121
122
  if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
@@ -6,6 +6,7 @@
6
6
 
7
7
  /** How the world is drawn. `family`: the starter library family that fits; `materials`: the material model. */
8
8
  export const RENDERS = Object.freeze({
9
+ illustrated: { label: 'Illustrated place', words: /\b(illustrated|illustration|pictorial)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fraunces', body: 'Inter' }, vfx: 'soft', note: 'readable objects, clear typography and purposeful motion for the work of this place' },
9
10
  'lowpoly-flat': { label: 'Flat low-poly', words: /\b(low[- ]?poly|cozy|cosy|cute|toy|minimal|clean|simple|wholesome|chill)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fredoka', body: 'Nunito' }, vfx: 'soft', note: 'faceted shapes in flat palette colours: the cheapest look on a phone, the most coherent, and the richest free library' },
10
11
  toon: { label: 'Toon / cel', words: /\b(toon|cel|cartoon|anime|comic|neon|cyber|synth|arcade)\b/i, materials: 'toon', outline: true, family: 'kaykit', fonts: { display: 'Lilita One', body: 'Nunito' }, vfx: 'toon', note: 'banded light and ink outlines: reads at any size, loud on a TV' },
11
12
  painted: { label: 'Stylised hand-painted', words: /\b(painterly|painted|storybook|watercolou?r|ghibli-like|fairy ?tale|whimsical|hand[- ]painted)\b/i, materials: 'hand-painted', outline: false, family: 'kaykit', fonts: { display: 'Fraunces', body: 'Lora' }, vfx: 'soft', note: 'soft painted albedo with the light baked gently in: warm and storybook' },
@@ -3553,7 +3553,10 @@
3553
3553
  "## Version-matched authoring guides": "f6261a0e5801b96b",
3554
3554
  "## Layout": "f3a280b8c96cd068",
3555
3555
  "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3556
- "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3556
+ "## Commands (all through the pinned CLI in node_modules)": [
3557
+ "5222e08ca55e326e",
3558
+ "c20d90de21cde0c7"
3559
+ ],
3557
3560
  "## The site": "0cb8dd9a6f8a0096",
3558
3561
  "## Making games": "bb715272fd1a615b",
3559
3562
  "## The Game Codex and progress": "0738ef719cef3a4c",
@@ -3571,7 +3574,94 @@
3571
3574
  "Homie Arcade": {
3572
3575
  "sections": {
3573
3576
  "AGENTS.md": {
3574
- "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3577
+ "## Commands (all through the pinned CLI in node_modules)": [
3578
+ "611b437f5b3303f0",
3579
+ "8364e05cebefcba4"
3580
+ ]
3581
+ }
3582
+ }
3583
+ }
3584
+ }
3585
+ },
3586
+ "0.51.2": {
3587
+ "files": {
3588
+ "CLAUDE.md": "ddf2b2a0376202f6",
3589
+ "README.md": "684b3cca8001673c",
3590
+ "HANDOFF.md": "29337e2ace95b2e7",
3591
+ "games/README.md": "5ad32fe80c61f117",
3592
+ "music/README.md": "a3dc042dadd39df8",
3593
+ "videos/README.md": "16babc3437d5545b",
3594
+ "posts/README.md": "37841aae476bd540",
3595
+ "site/README.md": "1dbdf22c03a1e505",
3596
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3597
+ },
3598
+ "sections": {
3599
+ "AGENTS.md": {
3600
+ "(intro)": "394cbbe934e80722",
3601
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3602
+ "## Layout": "f3a280b8c96cd068",
3603
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3604
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3605
+ "## The site": "0cb8dd9a6f8a0096",
3606
+ "## Making games": "bb715272fd1a615b",
3607
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3608
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3609
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3610
+ "## Servers and AI seats": "1ccfd549d2369a60",
3611
+ "## Selling things (the shop)": "43a3556be93d1235",
3612
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3613
+ "## Rules": "b2234d80f6cbe71d",
3614
+ "## Telling Homie": "80b46fdd9e578db8",
3615
+ "## Beta": "9d02638207a26e73"
3616
+ }
3617
+ },
3618
+ "named": {
3619
+ "Homie Arcade": {
3620
+ "sections": {
3621
+ "AGENTS.md": {
3622
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3623
+ }
3624
+ }
3625
+ }
3626
+ }
3627
+ },
3628
+ "0.51.3": {
3629
+ "files": {
3630
+ "CLAUDE.md": "ddf2b2a0376202f6",
3631
+ "README.md": "684b3cca8001673c",
3632
+ "HANDOFF.md": "29337e2ace95b2e7",
3633
+ "games/README.md": "5ad32fe80c61f117",
3634
+ "music/README.md": "a3dc042dadd39df8",
3635
+ "videos/README.md": "16babc3437d5545b",
3636
+ "posts/README.md": "37841aae476bd540",
3637
+ "site/README.md": "1dbdf22c03a1e505",
3638
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3639
+ },
3640
+ "sections": {
3641
+ "AGENTS.md": {
3642
+ "(intro)": "394cbbe934e80722",
3643
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3644
+ "## Layout": "f3a280b8c96cd068",
3645
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3646
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3647
+ "## The site": "0cb8dd9a6f8a0096",
3648
+ "## Making games": "bb715272fd1a615b",
3649
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3650
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3651
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3652
+ "## Servers and AI seats": "1ccfd549d2369a60",
3653
+ "## Selling things (the shop)": "43a3556be93d1235",
3654
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3655
+ "## Rules": "b2234d80f6cbe71d",
3656
+ "## Telling Homie": "80b46fdd9e578db8",
3657
+ "## Beta": "9d02638207a26e73"
3658
+ }
3659
+ },
3660
+ "named": {
3661
+ "Homie Arcade": {
3662
+ "sections": {
3663
+ "AGENTS.md": {
3664
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3575
3665
  }
3576
3666
  }
3577
3667
  }