@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -265,42 +265,48 @@ ${THEME_TOKENS_CSS}
265
265
  @media (prefers-color-scheme: dark) { :root { --fly: #D9A94B; } }
266
266
  :root[data-theme="dark"] { --fly: #D9A94B; }
267
267
  :root[data-theme="light"] { --fly: #A6791F; }
268
- /* The dashboard chrome (Part C.4.2): a beveled control-panel palette
269
- WRAPPING the dusty-window board scene above, never touching it — every
270
- rule below this block styles .side/.controls-row/.tuning/.status only,
271
- all of which are hidden in ?preview=1 mode alongside .side itself, so
272
- none of this bleeds into the embedded home-page hero. --chrome-face is
273
- the panel's own warm plastic face; --chrome-edge-hi/-lo are its raised-
274
- bevel light/shadow edges; --chrome-brass is the console's metal accent
275
- (borders, active/hover states); --chrome-well/-well-ink are the dark
276
- "LCD" readout a real panel's numeric window would use, deliberately
277
- near-black in both themes (a lit readout reads dark-with-a-glow whether
278
- the room around it is light or dark). --chrome-shadow-raised/-inset
279
- compose the two bevel directions once so every panel/button/well below
280
- just references one of them, never re-deriving the edge colors. */
268
+ /* The console chrome, in this page's own gothic-whimsical register (a
269
+ Charles Addams cartoon: india-ink line work over aged paper, spooky-cute
270
+ rather than scary) WRAPPING the window-corner board scene above, never
271
+ touching it every rule below this block styles
272
+ .side/.controls-row/.tuning/.status only, all of which are hidden in
273
+ ?preview=1 mode alongside .side itself, so none of this bleeds into the
274
+ embedded home-page hero. --chrome-face is the panel's aged-ivory (night
275
+ slate in dark) face, textured by --chrome-hatch, a two-direction ink
276
+ cross-hatch; --chrome-edge-hi/-lo are its raised-bevel light/shadow
277
+ edges; --chrome-accent is the ink-plum accent (borders, active/hover
278
+ states); --chrome-well/-well-ink are the near-black readout well with
279
+ bone-pale numerals, deliberately dark in both themes (a lit readout
280
+ reads dark-with-a-glow whether the room around it is light or dark).
281
+ --chrome-shadow-raised/-inset compose the two bevel directions once so
282
+ every panel/button/well below just references one of them. */
281
283
  :root {
282
- --chrome-face: #DCD3B8; --chrome-face-hi: #EFE8CC; --chrome-edge-hi: #FBF7E6; --chrome-edge-lo: #8B7F5C;
283
- --chrome-brass: #8A6A26; --chrome-brass-soft: rgba(138, 106, 38, .16);
284
- --chrome-well: #201A10; --chrome-well-ink: #E8C876;
284
+ --chrome-face: #E7E1D3; --chrome-face-hi: #F2EDE0; --chrome-edge-hi: #FAF6EA; --chrome-edge-lo: #6E6A5E;
285
+ --chrome-accent: #5C4B66; --chrome-accent-soft: rgba(92, 75, 102, .14);
286
+ --chrome-well: #1A171C; --chrome-well-ink: #E4DDCB;
287
+ --chrome-hatch: repeating-linear-gradient(55deg, rgba(28, 23, 32, .05) 0 1px, transparent 1px 6px), repeating-linear-gradient(-35deg, rgba(28, 23, 32, .04) 0 1px, transparent 1px 9px);
285
288
  --chrome-shadow-raised: inset 0 1px 0 var(--chrome-edge-hi), inset 0 -1px 0 var(--chrome-edge-lo), 0 1px 2px rgba(0, 0, 0, .18);
286
289
  --chrome-shadow-inset: inset 0 1px 3px var(--chrome-edge-lo), inset 0 -1px 0 var(--chrome-edge-hi);
287
290
  }
288
291
  @media (prefers-color-scheme: dark) {
289
- :root { --chrome-face: #2B2E23; --chrome-face-hi: #363A29; --chrome-edge-hi: #4C5138; --chrome-edge-lo: #14150D;
290
- --chrome-brass: #D3AE5C; --chrome-brass-soft: rgba(211, 174, 92, .16);
291
- --chrome-well: #0B0904; --chrome-well-ink: #F0D392; }
292
+ :root { --chrome-face: #262230; --chrome-face-hi: #302B3D; --chrome-edge-hi: #453E57; --chrome-edge-lo: #121016;
293
+ --chrome-accent: #A995BD; --chrome-accent-soft: rgba(169, 149, 189, .16);
294
+ --chrome-well: #0C0A10; --chrome-well-ink: #E0D8C6;
295
+ --chrome-hatch: repeating-linear-gradient(55deg, rgba(240, 235, 220, .04) 0 1px, transparent 1px 6px), repeating-linear-gradient(-35deg, rgba(240, 235, 220, .03) 0 1px, transparent 1px 9px); }
292
296
  }
293
- :root[data-theme="dark"] { --chrome-face: #2B2E23; --chrome-face-hi: #363A29; --chrome-edge-hi: #4C5138; --chrome-edge-lo: #14150D;
294
- --chrome-brass: #D3AE5C; --chrome-brass-soft: rgba(211, 174, 92, .16);
295
- --chrome-well: #0B0904; --chrome-well-ink: #F0D392; }
296
- :root[data-theme="light"] { --chrome-face: #DCD3B8; --chrome-face-hi: #EFE8CC; --chrome-edge-hi: #FBF7E6; --chrome-edge-lo: #8B7F5C;
297
- --chrome-brass: #8A6A26; --chrome-brass-soft: rgba(138, 106, 38, .16);
298
- --chrome-well: #201A10; --chrome-well-ink: #E8C876; }
297
+ :root[data-theme="dark"] { --chrome-face: #262230; --chrome-face-hi: #302B3D; --chrome-edge-hi: #453E57; --chrome-edge-lo: #121016;
298
+ --chrome-accent: #A995BD; --chrome-accent-soft: rgba(169, 149, 189, .16);
299
+ --chrome-well: #0C0A10; --chrome-well-ink: #E0D8C6;
300
+ --chrome-hatch: repeating-linear-gradient(55deg, rgba(240, 235, 220, .04) 0 1px, transparent 1px 6px), repeating-linear-gradient(-35deg, rgba(240, 235, 220, .03) 0 1px, transparent 1px 9px); }
301
+ :root[data-theme="light"] { --chrome-face: #E7E1D3; --chrome-face-hi: #F2EDE0; --chrome-edge-hi: #FAF6EA; --chrome-edge-lo: #6E6A5E;
302
+ --chrome-accent: #5C4B66; --chrome-accent-soft: rgba(92, 75, 102, .14);
303
+ --chrome-well: #1A171C; --chrome-well-ink: #E4DDCB;
304
+ --chrome-hatch: repeating-linear-gradient(55deg, rgba(28, 23, 32, .05) 0 1px, transparent 1px 6px), repeating-linear-gradient(-35deg, rgba(28, 23, 32, .04) 0 1px, transparent 1px 9px); }
299
305
  html { background: var(--bg); }
300
306
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
301
307
  .mono { font-family: ${MONO_STACK}; }
302
308
  main { max-width: 1120px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
303
- .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--chrome-brass); }
309
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--chrome-accent); }
304
310
  h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
305
311
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
306
312
  button:focus-visible, input:focus-visible, .sprite:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@@ -310,7 +316,7 @@ ${THEME_TOKENS_CSS}
310
316
  needing to sum to a full width. */
311
317
  .stage { display: grid; grid-template-columns: minmax(0, 8fr) minmax(280px, 3fr); gap: 1.2rem; align-items: start; }
312
318
  @media (max-width: 760px) { .stage { grid-template-columns: minmax(0, 1fr); } }
313
- /* A dusty window corner: a soft light glow near the top-left (WEB_HOME
319
+ /* A moonlit window corner: a cool pale glow near the top-left (WEB_HOME
314
320
  already sits near that corner — spider-fly-world.mjs's own header
315
321
  comment), and a faint diagonal weave standing in for dust/silk caught
316
322
  in the light. Decoration only — the 10x10 game grid itself is drawn by
@@ -319,20 +325,26 @@ ${THEME_TOKENS_CSS}
319
325
  margin: 0 auto;
320
326
  position: relative; width: ${BOARD_PX}px; max-width: 100%; aspect-ratio: 1 / 1;
321
327
  background:
322
- radial-gradient(140% 140% at 6% 6%, rgba(255, 241, 199, .55), transparent 52%),
323
- repeating-linear-gradient(115deg, rgba(120, 110, 90, .06) 0 1px, transparent 1px 30px),
328
+ radial-gradient(140% 140% at 6% 6%, rgba(224, 224, 240, .55), transparent 52%),
329
+ repeating-linear-gradient(115deg, rgba(100, 95, 110, .06) 0 1px, transparent 1px 30px),
324
330
  var(--card);
325
331
  border: 1px solid var(--line);
326
332
  box-shadow: inset 0 0 0 6px var(--bg), inset 0 0 0 7px var(--line);
327
333
  }
328
- @media (prefers-color-scheme: dark) { .board-frame { background: radial-gradient(140% 140% at 6% 6%, rgba(130, 112, 60, .32), transparent 52%), repeating-linear-gradient(115deg, rgba(255, 255, 255, .045) 0 1px, transparent 1px 30px), var(--card); } }
329
- :root[data-theme="dark"] .board-frame { background: radial-gradient(140% 140% at 6% 6%, rgba(130, 112, 60, .32), transparent 52%), repeating-linear-gradient(115deg, rgba(255, 255, 255, .045) 0 1px, transparent 1px 30px), var(--card); }
334
+ @media (prefers-color-scheme: dark) { .board-frame { background: radial-gradient(140% 140% at 6% 6%, rgba(126, 120, 158, .30), transparent 52%), repeating-linear-gradient(115deg, rgba(255, 255, 255, .045) 0 1px, transparent 1px 30px), var(--card); } }
335
+ :root[data-theme="dark"] .board-frame { background: radial-gradient(140% 140% at 6% 6%, rgba(126, 120, 158, .30), transparent 52%), repeating-linear-gradient(115deg, rgba(255, 255, 255, .045) 0 1px, transparent 1px 30px), var(--card); }
330
336
  .board-frame canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
337
+ /* The cobweb in the window corner — pure decoration over the moonlit
338
+ glow (never a game element; the ACTIVE web block is still drawn by
339
+ drawBoard() on the canvas). It sits under the sprite layer so agents
340
+ always pass in front of it. */
341
+ .cobweb { position: absolute; top: 7px; left: 7px; width: 36%; height: 36%; color: var(--ink); opacity: .26; pointer-events: none; }
342
+ .cobweb svg { width: 100%; height: 100%; display: block; }
331
343
  .sprite-layer { position: absolute; inset: 0; }
332
344
  .sprite { position: absolute; width: 7.6%; height: 7.6%; transform: translate(-50%, -50%); transition: left .25s ease, top .25s ease; }
333
345
  @media (prefers-reduced-motion: reduce) { .sprite, .sprite-face { transition: none !important; } }
334
346
  .sprite-face { width: 100%; height: 100%; transition: transform .25s ease; }
335
- .sprite-face svg { width: 100%; height: 100%; display: block; }
347
+ .sprite-face svg { width: 100%; height: 100%; display: block; filter: var(--sprite-pop); }
336
348
  .sprite[data-cls="spider"] { color: var(--taught); }
337
349
  .sprite[data-cls="fly"] { color: var(--fly); }
338
350
  .sprite[data-cls="egg"] { color: var(--muted); }
@@ -350,7 +362,7 @@ ${THEME_TOKENS_CSS}
350
362
  nameplate header below bleeds to this same panel's own edges via a
351
363
  negative margin equal to this padding, so it reads as one welded unit,
352
364
  not a label floating inside a box. */
353
- .hud, .chat, .tuning { background: var(--chrome-face); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-raised); border-radius: 2px; padding: .6rem .75rem; }
365
+ .hud, .chat, .tuning { background: var(--chrome-hatch), var(--chrome-face); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-raised); border-radius: 2px; padding: .6rem .75rem; }
354
366
  /* Both the controls strip above the board and the tuning strip below it
355
367
  match the board's own width (not the wider stage-left column they sit
356
368
  in), so all three line up as one visual stack. */
@@ -380,12 +392,12 @@ ${THEME_TOKENS_CSS}
380
392
  .hud-row { display: flex; align-items: flex-start; gap: .6rem; padding: .4rem 0; border-top: 1px solid var(--chrome-edge-lo); box-shadow: inset 0 1px 0 var(--chrome-edge-hi); }
381
393
  .hud-row:first-of-type { border-top: none; box-shadow: none; }
382
394
  .hud-row.clickable { cursor: pointer; }
383
- .hud-row.clickable:hover, .hud-row.clickable:focus-visible { background: var(--chrome-brass-soft); }
395
+ .hud-row.clickable:hover, .hud-row.clickable:focus-visible { background: var(--chrome-accent-soft); }
384
396
  .hud-main { display: flex; flex-direction: column; gap: .1rem; flex: 1 1 auto; min-width: 0; }
385
397
  .hud-id { font-family: ${MONO_STACK}; font-size: .74rem; font-weight: 600; }
386
398
  .hud-id.spider { color: var(--taught); } .hud-id.fly { color: var(--fly); } .hud-id.egg { color: var(--muted); }
387
399
  .hud-goal { font-size: .85rem; }
388
- .hud-plan, .hud-belief { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); margin-top: .25rem; line-height: 1.4; padding-left: .5rem; border-left: 2px solid var(--chrome-brass); }
400
+ .hud-plan, .hud-belief { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); margin-top: .25rem; line-height: 1.4; padding-left: .5rem; border-left: 2px solid var(--chrome-accent); }
389
401
  /* the click-expand facts panel (§28): beside the clicked spider/fly's own
390
402
  row, never a separate popover or a second panel elsewhere on the page —
391
403
  the same believedCellOf/beliefSnapshotFor read path spider-fly.mjs
@@ -417,7 +429,7 @@ ${THEME_TOKENS_CSS}
417
429
  .chatask input:disabled { opacity: .5; }
418
430
  .chatpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; }
419
431
  .pill { font-family: ${MONO_STACK}; font-size: .68rem; padding: .2rem .6rem; border: 1px solid var(--chrome-edge-lo); border-radius: 99px; background: var(--chrome-face); color: var(--ink); box-shadow: inset 0 1px 0 var(--chrome-edge-hi); white-space: nowrap; }
420
- .pill:hover:not(:disabled) { border-color: var(--chrome-brass); }
432
+ .pill:hover:not(:disabled) { border-color: var(--chrome-accent); }
421
433
  .pill:disabled { opacity: .45; cursor: default; }
422
434
  .pill[data-role="addr"].active { border-color: var(--taught); color: var(--taught); }
423
435
  /* The dynamic deception-pill rail (§A.2.4): a true/false tag shown ONLY
@@ -437,7 +449,7 @@ ${THEME_TOKENS_CSS}
437
449
  .tuning-col.spider h3 { color: var(--taught); } .tuning-col.fly h3 { color: var(--fly); }
438
450
  .tuning-col label { display: block; font-size: .68rem; color: var(--muted); margin-bottom: .6rem; }
439
451
  /* Every live number on this page (this readout, the turn plaque below)
440
- shares the same brass-on-well "LED" treatment — one readout language,
452
+ shares the same bone-on-ink readout treatment — one readout language,
441
453
  not a one-off flourish. */
