@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +178 -9
  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 +401 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +49 -9
  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-hollow.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  13. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  14. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  15. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  16. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  17. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  18. package/data/sprites/animal-icon.toml +11 -5
  19. package/data/sprites/book-icon.toml +9 -5
  20. package/data/sprites/cabinet-icon.toml +10 -5
  21. package/data/sprites/cellar-icon.toml +16 -6
  22. package/data/sprites/container-icon.toml +7 -3
  23. package/data/sprites/desk-icon.toml +8 -5
  24. package/data/sprites/dog-icon.toml +8 -5
  25. package/data/sprites/dog-with-colour-icon.toml +13 -12
  26. package/data/sprites/drawing-room-icon.toml +14 -5
  27. package/data/sprites/egg-icon.toml +6 -3
  28. package/data/sprites/fly-icon.toml +10 -5
  29. package/data/sprites/furniture-icon.toml +5 -3
  30. package/data/sprites/garden-icon.toml +10 -3
  31. package/data/sprites/key-icon.toml +3 -1
  32. package/data/sprites/kitchen-icon.toml +15 -6
  33. package/data/sprites/lamp-icon.toml +9 -4
  34. package/data/sprites/letter-icon.toml +7 -4
  35. package/data/sprites/library-icon.toml +14 -3
  36. package/data/sprites/pan-icon.toml +7 -3
  37. package/data/sprites/person-icon.toml +6 -2
  38. package/data/sprites/poodle-icon.toml +1 -1
  39. package/data/sprites/portable-icon.toml +6 -4
  40. package/data/sprites/portrait-icon.toml +7 -4
  41. package/data/sprites/room-icon.toml +11 -2
  42. package/data/sprites/spider-icon.toml +9 -3
  43. package/data/sprites/study-icon.toml +10 -2
  44. package/package.json +5 -4
  45. package/src/adapters/memory/core.mjs +20 -0
  46. package/src/domain/ask-vocab.mjs +71 -0
  47. package/src/domain/ask.mjs +168 -0
  48. package/src/domain/game-config.mjs +11 -0
  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 +18 -0
  52. package/src/domain/mud-facts.mjs +15 -0
  53. package/src/domain/reference-pack.mjs +31 -7
  54. package/src/domain/router/drive.mjs +35 -9
  55. package/src/domain/router/registry.mjs +24 -4
  56. package/src/domain/router/resolver.mjs +102 -40
  57. package/src/domain/scene-compose.mjs +117 -0
  58. package/src/domain/spider-fly-world.mjs +37 -1
  59. package/src/domain/sprite-facts.mjs +0 -0
  60. package/src/domain/sprite-request.mjs +156 -0
  61. package/src/domain/sprite-templates.mjs +169 -20
  62. package/src/services/adventure-editor.mjs +8 -14
  63. package/src/services/adventure-viz.mjs +209 -157
  64. package/src/services/adventure.mjs +526 -391
  65. package/src/services/chat-page-viz.mjs +69 -25
  66. package/src/services/chat.mjs +200 -51
  67. package/src/services/code-explorer-viz.mjs +102 -62
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +68 -82
  70. package/src/services/ledger-viz.mjs +136 -67
  71. package/src/services/memory-panel-viz.mjs +62 -0
  72. package/src/services/mud-editor.mjs +10 -15
  73. package/src/services/mud-turn.mjs +6 -6
  74. package/src/services/mud-viz.mjs +1016 -208
  75. package/src/services/p2p-room.mjs +90 -23
  76. package/src/services/plan-pddl.mjs +3 -1
  77. package/src/services/plan-viz.mjs +123 -64
  78. package/src/services/research-viz.mjs +160 -108
  79. package/src/services/spider-fly-turn.mjs +15 -23
  80. package/src/services/spider-fly-viz.mjs +146 -161
  81. package/src/services/spider-fly.mjs +69 -11
  82. package/src/services/sprite-catalog-viz.mjs +414 -240
  83. package/src/services/viz-boot.mjs +71 -0
  84. package/src/services/viz-room-graph.mjs +203 -0
  85. package/src/services/viz-theme.mjs +90 -1
  86. package/src/services/viz-ticker.mjs +22 -0
  87. package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
  88. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  89. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  90. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  91. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  92. package/src/surfaces/web/memory-ask-browser.bundle.js +127 -124
  93. package/src/surfaces/web/memory-stats.mjs +11 -0
  94. package/src/surfaces/web/mud-browser-entry.mjs +71 -29
  95. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  96. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  97. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  98. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  99. package/src/surfaces/web/turn-session.mjs +120 -0
  100. package/src/tools/definitions.mjs +30 -0
  101. package/src/tools/handlers/index.mjs +6 -3
  102. package/src/tools/handlers/kit.mjs +19 -2
  103. package/src/tools/handlers/tmct-ask.mjs +11 -6
  104. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  105. package/src/tools/handlers/tmct-related.mjs +4 -4
  106. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  107. package/src/tools/memory-fallthrough.mjs +9 -2
  108. package/src/tools/server.mjs +25 -1
@@ -45,7 +45,7 @@
45
45
  // why the mechanism exists, just not exercised by any class shown here).
46
46
 
47
47
  import { classAncestorChain, SPRITE_REGISTRY } from "../domain/sprite-map.mjs";
48
- import { resolveSpriteAsset } from "../domain/sprite-templates.mjs";
48
+ import { resolveSpriteAsset, matchConstraints } from "../domain/sprite-templates.mjs";
49
49
  import { MATERIAL_PALETTE } from "../domain/sprite-materials.mjs";
50
50
  import { spriteFactRows } from "../domain/sprite-facts.mjs";
51
51
  import { SEED_TAXONOMY } from "../domain/spider-fly-world.mjs";
@@ -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
  }
@@ -220,7 +247,16 @@ export function parameterVariantsFor(template) {
220
247
  * generic root-fallback shape, not its own silhouette (this module's own
221
248
  * header names the 19 large-tier classes this applies to). Every
222
249
  * [parameters.*] value and every [match] variant renders through the real
223
- * resolveSpriteAsset with the exact property fact it declares. A class
250
+ * resolveSpriteAsset with the exact property facts it declares and a
251
+ * parameter sitting ON a [match] variant is rendered with BOTH facts, so a
252
+ * facing profile's mood swatch shows the profile wearing that mood and is
253
+ * labeled for the pair ("left + happy"). Rendering it on the parameter fact
254
+ * alone would resolve a different template altogether (the front-facing
255
+ * one) and label it as if it belonged to the profile. A value that a
256
+ * match-free template of the same class already offers is listed once, from
257
+ * that template: the facing pair and the `*-with-emotion.toml` file both
258
+ * carry all six moods, and one swatch per mood is the catalog's unit. A
259
+ * class
224
260
  * with templates but no plain among them also gets one extra swatch,
225
261
  * labeled `fallback: true`, showing exactly what the real resolver returns
226
262
  * for that class with NO taught fact — honest engine behaviour, never
@@ -237,19 +273,33 @@ export function tierSwatchesFor(cls, templates, registry, tier) {
237
273
  svg: resolveSpriteAsset(cls, [], [], templates, registry, { instanceKey: `${cls}-${tier}-plain` }),
238
274
  });