442
454
  .tuning-col .tuning-val { font-family: ${MONO_STACK}; font-variant-numeric: tabular-nums; color: var(--chrome-well-ink); background: var(--chrome-well); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: 0 .35rem; float: right; }
443
455
  .tuning-col input[type="range"] {
@@ -446,7 +458,7 @@ ${THEME_TOKENS_CSS}
446
458
  -webkit-appearance: none; appearance: none;
447
459
  }
448
460
  .tuning-col.fly input[type="range"] { accent-color: var(--fly); }
449
- /* A lever-look thumb (a molded brass-edged cap) — appearance: none above
461
+ /* A lever-look thumb (a molded, ink-edged cap) — appearance: none above
450
462
  drops the OS's own native slider look in browsers that honor it;
451
463
  accent-color just above is the fallback for any that don't. */
452
464
  .tuning-col input[type="range"]::-webkit-slider-thumb {
@@ -469,14 +481,14 @@ ${THEME_TOKENS_CSS}
469
481
  control panel", and it costs nothing under reduced-motion since neither
470
482
  state involves a transition, just an instant bevel swap. */
471
483
  .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .03em; text-transform: uppercase; padding: .4rem .85rem; border-radius: 2px; border: 1px solid var(--chrome-edge-lo); background: var(--chrome-face); box-shadow: var(--chrome-shadow-raised); color: var(--ink); }
472
- .controls-row button:hover:not(:disabled) { border-color: var(--chrome-brass); }
484
+ .controls-row button:hover:not(:disabled) { border-color: var(--chrome-accent); }
473
485
  .controls-row button:active:not(:disabled) { box-shadow: var(--chrome-shadow-inset); }
474
486
  .controls-row button:disabled { opacity: .4; cursor: default; box-shadow: none; }
475
- /* The signature element: a brass-framed digital turn counter, the same
487
+ /* The turn counter plaque: an accent-framed readout, the same
476
488
  inset-well readout language as every stat above it — the one thing this
477
489
  page is remembered by. */
478
- .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .82rem; letter-spacing: .05em; text-transform: uppercase; font-variant-numeric: tabular-nums; color: var(--chrome-well-ink); background: var(--chrome-well); border: 1px solid var(--chrome-brass); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: .3rem .7rem; }
479
- .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .5rem; padding-left: .5rem; border-left: 2px solid var(--chrome-brass); }
490
+ .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .82rem; letter-spacing: .05em; text-transform: uppercase; font-variant-numeric: tabular-nums; color: var(--chrome-well-ink); background: var(--chrome-well); border: 1px solid var(--chrome-accent); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: .3rem .7rem; }
491
+ .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .5rem; padding-left: .5rem; border-left: 2px solid var(--chrome-accent); }
480
492
  body.preview .side, body.preview .controls-panel, body.preview .tuning { display: none; }
481
493
  body.preview main { padding: 0; max-width: none; }
482
494
  body.preview .stage, body.preview .stage-left { display: block; }
@@ -507,11 +519,23 @@ ${THEME_TOKENS_CSS}
507
519
  <div class="board-frame" id="boardFrame">
508
520
  <canvas id="board" width="${BOARD_PX}" height="${BOARD_PX}" aria-label="the 10x10 board"></canvas>
509
521
  <canvas id="pov" width="${BOARD_PX}" height="${BOARD_PX}" aria-hidden="true"></canvas>