239
275
  }
276
+ const valueKey = (v) => `${v.paramName}:${v.rawValue}`;
277
+ const shownByUnmatchedTemplate = new Set(
278
+ forClass.filter((t) => !t.match).flatMap((t) => parameterVariantsFor(t).map(valueKey)),
279
+ );
240
280
  for (const t of forClass) {
241
- if (t.match) {
242
- const propertyFacts = [{ predicate: t.match.property, object: t.match.value }];
281
+ // A variant may require several facts at once (sprite-templates.mjs's own
282
+ // matchConstraints), so the swatch is rendered with EVERY fact its
283
+ // [match] asks for and labeled for the whole set — "left + moving" for a
284
+ // combined facing-and-pose file. A one-constraint variant reads exactly
285
+ // as it did before the plural spelling existed.
286
+ const constraints = matchConstraints(t);
287
+ const matchFact = constraints.map((c) => ({ predicate: c.property, object: c.value }));
288
+ const variantLabel = constraints.map((c) => c.value).join(" + ");
289
+ const variantKey = constraints.map((c) => c.value).join("-");
290
+ const matchPrefix = constraints.length ? `${variantLabel} + ` : "";
291
+ if (constraints.length) {
243
292
  swatches.push({
244
- tier, label: t.match.value, kind: "variant", property: t.match.property,
245
- svg: resolveSpriteAsset(cls, [], propertyFacts, templates, registry, { instanceKey: `${cls}-${tier}-match-${t.match.value}` }),
293
+ tier, label: variantLabel, kind: "variant", property: constraints.map((c) => c.property).join(" + "),
294
+ svg: resolveSpriteAsset(cls, [], matchFact, templates, registry, { instanceKey: `${cls}-${tier}-match-${variantKey}` }),
246
295
  });
247
296
  }
248
297
  for (const v of parameterVariantsFor(t)) {
249
- const propertyFacts = [{ predicate: v.property, object: v.rawValue }];
298
+ if (constraints.length && shownByUnmatchedTemplate.has(valueKey(v))) continue;
299
+ const propertyFacts = [...matchFact, { predicate: v.property, object: v.rawValue }];
250
300
  swatches.push({
251
- tier, label: v.rawValue, kind: "material", property: v.property, treatment: v.treatment,
252
- svg: resolveSpriteAsset(cls, [], propertyFacts, templates, registry, { instanceKey: `${cls}-${tier}-${v.paramName}-${v.rawValue}` }),
301
+ tier, label: `${matchPrefix}${v.rawValue}`, kind: "material", property: v.property, treatment: v.treatment,
302
+ svg: resolveSpriteAsset(cls, [], propertyFacts, templates, registry, { instanceKey: `${cls}-${tier}-${matchPrefix ? `${variantKey}-` : ""}${v.paramName}-${v.rawValue}` }),
253
303
  });
254
304
  }
255
305
  }
@@ -284,121 +334,95 @@ export function buildSpriteCatalogEntries({ iconTemplates = [], largeTemplates =
284
334
  });
285
335
  }
286
336
 
287
- // ---- scene composer (pure) ----
337
+ // ---- scene composer ----
288
338
  //
289
- // The free-text "there is a..." box (PLAN_GAMES_UPLIFT_V3.md's own precedent:
290
- // adventure-viz.mjs's roomSceneObjects/room-frame) over THIS page's own
291
- // already-real classes never a general NLU pass. extractSceneItems is the
292
- // one pure, unit-testable piece; the DOM index it's matched against
293
- // (className -> real swatch labels, read straight off this page's own
339
+ // The free-text "there is a..." box (adventure-viz.mjs's roomSceneObjects/
340
+ // room-frame is the same shape) over THIS page's own already-real classes.
341
+ // The parser itself is scene-compose.mjs, which resolves a typed class name
342
+ // through the real resolver; this page owns only the class index it is matched
343
+ // against (className -> real swatch labels, read straight off this page's own
294
344
  // already-rendered `.card`/`.swatch-label` markup at load time — see this
295
345
  // module's own header for why that beats re-embedding the same SVG data a