522
+ <div class="cobweb" aria-hidden="true"><svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-linecap="round">
523
+ <g stroke-width="0.7">
524
+ <path d="M0 0 L73.8 5.2"/><path d="M0 0 L68.6 27.7"/><path d="M0 0 L52.3 52.3"/><path d="M0 0 L27.7 68.6"/><path d="M0 0 L5.2 73.8"/>
525
+ </g>
526
+ <g stroke-width="0.55">
527
+ <path d="M20 1.4 Q16.8 3.9 18.5 7.5 Q14.3 9.5 14.1 14.1 Q9.5 14.3 7.5 18.5 Q3.9 16.8 1.4 20"/>
528
+ <path d="M33.9 2.4 Q28.4 6.6 31.5 12.7 Q24.4 16.1 24 24 Q16.1 24.4 12.7 31.5 Q6.6 28.4 2.4 33.9"/>
529
+ <path d="M49.9 3.5 Q41.9 9.7 46.4 18.7 Q35.9 23.7 35.4 35.4 Q23.7 35.9 18.7 46.4 Q9.7 41.9 3.5 49.9"/>
530
+ <path d="M67.8 4.8 Q57 13.2 63 25.5 Q48.8 32.3 48.1 48.1 Q32.3 48.8 25.5 63 Q13.2 57 4.8 67.8"/>
531
+ </g>
532
+ <path d="M48.1 48.1 Q49.5 54 48.6 59.5" stroke-width="0.5"/>
533
+ </svg></div>
510
534
  <div class="sprite-layer" id="spriteLayer"></div>
511
535
  <div class="thread-tip" id="threadTip"></div>
512
536
  </div>
513
537
  <div class="tuning" id="tuning">
514
- <h2>live tuning &mdash; mass loss, spawn rate, vision, per class</h2>
538
+ <h2>live tuning &middot; mass loss, spawn rate, vision, per class</h2>
515
539
  <div class="tuning-grid">
516
540
  <div class="tuning-col spider">
517
541
  <h3>spider</h3>
@@ -165,10 +165,10 @@ export const GROUP_OBJECT = "object";
165
165
  export const GROUP_EMOJI = "emoji";
166
166
 
167
167
  export const CATALOG_GROUPS = Object.freeze([
168
- Object.freeze({ id: GROUP_ADVENTURE, label: "Ashcombe Hall's own adventure props", note: "the icon tier's own named cast and furniture a dedicated 44px sprite exists for each" }),
168
+ Object.freeze({ id: GROUP_ADVENTURE, label: "Ashcombe Hall's own adventure props", note: "the icon tier's named cast and furniture. Each has its own 44px sprite." }),
169
169
  Object.freeze({ id: GROUP_PERSON, label: "Person roles" }),
170
170
  Object.freeze({ id: GROUP_OBJECT, label: "Physical objects, creatures & places" }),
171
- Object.freeze({ id: GROUP_EMOJI, label: "Emotions & events", note: "abstract concepts with no honest single physical picture rendered as the ubiquitous emoji instead" }),
171
+ Object.freeze({ id: GROUP_EMOJI, label: "Emotions & events", note: "abstract concepts with no single physical picture, drawn as the familiar emoji instead" }),
172
172
  ]);
173
173
 
174
174
  /** Which catalog section `cls` belongs in. Pure. `isIconTierClass`/`isEmoji`
@@ -181,6 +181,33 @@ export function groupForClass(cls, { isIconTierClass, isEmoji }) {
181
181
  return GROUP_OBJECT;
182
182
  }
183
183
 
184
+ /** Cluster one presentation group's entries under each class's nearest
185
+ * ILLUSTRATED ancestor — the first term after the class itself, walking its
186
+ * own real chain outward, that carries a sprite template at either tier. A
187
+ * new leaf attached under that ancestor later resolves to the ancestor's
188
+ * own sprite through the same walk, so these headings show exactly where
189
+ * the graph can grow with no new art needed. A cluster needs two members
190
+ * to earn a heading; singletons (and classes whose chain reaches no
191
+ * illustrated ancestor) fold into one trailing `ancestor: null` bucket so
192
+ * the section never shreds into one-card headings. Pure. */
193
+ export function clusterEntriesByAncestor(entries, spritedClasses) {
194
+ const byKey = new Map();
195
+ for (const e of entries || []) {
196
+ const key = (e.chain || []).slice(1).find((t) => spritedClasses.has(t)) || "";
197
+ if (!byKey.has(key)) byKey.set(key, []);
198
+ byKey.get(key).push(e);
199
+ }
200
+ const clusters = [];
201
+ const rest = [];
202
+ for (const [key, list] of byKey) {
203
+ if (key && list.length >= 2) clusters.push({ ancestor: key, entries: list });
204
+ else rest.push(...list);
205
+ }
206
+ clusters.sort((a, b) => b.entries.length - a.entries.length || a.ancestor.localeCompare(b.ancestor));
207
+ if (rest.length) clusters.push({ ancestor: null, entries: [...rest].sort((a, b) => a.className.localeCompare(b.className)) });
208
+ return clusters;
209
+ }
210
+
184
211
  function templatesForClass(cls, templates) {
185
212
  return (templates || []).filter((t) => Array.isArray(t?.classes) && t.classes.includes(cls));
186
213
  }
@@ -418,7 +445,8 @@ function swatchHtml(s) {
418
445
  if (s.treatment) parts.push(`<span class="swatch-treat">&rarr; ${escapeHtml(s.treatment)} treatment</span>`);
419
446
  const title = s.property ? `${s.property} = ${s.label}` : s.label;
420
447
  const cls = ["swatch", s.tier, s.kind].filter(Boolean).join(" ");
421
- return `<div class="${cls}" title="${escapeHtml(title)}"><div class="swatch-img">${s.svg}</div><div class="swatch-caption">${parts.join("")}</div></div>`;
448
+ const property = s.property ? ` data-property="${escapeHtml(s.property)}"` : "";
449
+ return `<div class="${cls}"${property} title="${escapeHtml(title)}"><div class="swatch-img">${s.svg}</div><div class="swatch-caption">${parts.join("")}</div></div>`;
422
450
  }
423
451
 
424
452
  function tierRowHtml(tierName, swatches) {
@@ -438,14 +466,32 @@ function cardHtml(entry) {
438
466
  </article>`;
439
467
  }
440
468
 
441
- function sectionHtml(group, entries) {
469
+ /** One ancestor cluster inside a section: a heading that carries the
470
+ * ancestor's OWN resolved sprite (the proof the fallback has art to land
471
+ * on), then that cluster's cards. `ancestorSvg` is null for the trailing
472
+ * no-illustrated-ancestor bucket. */
473
+ function clusterHtml(cluster, ancestorSvg) {
474
+ const name = cluster.ancestor || "everything else";
475
+ const chip = ancestorSvg ? `<span class="cluster-chip">${ancestorSvg}</span>` : "";
476
+ return `<div class="cluster">
477
+ <h3 class="cluster-head">${chip}<span class="cluster-name">${escapeHtml(name)}</span><span class="count">${cluster.entries.length}</span></h3>
478
+ <div class="cards">${cluster.entries.map(cardHtml).join("")}</div>
479
+ </div>`;
480
+ }
481
+
482
+ function sectionHtml(group, entries, { clusterBy = null } = {}) {
442
483
  const rows = entries.filter((e) => e.group === group.id);
443
484
  if (!rows.length) return "";
444
485
  const note = group.note ? `<p class="section-note">${escapeHtml(group.note)}</p>` : "";
486
+ const body = clusterBy
487
+ ? clusterEntriesByAncestor(rows, clusterBy.spritedClasses)
488
+ .map((c) => clusterHtml(c, c.ancestor ? clusterBy.resolveChip(c.ancestor) : null))
489
+ .join("")
490
+ : `<div class="cards">${rows.map(cardHtml).join("")}</div>`;
445
491
  return `<section class="group" id="g-${group.id}" aria-label="${escapeHtml(group.label)}">
446
492
  <h2>${escapeHtml(group.label)} <span class="count">${rows.length}</span></h2>
447
493
  ${note}
448
- <div class="cards">${rows.map(cardHtml).join("")}</div>
494
+ ${body}
449
495
  </section>`;
450
496
  }
451
497
 
@@ -471,6 +517,15 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
471
517
  const entries = buildSpriteCatalogEntries({ iconTemplates, largeTemplates, factRows });
472
518
  const totalSwatches = entries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
473
519
  const pageData = embedJson({ classCount: entries.length, swatchCount: totalSwatches });
520
+ // Ancestor clustering for the two big sections: the heading chip is the
521
+ // ancestor's OWN resolved sprite — visible proof that a new subclass
522
+ // attached there already has fallback art waiting for it.
523
+ const spritedClasses = new Set([...iconTemplates, ...largeTemplates].flatMap((t) => t?.classes || []));
524
+ const clusterBy = {
525
+ spritedClasses,
526
+ resolveChip: (ancestor) =>
527
+ resolveSpriteAsset(ancestor, [], [], largeTemplates, SPRITE_REGISTRY, { instanceKey: `cluster-${ancestor}` }),
528
+ };
474
529
  const dockRows = spritesBundleAvailable ? spriteFactRows({ iconTemplates, largeTemplates }) : [];
475
530
  const navHtml = CATALOG_GROUPS
476
531
  .map((g) => `<a class="jump" href="#g-${g.id}">${escapeHtml(g.label)} <span class="count">${entries.filter((e) => e.group === g.id).length}</span></a>`)
@@ -478,8 +533,6 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
478
533
 
479
534
  const dockCss = !spritesBundleAvailable ? "" : `
480
535
  .dockwrap { margin: .2rem 0 1.3rem; }
481
- .dockwrap .panel { background: var(--card); border: 1px solid var(--line); border-top: 2px solid var(--taught); padding: .75rem .85rem; }
482
- .dockwrap h2 { font-family: ${SERIF_STACK}; font-variant: small-caps; font-size: .82rem; letter-spacing: .04em; color: var(--muted); font-weight: 600; margin: 0 0 .55rem; }
483
536
  .dock-note { color: var(--muted); font-size: .8rem; margin: 0 0 .6rem; max-width: 72ch; }
484
537
  .docklog { display: flex; flex-direction: column; gap: .4rem; max-height: 240px; overflow-y: auto; margin-bottom: .5rem; }
485
538
  .docklog:empty { display: none; margin-bottom: 0; }
@@ -490,8 +543,8 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
490
543
  .docklog .a.grounded { border-left: 2px solid var(--taught); padding-left: .5rem; }
491
544
  .dockask { display: flex; align-items: center; gap: .5rem; }
492
545
  .dockask .prompt { color: var(--taught); font-size: .78rem; }
493
- .dockask input { flex: 1; font-family: ${MONO_STACK}; font-size: .82rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: .38rem .6rem; min-width: 0; }
494
- .dockask input:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
546
+ .dockask input { flex: 1; font-family: ${MONO_STACK}; font-size: .82rem; background: var(--ai-panel-hi); color: var(--ink); border: 1px solid var(--ai-edge); border-radius: 3px; padding: .38rem .6rem; min-width: 0; }
547
+ .dockask input:focus-visible { outline: 2px solid var(--corpus); outline-offset: 2px; }
495
548
  .dockask input:disabled { opacity: .5; }
496
549
  .dock-status { font-size: .72rem; color: var(--muted); margin-top: .55rem; }
497
550
  `;
@@ -499,7 +552,7 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
499
552
  const dockHtml = !spritesBundleAvailable ? "" : `<div class="dockwrap">
500
553
  <section class="panel" aria-label="Ask about the sprite catalog">
501
554
  <h2>ask the catalog</h2>
502
- <p class="dock-note">every answer is read from the sprite templates&rsquo; own facts &mdash; a question they can&rsquo;t ground gets a refusal, never a guess.</p>
555
+ <p class="dock-note">every answer is read from the sprite templates&rsquo; own facts. A question they can&rsquo;t ground gets a refusal, never a guess.</p>
503
556
  <div class="docklog" id="dockLog" aria-live="polite"></div>
504
557
  <form class="dockask" id="dockForm">
505
558
  <span class="prompt mono">tmct&gt;</span>
@@ -575,7 +628,7 @@ const SPRITE_CHAT = ${embedJson({ rows: dockRows })};
575
628
  addDockLine("u", q);
576
629
  const local = answerSpriteQuestion(q, SPRITE_CHAT.rows);
577
630
  if (local) { addDockLine("a grounded", local.answer); return; }
578
- if (!session) { addDockLine("a miss", "the engine is still loading \\u2014 try again in a moment."); return; }
631
+ if (!session) { addDockLine("a miss", "the engine is still loading. Try again in a moment."); return; }
579
632
  withLock(async () => {
580
633
  const result = await session.turn(q);
581
634
  addDockLine(result.record && result.record.miss ? "a miss" : "a", result.answer);
@@ -594,9 +647,9 @@ const SPRITE_CHAT = ${embedJson({ rows: dockRows })};
594
647
  await tryLoadWink();
595
648
  session = await tmctSprites.createSpriteCatalogSession({ factRows: SPRITE_CHAT.rows });
596
649
  dockqEl.disabled = false;
597
- dockStatusEl.textContent = SPRITE_CHAT.rows.length + " sprite facts on record \\u2014 ask away, or use a quick question.";
650
+ dockStatusEl.textContent = SPRITE_CHAT.rows.length + " sprite facts on record. Ask away, or use a quick question.";
598
651
  } catch (err) {
599
- dockStatusEl.textContent = "the chat engine failed to load \\u2014 the catalog below still works.";
652
+ dockStatusEl.textContent = "the chat engine failed to load. The catalog below still works.";
600
653
  console.error("tmct sprites: dock boot failed", err);
601
654
  }
602
655
  })();
@@ -614,49 +667,67 @@ ${spritesBundleAvailable ? `<link rel="icon" href="./favicon.svg" type="image/sv
614
667
  <link rel="apple-touch-icon" href="./apple-touch-icon.png">` : ""}
615
668
  <style>
616
669
  ${THEME_TOKENS_CSS}
617
- html { background: var(--bg); }
618
- body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
670
+ html { background: var(--ai-canvas); }
671
+ /* Creative-app chrome: a dark document bar in BOTH themes (the app-window
672
+ read), panels and a recessed canvas that follow the theme, and a
673
+ transparency checker under every drawn swatch so each sprite proves it
674
+ holds its own boundary with nothing behind it. */
675
+ :root {
676
+ --ai-bar: #26272B; --ai-bar-ink: #E7E5DF;
677
+ --ai-panel: #EDECE8; --ai-panel-hi: #F4F3F0; --ai-canvas: #DFDEDA; --ai-edge: #C8C6C0;
678
+ --checker: rgba(0, 0, 0, .07);
679
+ }
680
+ @media (prefers-color-scheme: dark) { :root { --ai-panel: #26272B; --ai-panel-hi: #2D2E33; --ai-canvas: #1A1B1F; --ai-edge: #34363C; --checker: rgba(255, 255, 255, .055); } }
681
+ :root[data-theme="dark"] { --ai-panel: #26272B; --ai-panel-hi: #2D2E33; --ai-canvas: #1A1B1F; --ai-edge: #34363C; --checker: rgba(255, 255, 255, .055); }
682
+ :root[data-theme="light"] { --ai-panel: #EDECE8; --ai-panel-hi: #F4F3F0; --ai-canvas: #DFDEDA; --ai-edge: #C8C6C0; --checker: rgba(0, 0, 0, .07); }
683
+ body { margin: 0; background: var(--ai-canvas); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
619
684
  .mono { font-family: ${MONO_STACK}; }
620
- main { max-width: 1180px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
621
- .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
622
- h1 { font-size: 1.4rem; margin: .3rem 0 .6rem; text-wrap: balance; }
623
-
624
- .composer { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: .6rem 0 1.3rem; }
685
+ main { max-width: 1240px; margin: 0 auto; padding: 0 1.2rem 3rem; }
686
+ button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--corpus); outline-offset: 2px; }
687
+ .appbar { display: flex; align-items: flex-end; gap: 1rem; margin: 0 -1.2rem; padding: .6rem 1.2rem 0; background: var(--ai-bar); }
688
+ .appbar h1 { font-family: ${MONO_STACK}; font-size: .84rem; font-weight: 600; letter-spacing: .02em; margin: 0; padding: .32rem .8rem .38rem; background: var(--ai-panel); color: var(--ink); border-radius: 4px 4px 0 0; }
689
+ .appbar .doc-sub { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: color-mix(in srgb, var(--ai-bar-ink) 65%, transparent); padding-bottom: .5rem; }
690
+ .topbar { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; background: var(--ai-panel); border-bottom: 1px solid var(--ai-edge); margin: 0 -1.2rem 1.4rem; padding: .5rem 1.2rem; }
691
+ .jump { font-family: ${MONO_STACK}; font-size: .7rem; padding: .2rem .6rem; border: 1px solid var(--ai-edge); border-radius: 3px; background: transparent; color: var(--ink); text-decoration: none; }
692
+ .jump:hover { border-color: var(--corpus); color: var(--corpus); }
693
+ .jump .count { color: var(--muted); }
694
+ .filter { margin-left: auto; display: flex; align-items: center; gap: .4rem; }
695
+ .filter input { font-family: ${MONO_STACK}; font-size: .78rem; background: var(--ai-panel-hi); color: var(--ink); border: 1px solid var(--ai-edge); border-radius: 3px; padding: .3rem .6rem; width: 200px; }
696
+ .filter .n { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); white-space: nowrap; }
697
+ .composer { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1.2rem 0 1.3rem; }
625
698
  @media (max-width: 700px) { .composer { grid-template-columns: 1fr; } }
626
- .composer .panel { background: var(--card); border: 1px solid var(--line); border-top: 2px solid var(--taught); padding: .75rem .85rem; min-width: 0; }
627
- .composer h2 { font-family: ${SERIF_STACK}; font-variant: small-caps; font-size: .82rem; letter-spacing: .04em; color: var(--muted); font-weight: 600; margin: 0 0 .55rem; }
699
+ .composer .panel, .dockwrap .panel { background: var(--ai-panel); border: 1px solid var(--ai-edge); border-radius: 4px; padding: .75rem .85rem; min-width: 0; box-shadow: 0 1px 2px rgba(0, 0, 0, .08); }
700
+ .composer h2, .dockwrap h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); font-weight: 600; margin: -.75rem -.85rem .6rem; padding: .42rem .85rem; background: var(--ai-panel-hi); border-bottom: 1px solid var(--ai-edge); border-radius: 4px 4px 0 0; }
628
701
  .composeform { display: flex; align-items: center; gap: .5rem; }
629
702
  .composeform .prompt { color: var(--taught); font-size: .8rem; white-space: nowrap; }
630
- .composeform input { flex: 1; font-family: ${MONO_STACK}; font-size: .82rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: .38rem .6rem; min-width: 0; }
631
- .composeform input:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
703
+ .composeform input { flex: 1; font-family: ${MONO_STACK}; font-size: .82rem; background: var(--ai-panel-hi); color: var(--ink); border: 1px solid var(--ai-edge); border-radius: 3px; padding: .38rem .6rem; min-width: 0; }
632
704
  .pills { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .6rem; }
633
- .pill { font-family: ${MONO_STACK}; font-size: .7rem; padding: .22rem .55rem; border: 1px solid var(--line); border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
634
- .pill:hover { border-color: var(--taught); }
635
- .pill:focus-visible { outline: 2px solid var(--taught); outline-offset: 2px; }
705
+ .pill { font-family: ${MONO_STACK}; font-size: .7rem; padding: .22rem .55rem; border: 1px solid var(--ai-edge); border-radius: 999px; background: var(--ai-panel-hi); color: var(--ink); cursor: pointer; }
706
+ .pill:hover { border-color: var(--corpus); color: var(--corpus); }
636
707
  .scene-frame { min-height: 5.6rem; display: flex; align-items: center; }
637
708
  .scene-row { display: flex; flex-wrap: wrap; gap: .8rem; align-items: flex-start; width: 100%; }
709
+ .scene-row:empty { display: none; }
638
710
  .scene-card { display: flex; flex-direction: column; align-items: center; width: 74px; }
639
- .scene-sprite { width: 60px; height: 60px; border-radius: 8px; background: var(--bg); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; padding: 8px; box-sizing: border-box; }
640
- .scene-sprite svg { width: 100%; height: 100%; display: block; }
711
+ .scene-sprite { width: 60px; height: 60px; border-radius: 3px; border: 1px solid var(--ai-edge); display: flex; align-items: center; justify-content: center; padding: 6px; box-sizing: border-box; background-image: linear-gradient(45deg, var(--checker) 25%, transparent 25% 75%, var(--checker) 75%), linear-gradient(45deg, var(--checker) 25%, transparent 25% 75%, var(--checker) 75%); background-position: 0 0, 5px 5px; background-size: 10px 10px; background-color: var(--card); }
712
+ .scene-sprite svg { width: 100%; height: 100%; display: block; filter: var(--sprite-pop); }
641
713
  .scene-label { font-size: .7rem; text-align: center; color: var(--ink); margin-top: .3rem; line-height: 1.2; }
642
714
  .empty-note { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); }
643
- .topbar { position: sticky; top: 0; z-index: 2; background: var(--bg); display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: .6rem 0; margin: 1rem 0 1.2rem; }
644
- .jump { font-family: ${MONO_STACK}; font-size: .72rem; padding: .18rem .55rem; border: 1px solid var(--line); border-radius: 99px; background: var(--card); color: var(--ink); text-decoration: none; }
645
- .jump:hover { border-color: var(--taught); }
646
- .jump .count { color: var(--muted); }
647
- .filter { margin-left: auto; display: flex; align-items: center; gap: .4rem; }
648
- .filter input { font-family: ${MONO_STACK}; font-size: .8rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .32rem .6rem; width: 200px; }
649
- .filter .n { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); white-space: nowrap; }
650
- .group { margin: 2rem 0; content-visibility: auto; contain-intrinsic-size: 800px; }
651
- .group h2 { font-size: 1.05rem; margin: 0 0 .2rem; display: flex; align-items: baseline; gap: .5rem; }
652
- .group h2 .count { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); font-weight: 400; }
715
+ .group { margin: 2.2rem 0; content-visibility: auto; contain-intrinsic-size: 800px; }
716
+ .group h2 { font-family: ${MONO_STACK}; font-size: .78rem; text-transform: uppercase; letter-spacing: .09em; margin: 0 0 .2rem; display: flex; align-items: baseline; gap: .5rem; }
717
+ .group h2 .count { font-size: .7rem; color: var(--muted); font-weight: 400; }
653
718
  .section-note { color: var(--muted); font-size: .82rem; margin: 0 0 .8rem; max-width: 68ch; }