296
- // second time) is built client-side in the inline script below, since
297
- // walking rendered DOM has no meaning in this pure module.
298
-
299
- /** `text`'s lowercase word runs with their token index, the unit
300
- * extractSceneItems matches class names against — punctuation never fuses
301
- * two real words into one token nor splits one real word into two. */
302
- function tokenizeSceneText(text) {
303
- const tokens = [];
304
- const re = /[A-Za-z]+/g;
305
- let m;
306
- while ((m = re.exec(String(text ?? "")))) tokens.push({ word: m[0].toLowerCase() });
307
- return tokens;
346
+ // second time), which is built client-side, since walking rendered DOM has no
347
+ // meaning in this pure module.
348
+
349
+ export { extractSceneItems } from "../domain/scene-compose.mjs";
350
+
351
+ // ---- the catalog question lane ----
352
+ //
353
+ // There isn't one. Every catalog-shaped question the dock takes — "how many
354
+ // sprite classes are there", "list the sprite classes", "what parameters does
355
+ // a person sprite take", "what emotions does a person sprite accept" — is
356
+ // answered by the ordinary chat engine reading the same sprite-facts rows this
357
+ // page embeds, through the membership, count and property lanes it already runs
358
+ // for every other caller. The predicate spellings sprite-facts.mjs mints
359
+ // (mgx:take-parameter, mgx:accept-emotion, mgx:offer-variant) are what those
360
+ // lanes read, so the page hands the line over and renders what comes back.
361
+
362
+ // ---- the animated swatches (pure) ----
363
+ //
364
+ // Each card's large tier carries three animated swatches, one per axis the
365
+ // sprite tier actually varies on: mood (mgx:feels), facing (mgx:faces) and
366
+ // pose (mgx:pose). The page builds their frames client-side out of its own
367
+ // already-rendered swatches (see the inline script below — the same
368
+ // read-the-DOM posture the scene composer's class index takes), but the
369
+ // frame ORDER and the tempo are real logic worth pinning on their own, so
370
+ // they live here as pure functions the page splices in by `.toString()`.
371
+ // One delay constant serves all three, so the three can't drift apart into
372
+ // three tempos.
373
+ //
374
+ // A `frame` is `{svg, label}`. An empty return means there is nothing worth
375
+ // animating for that class, and the page leaves its static swatch alone.
376
+
377
+ /** The step between animated swatch frames, in milliseconds — the mood
378
+ * cycle's own established tempo, shared by the facing sweep and the pose
379
+ * toggle. */
380
+ export const CYCLE_FRAME_DELAY_MS = 800;
381
+
382
+ /** The turntable order the facing sweep steps through: one continuous
383
+ * rotation from the sprite's own left profile round to its right. `null`
384
+ * is the centre pose — the plain sprite, no mgx:faces fact at all — which
385
+ * sits in the middle of the sweep exactly where the geometry puts it, so
386
+ * the turn reads as a rotation rather than a jump back through front. */
387
+ export const FACING_TURN_ORDER = Object.freeze(["left", "half-left", null, "half-right", "right"]);
388
+
389
+ /** The mood cycle's frames: the class's plain sprite, then every mood it
390
+ * has a rendered swatch for, in the order the templates declare them. Two
391
+ * moods is the floor — one mood alternating with plain is a blink, not a
392
+ * cycle. Pure; self-contained (no outer refs), `.toString()`-splice safe. */
393
+ export function moodFrameSequence(plainFrame, moodFrames) {
394
+ const moods = (moodFrames || []).filter(Boolean);
395
+ if (moods.length < 2) return [];
396
+ return plainFrame ? [plainFrame, ...moods] : moods;
308
397
  }
309
398
 
310
- /** Every real catalog class the free-typed `text` names, in the order each
311
- * first appears, paired with the real material label (one of that SAME
312
- * class's own swatch labels never another class's, never a fabricated
313
- * one) immediately preceding it, or `null`. `classIndex` is
314
- * `{className: {materials}}` with `materials` keyed by lowercase label
315
- * (sprite-catalog-viz's own client-side `buildClassIndexFromDom` output, or
316
- * an equivalent test fixture) — a class name absent from `classIndex` can
317
- * never match, and a modifier word that isn't one of ITS matched class's
318
- * own material labels is silently dropped rather than guessed at, the same
319
- * honest-miss posture an unrecognized class name gets (an unmatched word,
320
- * e.g. "red" before a lamp with no red material, is never an error, just
321
- * silently not drawn). A multi-word class name (e.g. "body of water") is
322
- * checked, at every token position, before any shorter class that would
323
- * otherwise claim part of it — candidates are tried longest-word-count
324
- * first, so the longer name always wins the position it starts at. Pure. */
325
- export function extractSceneItems(text, classIndex) {
326
- const index = classIndex || {};
327
- const candidates = Object.keys(index)
328
- .map((name) => ({ name, words: name.toLowerCase().split(/\s+/).filter(Boolean) }))
329
- .filter((c) => c.words.length)
330
- .sort((a, b) => b.words.length - a.words.length || b.name.length - a.name.length);
331
- const tokens = tokenizeSceneText(text);
332
- const used = new Array(tokens.length).fill(false);
333
- const items = [];
334
- for (let i = 0; i < tokens.length; i += 1) {
335
- if (used[i]) continue;
336
- const hit = candidates.find(({ words }) => {
337
- if (i + words.length > tokens.length) return false;
338
- for (let k = 0; k < words.length; k += 1) {
339
- if (used[i + k] || tokens[i + k].word !== words[k]) return false;
340
- }
341
- return true;
342
- });
343
- if (!hit) continue;
344
- let materialLabel = null;
345
- if (i > 0 && !used[i - 1]) {
346
- const materials = index[hit.name]?.materials || {};
347
- const prevWord = tokens[i - 1].word;
348
- if (Object.prototype.hasOwnProperty.call(materials, prevWord)) {
349
- materialLabel = prevWord;
350
- used[i - 1] = true;
351
- }
399
+ /** The facing sweep's frames: `order` (FACING_TURN_ORDER) filtered down to
400
+ * the facings this class actually has a rendered swatch for, with
401
+ * `centreFrame` the plain, undirected sprite dropped into the centre
402
+ * slot. `facingFrames` is keyed by each swatch's own label, which is its
403
+ * real mgx:faces value. A class with one facing or none has no sweep.
404
+ * Pure; self-contained, `.toString()`-splice safe. */
405
+ export function turnFrameSequence(order, centreFrame, facingFrames) {
406
+ const byFacing = facingFrames || {};
407
+ const frames = [];
408
+ for (const facing of order || []) {
409
+ if (facing === null) {
410
+ if (centreFrame) frames.push({ svg: centreFrame.svg, label: "centre" });
411
+ } else if (byFacing[facing]) {
412
+ frames.push({ svg: byFacing[facing].svg, label: facing });
352
413
  }
353
- for (let k = 0; k < hit.words.length; k += 1) used[i + k] = true;
354
- items.push({ className: hit.name, materialLabel });
355
414
  }
356
- return items;
415
+ return frames.length >= 2 ? frames : [];
357
416
  }
358
417
 
359
- // ---- catalog question lane (pure) ----
360
-
361
- /** The closed set of catalog-shaped questions the chat dock answers straight
362
- * off THIS page's own embedded sprite-facts rows (src/domain/
363
- * sprite-facts.mjs's output), before a line ever reaches the full engine —
364
- * the same closed-template posture extractSceneItems takes for the scene
365
- * composer. Returns { answer, grounding } (grounding = how many real rows
366
- * the answer was read from) or null for ANY question this lane doesn't own,
367
- * including a catalog-shaped question about a class with no rows on record —
368
- * the caller falls through to the engine, whose refusal is the honest miss.
369
- * Self-contained (no outer refs), `.toString()`-splice safe. Pure. */
370
- export function answerSpriteQuestion(text, rows) {
371
- const all = Array.isArray(rows) ? rows : [];
372
- const q = String(text ?? "").trim().toLowerCase().replace(/[?.!\s]+$/, "");
373
-
374
- if (/^what (?:classes|sprites|sprite classes) (?:can you|do you|are there)(?:\s+(?:render|draw|show))?$/.test(q)) {
375
- const classes = all
376
- .filter((r) => r.predicate === "rdf:type" && r.object === "sprite class")
377
- .map((r) => String(r.subject).replace(/ sprite$/, ""));
378
- if (!classes.length) return null;
379
- const sample = classes.slice(0, 10).join(", ");
380
- return {
381
- answer: `${classes.length} sprite classes are on record — ${sample}, … (the catalog below lists every one).`,
382
- grounding: classes.length,
383
- };
384
- }
385
-
386
- const m = q.match(/^(?:what|which) parameters? (?:does|do|can|will) (?:a |an |the )?(.+?)(?: sprite)? (?:take|accept|use)s?$/);
387
- if (m) {
388
- const subject = `${m[1]} sprite`;
389
- const params = all.filter((r) => r.subject === subject && r.predicate === "mgx:take-parameter").map((r) => r.object);
390
- if (!params.length) return null;
391
- let grounding = params.length;
392
- const parts = params.map((p) => {
393
- const values = all.filter((r) => r.subject === subject && r.predicate === `mgx:accept-${p}`).map((r) => r.object);
394
- grounding += values.length;
395
- return values.length ? `${p} (${values.join(", ")})` : p;
396
- });
397
- const lead = params.length === 1 ? "one parameter" : `${params.length} parameters`;
398
- return { answer: `a ${subject} takes ${lead}: ${parts.join("; ")}.`, grounding };
399
- }
400
-
401
- return null;
418
+ /** The pose toggle's frames: the class's plain idle sprite alternating with
419
+ * its mgx:pose = "moving" one. Both have to be there — a class with no
420
+ * moving sprite has no second state to toggle into, and gets nothing
421
+ * rather than a still frame pretending to animate. Pure; self-contained,
422
+ * `.toString()`-splice safe. */
423
+ export function movingFrameSequence(idleFrame, movingFrame) {
424
+ if (!idleFrame || !movingFrame) return [];
425
+ return [{ svg: idleFrame.svg, label: "idle" }, { svg: movingFrame.svg, label: "moving" }];
402
426
  }
403
427
 
404
428
  // ---- rendering ----
@@ -418,7 +442,8 @@ function swatchHtml(s) {
418
442
  if (s.treatment) parts.push(`<span class="swatch-treat">&rarr; ${escapeHtml(s.treatment)} treatment</span>`);
419
443
  const title = s.property ? `${s.property} = ${s.label}` : s.label;
420
444
  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>`;
445
+ const property = s.property ? ` data-property="${escapeHtml(s.property)}"` : "";
446
+ return `<div class="${cls}"${property} title="${escapeHtml(title)}"><div class="swatch-img">${s.svg}</div><div class="swatch-caption">${parts.join("")}</div></div>`;
422
447
  }
423
448
 
424
449
  function tierRowHtml(tierName, swatches) {
@@ -438,14 +463,32 @@ function cardHtml(entry) {
438
463
  </article>`;
439
464
  }
440
465
 
441
- function sectionHtml(group, entries) {
466
+ /** One ancestor cluster inside a section: a heading that carries the
467
+ * ancestor's OWN resolved sprite (the proof the fallback has art to land
468
+ * on), then that cluster's cards. `ancestorSvg` is null for the trailing
469
+ * no-illustrated-ancestor bucket. */
470
+ function clusterHtml(cluster, ancestorSvg) {
471
+ const name = cluster.ancestor || "everything else";
472
+ const chip = ancestorSvg ? `<span class="cluster-chip">${ancestorSvg}</span>` : "";
473
+ return `<div class="cluster">
474
+ <h3 class="cluster-head">${chip}<span class="cluster-name">${escapeHtml(name)}</span><span class="count">${cluster.entries.length}</span></h3>
475
+ <div class="cards">${cluster.entries.map(cardHtml).join("")}</div>
476
+ </div>`;
477
+ }
478
+
479
+ function sectionHtml(group, entries, { clusterBy = null } = {}) {
442
480
  const rows = entries.filter((e) => e.group === group.id);
443
481
  if (!rows.length) return "";
444
482
  const note = group.note ? `<p class="section-note">${escapeHtml(group.note)}</p>` : "";
483
+ const body = clusterBy
484
+ ? clusterEntriesByAncestor(rows, clusterBy.spritedClasses)
485
+ .map((c) => clusterHtml(c, c.ancestor ? clusterBy.resolveChip(c.ancestor) : null))
486
+ .join("")
487
+ : `<div class="cards">${rows.map(cardHtml).join("")}</div>`;
445
488
  return `<section class="group" id="g-${group.id}" aria-label="${escapeHtml(group.label)}">
446
489
  <h2>${escapeHtml(group.label)} <span class="count">${rows.length}</span></h2>
447
490
  ${note}
448
- <div class="cards">${rows.map(cardHtml).join("")}</div>
491
+ ${body}
449
492
  </section>`;
450
493
  }
451
494
 
@@ -459,18 +502,29 @@ function sectionHtml(group, entries) {
459
502
  * ancestor chains.
460
503
  *
461
504
  * `spritesBundleAvailable: true` (ledger-viz.mjs's own ledgerBundleAvailable
462
- * idiom) is what adds the chat dock at all: the page then embeds the
463
- * sprite-facts rows (src/domain/sprite-facts.mjs, derived purely from the
505
+ * idiom) is what adds the two interactive panels at all: the page then embeds
506
+ * the sprite-facts rows (src/domain/sprite-facts.mjs, derived purely from the
464
507
  * same two template sets) and references the sibling
465
508
  * ./sprites-browser.bundle.js scripts/build-demo-site.mjs builds alongside
466
- * it. Left false, the page renders exactly as before no dock, no bundle
467
- * reference, nothing extra to 404 including the favicon links, since the
468
- * CLI's standalone export (also `spritesBundleAvailable: false`) can't carry
469
- * a dangling relative ./favicon.svg either. */
509
+ * it. Both panels need that bundle the dock for its chat session, the scene
510
+ * composer for the resolver its parser asks. Left false, the page is the
511
+ * catalog alone: no dock, no composer, no bundle reference, nothing extra to
512
+ * 404 including the favicon links, since the CLI's standalone export (also
513
+ * `spritesBundleAvailable: false`) can't carry a dangling relative
514
+ * ./favicon.svg either. */
470
515
  export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates = [], largeTemplates = [], factRows = [], spritesBundleAvailable = false } = {}) {
471
516
  const entries = buildSpriteCatalogEntries({ iconTemplates, largeTemplates, factRows });
472
517
  const totalSwatches = entries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
473
518
  const pageData = embedJson({ classCount: entries.length, swatchCount: totalSwatches });
519
+ // Ancestor clustering for the two big sections: the heading chip is the
520
+ // ancestor's OWN resolved sprite — visible proof that a new subclass
521
+ // attached there already has fallback art waiting for it.
522
+ const spritedClasses = new Set([...iconTemplates, ...largeTemplates].flatMap((t) => t?.classes || []));
523
+ const clusterBy = {
524
+ spritedClasses,
525
+ resolveChip: (ancestor) =>
526
+ resolveSpriteAsset(ancestor, [], [], largeTemplates, SPRITE_REGISTRY, { instanceKey: `cluster-${ancestor}` }),
527
+ };
474
528
  const dockRows = spritesBundleAvailable ? spriteFactRows({ iconTemplates, largeTemplates }) : [];
475
529
  const navHtml = CATALOG_GROUPS
476
530
  .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 +532,6 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
478
532
 
479
533
  const dockCss = !spritesBundleAvailable ? "" : `
480
534
  .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
535
  .dock-note { color: var(--muted); font-size: .8rem; margin: 0 0 .6rem; max-width: 72ch; }
484
536
  .docklog { display: flex; flex-direction: column; gap: .4rem; max-height: 240px; overflow-y: auto; margin-bottom: .5rem; }
485
537
  .docklog:empty { display: none; margin-bottom: 0; }
@@ -490,8 +542,8 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
490
542
  .docklog .a.grounded { border-left: 2px solid var(--taught); padding-left: .5rem; }
491
543
  .dockask { display: flex; align-items: center; gap: .5rem; }
492
544
  .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; }
545
+ .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; }
546
+ .dockask input:focus-visible { outline: 2px solid var(--corpus); outline-offset: 2px; }
495
547
  .dockask input:disabled { opacity: .5; }
496
548
  .dock-status { font-size: .72rem; color: var(--muted); margin-top: .55rem; }
497
549
  `;
@@ -499,7 +551,7 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
499
551
  const dockHtml = !spritesBundleAvailable ? "" : `<div class="dockwrap">
500
552
  <section class="panel" aria-label="Ask about the sprite catalog">
501
553
  <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>
554
+ <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
555
  <div class="docklog" id="dockLog" aria-live="polite"></div>
504
556
  <form class="dockask" id="dockForm">
505
557
  <span class="prompt mono">tmct&gt;</span>
@@ -509,22 +561,56 @@ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates =
509
561
  </form>
510
562
  <div class="pills" id="dockPills" role="group" aria-label="quick questions to ask">
511
563
  <button type="button" class="pill" data-q="what parameters does a person sprite take?">person parameters</button>
512
- <button type="button" class="pill" data-q="what parameters does a cabinet sprite take?">cabinet parameters</button>
513
- <button type="button" class="pill" data-q="what classes can you render?">classes on record</button>
564
+ <button type="button" class="pill" data-q="what emotions does a person sprite accept?">person emotions</button>
565
+ <button type="button" class="pill" data-q="what materials does a cabinet sprite accept?">cabinet materials</button>
566
+ <button type="button" class="pill" data-q="how many sprite classes are there?">classes on record</button>
514
567
  <button type="button" class="pill" data-q="what is a portrait sprite?">about the portrait sprite</button>
515
568
  </div>
516
569
  <div class="dock-status mono" id="dockStatus">loading the engine&hellip;</div>
517
570
  </section>
518
571
  </div>`;
519
572
 
573
+ // The bundle carries the scene composer's parser as well as the dock's
574
+ // session, and the catalog script below calls it directly, so it loads ahead
575
+ // of that script rather than beside the dock's own wiring at the end.
576
+ const spriteBundleScript = !spritesBundleAvailable ? "" : `<script src="./sprites-browser.bundle.js"></script>`;
577
+
578
+ // The composer resolves a typed class name through the real resolver, which
579
+ // reaches the page in that bundle. Without it there is nothing to type into,
580
+ // so the panel stays out rather than rendering a box that can't answer.
581
+ const composerHtml = !spritesBundleAvailable ? "" : `<div class="composer">
582
+ <section class="panel compose-panel" aria-label="Describe a scene">
583
+ <h2>describe a scene</h2>
584
+ <form class="composeform" id="composeForm">
585
+ <span class="prompt mono">there is a</span>
586
+ <input id="composeq" type="text" autocomplete="off"
587
+ placeholder="red lamp, a doctor with a hat, and a cabinet"
588
+ aria-label="Continue the sentence: there is a&hellip;">
589
+ </form>
590
+ <div class="pills" id="composePills" role="group" aria-label="quick words to add">
591
+ <button type="button" class="pill" data-fill="doctor">doctor</button>
592
+ <button type="button" class="pill" data-fill="hat">hat</button>
593
+ <button type="button" class="pill" data-fill="wood cabinet">wood cabinet</button>
594
+ <button type="button" class="pill" data-fill="glass lamp">glass lamp</button>
595
+ <button type="button" class="pill" data-fill="cat">cat</button>
596
+ <button type="button" class="pill" data-fill="garden">garden</button>
597
+ </div>
598
+ </section>
599
+ <section class="panel viewer-panel" aria-label="The composed scene">
600
+ <h2>the scene</h2>
601
+ <div class="scene-frame" id="sceneFrame">
602
+ <div class="scene-row" id="sceneRow" aria-live="polite"></div>
603
+ <span class="empty-note" id="sceneEmpty">nothing recognized yet. Try &ldquo;a doctor with a hat, and a cabinet&rdquo;.</span>
604
+ </div>
605
+ </section>
606
+ </div>`;
607
+
520
608
  const dockScripts = !spritesBundleAvailable ? "" : `<script>
521
609
  const SPRITE_CHAT = ${embedJson({ rows: dockRows })};
522
610
  </script>
523
- <script src="./sprites-browser.bundle.js"></script>
524
611
  <script>
525
612
  (function () {
526
613
  "use strict";
527
- const answerSpriteQuestion = ${answerSpriteQuestion.toString()};
528
614
  const dockLogEl = document.getElementById("dockLog");
529
615
  const dockFormEl = document.getElementById("dockForm");
530
616
  const dockqEl = document.getElementById("dockq");
@@ -573,12 +659,11 @@ const SPRITE_CHAT = ${embedJson({ rows: dockRows })};
573
659
  if (!q) return;
574
660
  dockqEl.value = "";
575
661
  addDockLine("u", q);
576
- const local = answerSpriteQuestion(q, SPRITE_CHAT.rows);
577
- 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; }
662
+ if (!session) { addDockLine("a miss", "the engine is still loading. Try again in a moment."); return; }
579
663
  withLock(async () => {
580
664
  const result = await session.turn(q);
581
- addDockLine(result.record && result.record.miss ? "a miss" : "a", result.answer);
665
+ const missed = !!(result.record && result.record.miss);
666
+ addDockLine(missed ? "a miss" : "a grounded", result.answer);
582
667
  });
583
668
  });
584
669
 
@@ -594,9 +679,9 @@ const SPRITE_CHAT = ${embedJson({ rows: dockRows })};
594
679
  await tryLoadWink();
595
680
  session = await tmctSprites.createSpriteCatalogSession({ factRows: SPRITE_CHAT.rows });
596
681
  dockqEl.disabled = false;
597
- dockStatusEl.textContent = SPRITE_CHAT.rows.length + " sprite facts on record \\u2014 ask away, or use a quick question.";
682
+ dockStatusEl.textContent = SPRITE_CHAT.rows.length + " sprite facts on record. Ask away, or use a quick question.";
598
683
  } catch (err) {
599
- dockStatusEl.textContent = "the chat engine failed to load \\u2014 the catalog below still works.";
684
+ dockStatusEl.textContent = "the chat engine failed to load. The catalog below still works.";
600
685
  console.error("tmct sprites: dock boot failed", err);
601
686
  }
602
687
  })();
@@ -614,49 +699,67 @@ ${spritesBundleAvailable ? `<link rel="icon" href="./favicon.svg" type="image/sv
614
699
  <link rel="apple-touch-icon" href="./apple-touch-icon.png">` : ""}
615
700
  <style>
616
701
  ${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; }
702
+ html { background: var(--ai-canvas); }
703
+ /* Creative-app chrome: a dark document bar in BOTH themes (the app-window
704
+ read), panels and a recessed canvas that follow the theme, and a
705
+ transparency checker under every drawn swatch so each sprite proves it
706
+ holds its own boundary with nothing behind it. */
707
+ :root {
708
+ --ai-bar: #26272B; --ai-bar-ink: #E7E5DF;
709
+ --ai-panel: #EDECE8; --ai-panel-hi: #F4F3F0; --ai-canvas: #DFDEDA; --ai-edge: #C8C6C0;
710
+ --checker: rgba(0, 0, 0, .07);
711
+ }
712
+ @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); } }
713
+ :root[data-theme="dark"] { --ai-panel: #26272B; --ai-panel-hi: #2D2E33; --ai-canvas: #1A1B1F; --ai-edge: #34363C; --checker: rgba(255, 255, 255, .055); }
714
+ :root[data-theme="light"] { --ai-panel: #EDECE8; --ai-panel-hi: #F4F3F0; --ai-canvas: #DFDEDA; --ai-edge: #C8C6C0; --checker: rgba(0, 0, 0, .07); }
715
+ body { margin: 0; background: var(--ai-canvas); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
619
716
  .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; }
717
+ main { max-width: 1240px; margin: 0 auto; padding: 0 1.2rem 3rem; }
718
+ button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--corpus); outline-offset: 2px; }
719
+ .appbar { display: flex; align-items: flex-end; gap: 1rem; margin: 0 -1.2rem; padding: .6rem 1.2rem 0; background: var(--ai-bar); }
720
+ .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; }
721
+ .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; }
722
+ .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; }
723
+ .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; }
724
+ .jump:hover { border-color: var(--corpus); color: var(--corpus); }
725
+ .jump .count { color: var(--muted); }
726
+ .filter { margin-left: auto; display: flex; align-items: center; gap: .4rem; }
727
+ .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; }
728
+ .filter .n { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); white-space: nowrap; }
729
+ .composer { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1.2rem 0 1.3rem; }
625
730
  @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; }
731
+ .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); }
732
+ .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
733
  .composeform { display: flex; align-items: center; gap: .5rem; }
629
734
  .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; }
735
+ .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
736
  .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; }
737
+ .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; }
738
+ .pill:hover { border-color: var(--corpus); color: var(--corpus); }
636
739
  .scene-frame { min-height: 5.6rem; display: flex; align-items: center; }
637
740
  .scene-row { display: flex; flex-wrap: wrap; gap: .8rem; align-items: flex-start; width: 100%; }
741
+ .scene-row:empty { display: none; }
638
742
  .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; }
743
+ .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); }
744
+ .scene-sprite svg { width: 100%; height: 100%; display: block; filter: var(--sprite-pop); }
641
745
  .scene-label { font-size: .7rem; text-align: center; color: var(--ink); margin-top: .3rem; line-height: 1.2; }
642
746
  .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; }
747
+ .group { margin: 2.2rem 0; content-visibility: auto; contain-intrinsic-size: 800px; }
748
+ .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; }
749
+ .group h2 .count { font-size: .7rem; color: var(--muted); font-weight: 400; }
653
750
  .section-note { color: var(--muted); font-size: .82rem; margin: 0 0 .8rem; max-width: 68ch; }
751
+ .cluster { margin: 1rem 0 1.5rem; }
752
+ .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; }
753
+ .cluster-head .count { font-weight: 400; opacity: .75; }
754
+ .cluster-head::after { content: ""; flex: 1; height: 1px; background: var(--ai-edge); }
755
+ .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); }
756
+ .cluster-chip svg { width: 100%; height: 100%; display: block; filter: var(--sprite-pop); }
654
757
  .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; }
758
+ .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
759
  .card[hidden] { display: none; }
657
- .card-name { font-size: .92rem; margin: 0 0 .3rem; font-weight: 600; }
760
+ .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
761
  .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; }
762
+ .chain-link { padding: .04rem .35rem; border: 1px solid var(--ai-edge); border-radius: 99px; }
660
763
  .chain-link.own { border-color: var(--taught); color: var(--taught); }
661
764
  .chain-arrow { color: var(--muted); opacity: .6; }
662
765
  .chain-more { font-style: italic; opacity: .75; }
@@ -665,47 +768,28 @@ ${THEME_TOKENS_CSS}
665
768
  .tier-label { font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
666
769
  .swatches { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .25rem; }
667
770
  .swatch { width: 64px; text-align: center; }
771
+ .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
772
  .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; }
773
+ .swatch.icon .swatch-img { width: 36px; height: 36px; margin: 0 auto; }
774
+ .swatch-img svg { width: 100%; height: 100%; display: block; filter: var(--sprite-pop); }
671
775
  .swatch.fallback { opacity: .55; }
672
- .swatch.fallback .swatch-img { outline: 1px dashed var(--line); outline-offset: 2px; }
776
+ .swatch.fallback .swatch-img { border-style: dashed; }
673
777
  .swatch-caption { font-family: ${MONO_STACK}; font-size: .58rem; color: var(--muted); line-height: 1.25; margin-top: .15rem; word-break: break-word; }
674
778
  .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; } }
779
+ .swatch.cycle .swatch-img { border-color: var(--taught); cursor: pointer; display: block; }
780
+ .swatch.cycle .swatch-img:hover { border-color: var(--corpus); }
781
+ .swatch.cycle .swatch-caption { color: var(--taught); }
782
+ 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); }
783
+ @media (prefers-reduced-motion: no-preference) { .jump, .swatch, .pill { transition: border-color .12s ease, color .12s ease, opacity .12s ease; } }
677
784
  ${dockCss}</style>
678
785
  </head>
679
786
  <body>
680
787
  <main>
681
- <div class="eyebrow">tmct &middot; the sprite library</div>
682
- <h1>Sprites</h1>
683
- <div class="composer">
684
- <section class="panel compose-panel" aria-label="Describe a scene">
685
- <h2>describe a scene</h2>
686
- <form class="composeform" id="composeForm">
687
- <span class="prompt mono">there is a</span>
688
- <input id="composeq" type="text" autocomplete="off"
689
- placeholder="red lamp, a doctor with a hat, and a cabinet"
690
- aria-label="Continue the sentence: there is a&hellip;">
691
- </form>
692
- <div class="pills" id="composePills" role="group" aria-label="quick words to add">
693
- <button type="button" class="pill" data-fill="doctor">doctor</button>
694
- <button type="button" class="pill" data-fill="hat">hat</button>
695
- <button type="button" class="pill" data-fill="wood cabinet">wood cabinet</button>
696
- <button type="button" class="pill" data-fill="glass lamp">glass lamp</button>
697
- <button type="button" class="pill" data-fill="cat">cat</button>
698
- <button type="button" class="pill" data-fill="garden">garden</button>
699
- </div>
700
- </section>
701
- <section class="panel viewer-panel" aria-label="The composed scene">
702
- <h2>the scene</h2>
703
- <div class="scene-frame" id="sceneFrame">
704
- <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>
706
- </div>
707
- </section>
708
- </div>
788
+ <header class="appbar">
789
+ <h1>Sprites</h1>
790
+ <span class="doc-sub">tmct &middot; the sprite library</span>
791
+ </header>
792
+ ${composerHtml}
709
793
  ${dockHtml}
710
794
  <div class="topbar">
711
795
  <nav aria-label="Jump to group">${navHtml}</nav>
@@ -714,12 +798,13 @@ ${dockCss}</style>
714
798
  <span class="n mono" id="qcount"></span>
715
799
  </div>
716
800
  </div>
717
- ${CATALOG_GROUPS.map((g) => sectionHtml(g, entries)).join("")}
801
+ ${CATALOG_GROUPS.map((g) => sectionHtml(g, entries, { clusterBy: g.id === GROUP_PERSON || g.id === GROUP_OBJECT ? clusterBy : null })).join("")}
718
802
  <footer class="page">${entries.length} classes &middot; ${totalSwatches} swatches &middot; icon tier 44px, sprite tier 400px</footer>
719
803
  </main>
720
804
  <script>
721
805
  const SPRITE_CATALOG = ${pageData};
722
806
  </script>
807
+ ${spriteBundleScript}
723
808
  <script>
724
809
  (function () {
725
810
  "use strict";
@@ -747,10 +832,10 @@ const SPRITE_CATALOG = ${pageData};
747
832
  // THIS page's own already-rendered card and swatch-label markup (this
748
833
  // module's own header explains why: never a second embedded copy of the
749
834
  // same swatch data), so the composed scene below only ever shows a sprite
750
- // this same page already proved the resolver draws.
835
+ // this same page already proved the resolver draws. Which class a typed
836
+ // word names is the bundle's extractSceneItems, which asks the real
837
+ // resolver — the page never matches a class name itself.
751
838
  const esc = ${escapeHtml.toString()};
752
- const tokenizeSceneText = ${tokenizeSceneText.toString()};
753
- const extractSceneItems = ${extractSceneItems.toString()};
754
839
 
755
840
  function buildClassIndexFromDom() {
756
841
  const index = {};
@@ -777,42 +862,131 @@ const SPRITE_CATALOG = ${pageData};
777
862
  return index;
778
863
  }
779
864
 
780
- const classIndex = buildClassIndexFromDom();
781
- const composeqEl = document.getElementById("composeq");
782
- const composeFormEl = document.getElementById("composeForm");
783
- const composePillsEl = document.getElementById("composePills");
784
- const sceneRowEl = document.getElementById("sceneRow");
785
- const sceneEmptyEl = document.getElementById("sceneEmpty");
786
-
787
- function renderScene(text) {
788
- const items = extractSceneItems(text, classIndex);
789
- if (!items.length) {
790
- sceneRowEl.innerHTML = "";
791
- sceneEmptyEl.hidden = false;
792
- return;
865
+ function wireSceneComposer() {
866
+ const composeqEl = document.getElementById("composeq");
867
+ if (!composeqEl) return;
868
+ const extractSceneItems = tmctSprites.extractSceneItems;
869
+ const composeFormEl = document.getElementById("composeForm");
870
+ const composePillsEl = document.getElementById("composePills");
871
+ const sceneRowEl = document.getElementById("sceneRow");
872
+ const sceneEmptyEl = document.getElementById("sceneEmpty");
873
+ const classIndex = buildClassIndexFromDom();
874
+
875
+ function renderScene(text) {
876
+ const items = extractSceneItems(text, classIndex);
877
+ if (!items.length) {
878
+ sceneRowEl.innerHTML = "";
879
+ sceneEmptyEl.hidden = false;
880
+ return;
881
+ }
882
+ sceneEmptyEl.hidden = true;
883
+ sceneRowEl.innerHTML = items.map((item) => {
884
+ const entry = classIndex[item.className];
885
+ if (!entry) return "";
886
+ const svg = (item.materialLabel && entry.materials[item.materialLabel]) || entry.defaultSvg || "";
887
+ const label = item.materialLabel ? item.materialLabel + " " + item.className : item.className;
888
+ return '<div class="scene-card"><div class="scene-sprite">' + svg + '</div><div class="scene-label">' + esc(label) + "</div></div>";
889
+ }).join("");
793
890
  }
794
- sceneEmptyEl.hidden = true;
795
- sceneRowEl.innerHTML = items.map((item) => {
796
- const entry = classIndex[item.className];
797
- if (!entry) return "";
798
- const svg = (item.materialLabel && entry.materials[item.materialLabel]) || entry.defaultSvg || "";
799
- const label = item.materialLabel ? item.materialLabel + " " + item.className : item.className;
800
- return '<div class="scene-card"><div class="scene-sprite">' + svg + '</div><div class="scene-label">' + esc(label) + "</div></div>";
801
- }).join("");
891
+
892
+ composeqEl.addEventListener("input", () => renderScene(composeqEl.value));
893
+ composeFormEl.addEventListener("submit", (e) => { e.preventDefault(); renderScene(composeqEl.value); });
894
+ composePillsEl.addEventListener("click", (e) => {
895
+ const btn = e.target.closest(".pill");
896
+ if (!btn) return;
897
+ const phrase = btn.dataset.fill || "";
898
+ const current = composeqEl.value.trim();
899
+ composeqEl.value = current ? current + ", a " + phrase : phrase;
900
+ composeqEl.focus();
901
+ renderScene(composeqEl.value);
902
+ });
903
+ renderScene("");
904
+ }
905
+ wireSceneComposer();
906
+
907
+ // ---- the animated swatches — a card's large tier gains one moving
908
+ // swatch per axis its own templates actually vary on: mood (mgx:feels),
909
+ // facing (mgx:faces) and pose (mgx:pose). Frames are the card's own
910
+ // already-rendered swatches (the same read-the-DOM posture classIndex
911
+ // takes above — never a second embedded copy), ordered by the pure
912
+ // *FrameSequence functions spliced in below, and one shared interval
913
+ // steps every cycle in sync at one shared delay. The mood cycle leads the
914
+ // row; the facing sweep and the pose toggle take over the static plain
915
+ // and happy swatches' own places rather than adding cells, so the row
916
+ // keeps its width. Reduced motion disables the auto-step only; each frame
917
+ // is a button, so a click or Enter always advances one frame by hand.
918
+ // Injected AFTER buildClassIndexFromDom ran, so a moving frame never
919
+ // leaks into the composer's material index.
920
+ const CYCLE_FRAME_DELAY_MS = ${CYCLE_FRAME_DELAY_MS};
921
+ const FACING_TURN_ORDER = ${embedJson(FACING_TURN_ORDER)};
922
+ const MOOD_PROPERTY = "mgx:feels";
923
+ const FACING_PROPERTY = "mgx:faces";
924
+ const POSE_PROPERTY = "mgx:pose";
925
+ const moodFrameSequence = ${moodFrameSequence.toString()};
926
+ const turnFrameSequence = ${turnFrameSequence.toString()};
927
+ const movingFrameSequence = ${movingFrameSequence.toString()};
928
+ const cycleSteppers = [];
929
+
930
+ function frameFromSwatch(swatch) {
931
+ const img = swatch && swatch.querySelector(".swatch-img");
932
+ const label = swatch && swatch.querySelector(".swatch-label");
933
+ if (!img || !label) return null;
934
+ return { svg: img.innerHTML, label: label.textContent.trim() };
802
935
  }
803
936
 
804
- composeqEl.addEventListener("input", () => renderScene(composeqEl.value));
805
- composeFormEl.addEventListener("submit", (e) => { e.preventDefault(); renderScene(composeqEl.value); });
806
- composePillsEl.addEventListener("click", (e) => {
807
- const btn = e.target.closest(".pill");
808
- if (!btn) return;
809
- const phrase = btn.dataset.fill || "";
810
- const current = composeqEl.value.trim();
811
- composeqEl.value = current ? current + ", a " + phrase : phrase;
812
- composeqEl.focus();
813
- renderScene(composeqEl.value);
814
- });
815
- renderScene("");
937
+ function makeCycleSwatch(frames, ariaLabel, kind) {
938
+ const holder = document.createElement("div");
939
+ holder.className = "swatch large cycle cycle-" + kind;
940
+ holder.innerHTML = '<button type="button" class="swatch-img" aria-label="' + esc(ariaLabel)
941
+ + '"></button><div class="swatch-caption"><span class="swatch-label"></span></div>';
942
+ const frameImgEl = holder.querySelector(".swatch-img");
943
+ const frameLabelEl = holder.querySelector(".swatch-label");
944
+ let frameIndex = 0;
945
+ const showFrame = () => {
946
+ frameImgEl.innerHTML = frames[frameIndex].svg;
947
+ frameLabelEl.textContent = frames[frameIndex].label;
948
+ };
949
+ const stepFrame = () => { frameIndex = (frameIndex + 1) % frames.length; showFrame(); };
950
+ frameImgEl.addEventListener("click", stepFrame);
951
+ showFrame();
952
+ cycleSteppers.push(stepFrame);
953
+ return holder;
954
+ }
955
+
956
+ for (const card of cards) {
957
+ const swatchRow = card.querySelector('.tier-row[data-tier="large"] .swatches');
958
+ if (!swatchRow) continue;
959
+ const cls = card.dataset.cls;
960
+ const rows = Array.from(swatchRow.querySelectorAll(".swatch"))
961
+ .map((el) => ({ el, property: el.dataset.property || "", frame: frameFromSwatch(el) }))
962
+ .filter((r) => r.frame);
963
+ const baseSwatch = swatchRow.querySelector(".swatch.plain, .swatch.fallback");
964
+ const baseFrame = frameFromSwatch(baseSwatch);
965
+
966
+ const moodRows = rows.filter((r) => r.property === MOOD_PROPERTY);
967
+ const moodFrames = moodFrameSequence(baseFrame && { svg: baseFrame.svg, label: cls }, moodRows.map((r) => r.frame));
968
+ if (moodFrames.length) {
969
+ swatchRow.insertBefore(makeCycleSwatch(moodFrames, "step through the " + cls + " expressions", "mood"), swatchRow.firstChild);
970
+ }
971
+
972
+ const facingFrames = {};
973
+ for (const r of rows) { if (r.property === FACING_PROPERTY) facingFrames[r.frame.label] = r.frame; }
974
+ const turnFrames = turnFrameSequence(FACING_TURN_ORDER, baseFrame, facingFrames);
975
+ if (turnFrames.length && baseSwatch) {
976
+ swatchRow.replaceChild(makeCycleSwatch(turnFrames, "watch the " + cls + " turn through every direction", "turn"), baseSwatch);
977
+ }
978
+
979
+ const movingRow = rows.find((r) => r.property === POSE_PROPERTY && r.frame.label === "moving");
980
+ const movingFrames = movingFrameSequence(baseFrame, movingRow && movingRow.frame);
981
+ if (movingFrames.length) {
982
+ const happyRow = moodRows.find((r) => r.frame.label === "happy");
983
+ swatchRow.replaceChild(makeCycleSwatch(movingFrames, "watch the " + cls + " move", "moving"), (happyRow || movingRow).el);
984
+ }
985
+ }
986
+ const reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
987
+ if (cycleSteppers.length && !reducedMotion) {
988
+ setInterval(() => { for (const stepFrame of cycleSteppers) stepFrame(); }, CYCLE_FRAME_DELAY_MS);
989
+ }
816
990
  })();
817
991
  </script>
818
992
  ${dockScripts}