719
+ .cluster { margin: 1rem 0 1.5rem; }
720
+ .cluster-head { display: flex; align-items: center; gap: .45rem; font-family: ${MONO_STACK}; font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin: 0 0 .55rem; }
721
+ .cluster-head .count { font-weight: 400; opacity: .75; }
722
+ .cluster-head::after { content: ""; flex: 1; height: 1px; background: var(--ai-edge); }
723
+ .cluster-chip { width: 26px; height: 26px; display: inline-flex; padding: 2px; box-sizing: border-box; border: 1px solid var(--ai-edge); border-radius: 3px; background-image: linear-gradient(45deg, var(--checker) 25%, transparent 25% 75%, var(--checker) 75%), linear-gradient(45deg, var(--checker) 25%, transparent 25% 75%, var(--checker) 75%); background-position: 0 0, 4px 4px; background-size: 8px 8px; background-color: var(--card); }
724
+ .cluster-chip svg { width: 100%; height: 100%; display: block; filter: var(--sprite-pop); }
654
725
  .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: .7rem; }
655
- .card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem .7rem .7rem; content-visibility: auto; contain-intrinsic-size: 220px; }
726
+ .card { background: var(--card); border: 1px solid var(--ai-edge); border-radius: 4px; padding: .55rem .7rem .7rem; box-shadow: 0 1px 2px rgba(0, 0, 0, .08); content-visibility: auto; contain-intrinsic-size: 220px; }
656
727
  .card[hidden] { display: none; }
657
- .card-name { font-size: .92rem; margin: 0 0 .3rem; font-weight: 600; }
728
+ .card-name { font-family: ${MONO_STACK}; font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 0 .35rem; }
658
729
  .chain { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); margin-bottom: .5rem; display: flex; flex-wrap: wrap; align-items: center; gap: .15rem; }
659
- .chain-link { padding: .04rem .35rem; border: 1px solid var(--line); border-radius: 99px; }
730
+ .chain-link { padding: .04rem .35rem; border: 1px solid var(--ai-edge); border-radius: 99px; }
660
731
  .chain-link.own { border-color: var(--taught); color: var(--taught); }
661
732
  .chain-arrow { color: var(--muted); opacity: .6; }
662
733
  .chain-more { font-style: italic; opacity: .75; }
@@ -665,21 +736,27 @@ ${THEME_TOKENS_CSS}
665
736
  .tier-label { font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
666
737
  .swatches { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .25rem; }
667
738
  .swatch { width: 64px; text-align: center; }
739
+ .swatch-img { border: 1px solid var(--ai-edge); border-radius: 3px; box-sizing: border-box; padding: 3px; background-image: linear-gradient(45deg, var(--checker) 25%, transparent 25% 75%, var(--checker) 75%), linear-gradient(45deg, var(--checker) 25%, transparent 25% 75%, var(--checker) 75%); background-position: 0 0, 5px 5px; background-size: 10px 10px; background-color: var(--card); }
668
740
  .swatch.large .swatch-img { width: 64px; height: 64px; }
669
- .swatch.icon .swatch-img { width: 34px; height: 34px; margin: 0 auto; }
670
- .swatch-img svg { width: 100%; height: 100%; display: block; }
741
+ .swatch.icon .swatch-img { width: 36px; height: 36px; margin: 0 auto; }
742
+ .swatch-img svg { width: 100%; height: 100%; display: block; filter: var(--sprite-pop); }
671
743
  .swatch.fallback { opacity: .55; }
672
- .swatch.fallback .swatch-img { outline: 1px dashed var(--line); outline-offset: 2px; }
744
+ .swatch.fallback .swatch-img { border-style: dashed; }
673
745
  .swatch-caption { font-family: ${MONO_STACK}; font-size: .58rem; color: var(--muted); line-height: 1.25; margin-top: .15rem; word-break: break-word; }
674
746
  .swatch-treat { display: block; opacity: .8; }
675
- footer.page { max-width: 74ch; margin: 2.5rem 0 0; padding-top: 1rem; border-top: 1px solid var(--line); font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
676
- @media (prefers-reduced-motion: no-preference) { .jump, .swatch, .pill { transition: border-color .12s ease, opacity .12s ease; } }
747
+ .swatch.cycle .swatch-img { border-color: var(--taught); cursor: pointer; display: block; }
748
+ .swatch.cycle .swatch-img:hover { border-color: var(--corpus); }
749
+ .swatch.cycle .swatch-caption { color: var(--taught); }
750
+ footer.page { max-width: 74ch; margin: 2.5rem 0 0; padding-top: 1rem; border-top: 1px solid var(--ai-edge); font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
751
+ @media (prefers-reduced-motion: no-preference) { .jump, .swatch, .pill { transition: border-color .12s ease, color .12s ease, opacity .12s ease; } }
677
752
  ${dockCss}</style>
678
753
  </head>
679
754
  <body>
680
755
  <main>
681
- <div class="eyebrow">tmct &middot; the sprite library</div>
682
- <h1>Sprites</h1>
756
+ <header class="appbar">
757
+ <h1>Sprites</h1>
758
+ <span class="doc-sub">tmct &middot; the sprite library</span>
759
+ </header>
683
760
  <div class="composer">
684
761
  <section class="panel compose-panel" aria-label="Describe a scene">
685
762
  <h2>describe a scene</h2>
@@ -702,7 +779,7 @@ ${dockCss}</style>
702
779
  <h2>the scene</h2>
703
780
  <div class="scene-frame" id="sceneFrame">
704
781
  <div class="scene-row" id="sceneRow" aria-live="polite"></div>
705
- <span class="empty-note" id="sceneEmpty">nothing recognized yet &mdash; try &ldquo;a doctor with a hat, and a cabinet&rdquo;.</span>
782
+ <span class="empty-note" id="sceneEmpty">nothing recognized yet. Try &ldquo;a doctor with a hat, and a cabinet&rdquo;.</span>
706
783
  </div>
707
784
  </section>
708
785
  </div>
@@ -714,7 +791,7 @@ ${dockCss}</style>
714
791
  <span class="n mono" id="qcount"></span>
715
792
  </div>
716
793
  </div>
717
- ${CATALOG_GROUPS.map((g) => sectionHtml(g, entries)).join("")}
794
+ ${CATALOG_GROUPS.map((g) => sectionHtml(g, entries, { clusterBy: g.id === GROUP_PERSON || g.id === GROUP_OBJECT ? clusterBy : null })).join("")}
718
795
  <footer class="page">${entries.length} classes &middot; ${totalSwatches} swatches &middot; icon tier 44px, sprite tier 400px</footer>
719
796
  </main>
720
797
  <script>
@@ -813,6 +890,53 @@ const SPRITE_CATALOG = ${pageData};
813
890
  renderScene(composeqEl.value);
814
891
  });
815
892
  renderScene("");
893
+
894
+ // ---- the variant cycle — a card whose large tier carries expression
895
+ // (mgx:feels) or direction (mgx:faces) variants gains one extra swatch
896
+ // that steps through them: plain, then each direction, then each face.
897
+ // Frames are the card's own already-rendered swatches (the same
898
+ // read-the-DOM posture classIndex takes above — never a second embedded
899
+ // copy), and one shared interval steps every cycle in sync. Reduced
900
+ // motion disables the auto-step only; the frame itself is a button, so a
901
+ // click or Enter always advances one frame by hand. Injected AFTER
902
+ // buildClassIndexFromDom ran, so a cycle frame never leaks into the
903
+ // composer's material index.
904
+ const CYCLE_PROPERTIES = ["mgx:faces", "mgx:feels"];
905
+ const cycleSteppers = [];
906
+ for (const card of cards) {
907
+ const swatchRow = card.querySelector('.tier-row[data-tier="large"] .swatches');
908
+ if (!swatchRow) continue;
909
+ const frames = [];
910
+ for (const swatch of swatchRow.querySelectorAll(".swatch")) {
911
+ if (CYCLE_PROPERTIES.indexOf(swatch.dataset.property || "") === -1) continue;
912
+ const img = swatch.querySelector(".swatch-img");
913
+ const label = swatch.querySelector(".swatch-label");
914
+ if (img && label) frames.push({ svg: img.innerHTML, label: label.textContent.trim() });
915
+ }
916
+ if (frames.length < 2) continue;
917
+ const baseImg = swatchRow.querySelector(".swatch.plain .swatch-img, .swatch.fallback .swatch-img");
918
+ if (baseImg) frames.unshift({ svg: baseImg.innerHTML, label: card.dataset.cls });
919
+ const holder = document.createElement("div");
920
+ holder.className = "swatch large cycle";
921
+ holder.innerHTML = '<button type="button" class="swatch-img" aria-label="step through the '
922
+ + esc(card.dataset.cls) + ' variants"></button><div class="swatch-caption"><span class="swatch-label"></span></div>';
923
+ const frameImgEl = holder.querySelector(".swatch-img");
924
+ const frameLabelEl = holder.querySelector(".swatch-label");
925
+ let frameIndex = 0;
926
+ const showFrame = () => {
927
+ frameImgEl.innerHTML = frames[frameIndex].svg;
928
+ frameLabelEl.textContent = frames[frameIndex].label;
929
+ };
930
+ const stepFrame = () => { frameIndex = (frameIndex + 1) % frames.length; showFrame(); };
931
+ frameImgEl.addEventListener("click", stepFrame);
932
+ showFrame();
933
+ swatchRow.insertBefore(holder, swatchRow.firstChild);
934
+ cycleSteppers.push(stepFrame);
935
+ }
936
+ const reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
937
+ if (cycleSteppers.length && !reducedMotion) {
938
+ setInterval(() => { for (const stepFrame of cycleSteppers) stepFrame(); }, 800);
939
+ }
816
940
  })();
817
941
  </script>
818
942
  ${dockScripts}