@polycode-projects/the-mechanical-code-talker 2.8.0 → 2.8.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.
package/README.md CHANGED
@@ -120,10 +120,11 @@ tmct> /exit
120
120
 
121
121
  **[Try it live in your browser →](https://polycode-projects.gitlab.io/the-mechanical-code-talker/)**
122
122
  is a real, interactive chat demo running client-side. Your browser runs the
123
- actual query engine against a small example codebase, no server, no install.
124
- The page leads with the **memory ledger**: every fact as a readable sentence,
125
- drill by clicking the terms inside them, and an in-page chat whose answers
126
- focus the ledger.
123
+ actual query engine, no server, no install. The page opens with a live chat
124
+ you can talk to directly, then embeds the **memory ledger** (every fact as a
125
+ readable sentence, drill by clicking the terms inside them), a Towers-of-Hanoi
126
+ plan replayed move by move, and the two live games above — spider-fly and the
127
+ text adventure — each with a link to open it full-screen.
127
128
 
128
129
  From a clone, two build scripts regenerate that demo so you can check it
129
130
  offline before it deploys. The example graph, the ledger page, and the
@@ -183,7 +184,7 @@ resolves to a real graph traversal or declines cleanly:
183
184
  (*because/although/while*), conditionals, and false-premise flags ("why
184
185
  does X still import Y" when it no longer does).
185
186
 
186
- The full catalog with measured coverage lives in `CAPABILITIES_2.0.3.md` and
187
+ The full catalog with measured coverage lives in `CAPABILITIES_2.7.12.md` and
187
188
  the `BENCHMARK_*.md` reports.
188
189
 
189
190
  **Response finishing.** Before an answer prints, it is segmented into typed
@@ -371,7 +372,7 @@ the plan as a self-contained animated page (see "Two more surfaces" above).
371
372
 
372
373
  ## Play a game with it
373
374
 
374
- Two games run inside an ordinary chat session, no setup.
375
+ Three games run inside an ordinary chat session, no setup.
375
376
 
376
377
  **Guess the number.** Say `I'm thinking of a number between 1 and 100` and
377
378
  tmct guesses by narrowing an interval — answer `higher`, `lower`, or
@@ -393,6 +394,19 @@ a blocked action declines by name, and one of the household moves on its own
393
394
  schedule whether you are there to see it or not. The full worked mystery is
394
395
  pinned step by step in `test/corpus/games/adventure.jsonl`.
395
396
 
397
+ **Two agents, planning against each other.** Say `play spider and fly` (or
398
+ `watch the spider and the fly`) and tmct runs both sides itself — neither is
399
+ player-controlled. A spider hunts a fly across a 10×10 web; each side only
400
+ believes what it can currently see (`vision_radius`, tunable), a fly wanders
401
+ when nothing threatens it and evades when something does, a spider avoids
402
+ other spiders, chases what it believes it sees, and builds a web when it
403
+ holds position. Mass is real: both sides waste away each turn they don't
404
+ eat, and a spider gains exactly the mass of what it catches. You can address
405
+ either side directly (`@spider the fly is east`) to feed it a belief — true
406
+ or false — and watch a wrong assertion mislead it for as long as the real
407
+ target stays out of sight. `tmct.toml`'s `[games.spider-fly]` table tunes
408
+ every rate; the full mechanic is pinned in `test/corpus/games/spider-fly.jsonl`.
409
+
396
410
  ## Learning on a miss
397
411
 
398
412
  A question tmct cannot ground is still an honest miss — but on the cleanest
@@ -1035,18 +1049,18 @@ service. The LLM agent stays outside tmct, as the no-LLM ethos requires.
1035
1049
 
1036
1050
  ## Measuring it
1037
1051
 
1038
- What the 2.0.3 cycle measured, on 2026-07-16. Each figure links to its method
1039
- and carries, in the same row, the caveat that changes what it means. The full
1040
- tables, judge scores, and transcripts are in the linked write-ups.
1052
+ What the 2.7.11/2.7.12 cycle measured, on 2026-07-19. Each figure links to its
1053
+ method and carries, in the same row, the caveat that changes what it means.
1054
+ The full tables, judge scores, and transcripts are in the linked write-ups.
1041
1055
 
1042
- | What it does | Result (2.0.3) | Read the number with this | Method |
1056
+ | What it does | Result (2.7.12) | Read the number with this | Method |
1043
1057
  |---|---|---|---|
1044
- | Multi-hop entailment | 219/219 chat cases and 80/80 kernel cases, 0% fabrication, all six bands pass | 50 of the 219 greens (23%) are graded against a declared floor, not the classical answer. INF-C2's 20/20 grades that the engine answers contradictory memory without fabricating, never that it detects the clash. | `BENCHMARK_INFERENCE_2.0.3.md` |
1045
- | Tool-call planning | 56/56 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. All 11 C2 cases pass, so the ladder now has more headroom than the case set exercises. | `BENCHMARK_AGENT_2.0.3.md` |
1046
- | Groundedness | Every answer carries a source, and an empty graph reports itself empty (`bootstrap-empty` 2.000/2). Judge-scored groundedness 1.857/2 over 98 cases. | The 1.857 is judged (`claude-haiku-4-5-20251001`, `judge-prompt-v1`) at N=1 over 9 of 23 construction shapes, so read it as indicative. The judge runs in the offline eval harness, never in the product. | `BENCHMARK_CEFR_ENGLISH_2.0.3.md` |
1047
- | Abstention (the honest miss) | 0% fabrication across 299 inference rows and 0% hallucination across 168 agent rows | Structural, not a tuned threshold. tmct abstains because nothing matched, so the rows test a property of a no-model design rather than a score. | `BENCHMARK_INFERENCE_2.0.3.md`, `BENCHMARK_AGENT_2.0.3.md` |
1048
- | Determinism | Byte-identical on rerun, 0 verdict changes across 299 inference rows against the prior cycle, a 109-case replay in 877ms at $0 per turn | A property of the no-model pipeline. | `BENCHMARK_INFERENCE_2.0.3.md`, `CAPABILITIES_2.0.3.md` |
1049
- | Dialogue robustness (role and polarity) | An adversarial persona sweep could not force a single role or polarity inversion in 55 probes active/passive, forward/reverse, negation and the converse trap all compiled to the correct canonical shape | The same five-frame sweep (~200 probes) surfaced 25 dialogue dead-ends, eight of them confidently wrong, all from words dropped before the parser rather than from the reasoning. | `BENCHMARK_CONVERSATION_2.0.3.md` |
1058
+ | Multi-hop entailment | 379/379 chat cases and 100/100 kernel cases, 0% fabrication, all bands pass | The case set is unchanged from 2.6.0 (same templates, same counts) the one real move this cycle is INF-4's ceiling-graded count dropping 35→30, five cases that now pass as genuine capability instead of against the declared honest-miss floor. | `BENCHMARK_INFERENCE_2.7.12.md` |
1059
+ | Tool-call planning | 68/68 cases, 100% plan-completion, 100% result-completion, 0% hallucination, every rung A0→C2 | Goal driver. 2.6.0 gated at TOOL-7 (62/66, 94%) this cycle's router uplift (a guarded RECOVER step, a tied-candidate composer) cleared it, a real capability move, not a ruler change. | `BENCHMARK_AGENT_2.7.12.md` |
1060
+ | Groundedness | Every answer carries a source, and an empty graph reports itself empty. Judge-scored mean 1.809/2 over 138 cases, 5 hard fails, 136/138 tier-1. | Judged (`claude-haiku-4-5-20251001`, `judge-prompt-v2`) at N=1. The judge prompt moved v1→v2 since 2.6.0, so this is a measurement, not a clean lever comparison against the prior cycle. The judge runs in the offline eval harness, never in the product. | `BENCHMARK_CEFR_ENGLISH_2.7.12.md` |
1061
+ | Abstention (the honest miss) | 0% fabrication across 479 inference rows (379 chat + 100 kernel) and 0% hallucination across 272 agent rows | Structural, not a tuned threshold. tmct abstains because nothing matched, so the rows test a property of a no-model design rather than a score. | `BENCHMARK_INFERENCE_2.7.12.md`, `BENCHMARK_AGENT_2.7.12.md` |
1062
+ | Determinism | Byte-identical on rerun a 379-case `--replay` clean across 2 runs, no LLM, no network, $0 per turn | A property of the no-model pipeline. | `BENCHMARK_INFERENCE_2.7.12.md` |
1063
+ | Dialogue robustness (persona sweep) | A 6-persona sweep (textbook logician, casual newcomer, new developer, adversarial sceptic, returning user, planning user) fixed 25 of the prior cycle's 29 routed findings (21 clean, 4 with a residual noted); 4 remain broken, 2 in a shape distinct from the original complaint | Free exploration across all six personas surfaced roughly 60 fresh findings beyond the ratchet check — the single highest-signal pattern: tmct's own suggested repair text was itself frequently broken when followed verbatim (since fixed, see `HANDOVER.md`). | `BENCHMARK_CONVERSATION_2.7.11.md` |
1050
1064
 
1051
1065
  Three offline benchmark rigs live in a clone (they are not in the npm
1052
1066
  package). Each replays a committed case set through the real product and
@@ -665,6 +665,7 @@ export const CORPUSES = {
665
665
  ["hope", "/r/IsA", "emotion"], ["hope", "/r/CapableOf", "inspire_action"],
666
666
  ["surprise", "/r/IsA", "emotion"], ["surprise", "/r/CapableOf", "cause_astonishment"],
667
667
  ["pride", "/r/IsA", "emotion"], ["pride", "/r/CapableOf", "bring_satisfaction"],
668
+ ["sad", "/r/IsA", "emotion"],
668
669
  ["name", "/r/UsedFor", "identifying_a_person"], ["name", "/r/PartOf", "identity"],
669
670
  ["song", "/r/HasA", "word"], ["song", "/r/UsedFor", "entertaining"],
670
671
  ["music", "/r/HasA", "song"], ["music", "/r/CapableOf", "bring_joy"],
@@ -622,6 +622,7 @@
622
622
  {"start":"/c/en/surprise","rel":"/r/CapableOf","end":"/c/en/cause_astonishment","weight":1,"surfaceText":"[[surprise]] CapableOf [[cause astonishment]]"}
623
623
  {"start":"/c/en/pride","rel":"/r/IsA","end":"/c/en/emotion","weight":1,"surfaceText":"[[pride]] IsA [[emotion]]"}
624
624
  {"start":"/c/en/pride","rel":"/r/CapableOf","end":"/c/en/bring_satisfaction","weight":1,"surfaceText":"[[pride]] CapableOf [[bring satisfaction]]"}
625
+ {"start":"/c/en/sad","rel":"/r/IsA","end":"/c/en/emotion","weight":1,"surfaceText":"[[sad]] IsA [[emotion]]"}
625
626
  {"start":"/c/en/name","rel":"/r/UsedFor","end":"/c/en/identifying_a_person","weight":1,"surfaceText":"[[name]] UsedFor [[identifying a person]]"}
626
627
  {"start":"/c/en/name","rel":"/r/PartOf","end":"/c/en/identity","weight":1,"surfaceText":"[[name]] PartOf [[identity]]"}
627
628
  {"start":"/c/en/song","rel":"/r/HasA","end":"/c/en/word","weight":1,"surfaceText":"[[song]] HasA [[word]]"}
@@ -67,9 +67,9 @@
67
67
  "tool": "corpus/tier2/generate.mjs"
68
68
  },
69
69
  "file": "human.jsonl",
70
- "facts": 664,
71
- "bytes": 81430,
72
- "sha256": "040e6d1f6761a29a080f7825ff8aae60caf879125c1f7a1480340ef04129bcd5",
70
+ "facts": 665,
71
+ "bytes": 81540,
72
+ "sha256": "7710c88c4c96a73fbbae19fe1989b0be40fed5ed40205016e90cc221bcb79607",
73
73
  "license": "MPL-2.0"
74
74
  },
75
75
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -23,6 +23,7 @@ import { fileURLToPath } from "node:url";
23
23
  import { join, dirname } from "node:path";
24
24
  import { parse as parseToml } from "smol-toml";
25
25
  import { expandMaterialReferences } from "../../domain/sprite-materials.mjs";
26
+ import { expandExpressionReferences } from "../../domain/sprite-expressions.mjs";
26
27
 
27
28
  const HERE = dirname(fileURLToPath(import.meta.url));
28
29
  /** The sprite-tier template directory (data, not code) — every *.toml file
@@ -41,7 +42,10 @@ export const SPRITE_LARGE_TEMPLATES_DIR = join(HERE, "..", "..", "..", "data", "
41
42
  * never go blank because one hand-authored sprite has a typo.
42
43
  * test/adapters/sprite-large-template-files.test.mjs parses the real files
43
44
  * directly (not through this lenient loader) so a broken file still fails a
44
- * test loudly. */
45
+ * test loudly. Material references expand first, expression references
46
+ * second — the two indirections are independent (a template can carry
47
+ * either, both, or neither) so the order between them never matters to the
48
+ * result, only that both run before a template reaches a caller. */
45
49
  export function readSpriteLargeTemplateFiles(dir = SPRITE_LARGE_TEMPLATES_DIR) {
46
50
  let files;
47
51
  try {
@@ -58,5 +62,5 @@ export function readSpriteLargeTemplateFiles(dir = SPRITE_LARGE_TEMPLATES_DIR) {
58
62
  continue; // one malformed file never takes the others down
59
63
  }
60
64
  }
61
- return expandMaterialReferences(templates);
65
+ return expandExpressionReferences(expandMaterialReferences(templates));
62
66
  }
@@ -0,0 +1,120 @@
1
+ // sprite-expressions.mjs — the shared face-fragment scheme the sprite tier's
2
+ // mgx:feels parameter draws from, one small dot/curve-eyes-plus-mouth
3
+ // drawing per curated emotion word, mirroring sprite-materials.mjs's own
4
+ // by-name-reference shape exactly: a frozen palette of raw treatment data,
5
+ // and a pure expand function that turns a short by-name reference in a
6
+ // template's own [parameters.emotion.values] table into the full
7
+ // substitution — so a face fragment is authored ONCE here and referenced by
8
+ // name from every data/sprites-large/*-with-emotion.toml file, rather than
9
+ // six-plus files each hand-copying the same eyes-and-mouth paths.
10
+ //
11
+ // The one real difference from a material treatment: a hex triple needs no
12
+ // context to drop into a template's placeholder, but a face fragment does —
13
+ // classes vary enough in head geometry (a dog's head circle is
14
+ // cx=7 cy=9 r=3.6, a cat's is cx=9 cy=9.4 r=4.4, a person's is
15
+ // cx=12 cy=6.6 r=3.3) that one universal anchor position would misplace the
16
+ // eyes on at least one of them. So EXPRESSION_PALETTE's own fragments are
17
+ // authored in a UNIT face — eyes and mouth drawn as if the head were a
18
+ // radius-1 circle centred on the origin — and expandExpressionReferences
19
+ // wraps the matched fragment in `<g transform="translate({cx} {cy})
20
+ // scale({scale})">` using the SAME template's own required `[face]` table
21
+ // (cx/cy/scale) to place and size it for that one class's real head. A
22
+ // template with a [parameters.emotion] table but no [face] table is left
23
+ // unexpanded (spriteTemplateProblems flags the pairing as a real problem —
24
+ // see sprite-templates.mjs — this function itself stays defensive, never
25
+ // throws on a malformed template).
26
+ //
27
+ // Every fragment uses a fixed dark ink colour rather than currentColor —
28
+ // a face needs to read against ANY body fill (a gold lamp's flame stays a
29
+ // fixed amber for the same reason: what the face/flame looks like doesn't
30
+ // come from what the body is made of or coloured).
31
+ //
32
+ // Convention for every future `*-with-emotion.toml` file (content-authoring
33
+ // agents: read this before copying the shape) — `{{FACE}}` is always the
34
+ // LAST child before `</svg>`, so the resolved face fragment paints over
35
+ // the body/highlight shapes beneath it, never the reverse.
36
+
37
+ /** The dark ink colour every face fragment draws its eyes/mouth in,
38
+ * regardless of the sprite's own body fill — the same fixed-colour
39
+ * reasoning lamp.toml's flame already uses (sprite-templates.mjs's own
40
+ * header explains the precedent). */
41
+ const FACE_INK = "#000000";
42
+
43
+ /** One `<g>`-ready fragment per curated emotion word, dot/curve eyes plus a
44
+ * mouth curve, authored in a unit face (radius 1, centred on the origin) —
45
+ * expandExpressionReferences positions and scales it per class. Distinct
46
+ * by more than mouth curvature alone: scared/surprised both open wide, but
47
+ * scared's mouth stays a small tense "o" while surprised's is a large
48
+ * dropped-jaw oval; calm's eyes are closed contented arcs where happy's
49
+ * are open dots, so no two of the six collapse into the same read at a
50
+ * glance. */
51
+ export const EXPRESSION_PALETTE = Object.freeze({
52
+ happy:
53
+ `<circle cx="-0.4" cy="-0.15" r="0.11" fill="${FACE_INK}" opacity="0.85"/>`
54
+ + `<circle cx="0.4" cy="-0.15" r="0.11" fill="${FACE_INK}" opacity="0.85"/>`
55
+ + `<path d="M -0.4 0.35 Q 0 0.68 0.4 0.35" fill="none" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`,
56
+ sad:
57
+ `<circle cx="-0.4" cy="-0.1" r="0.1" fill="${FACE_INK}" opacity="0.85"/>`
58
+ + `<circle cx="0.4" cy="-0.1" r="0.1" fill="${FACE_INK}" opacity="0.85"/>`
59
+ + `<path d="M -0.38 0.55 Q 0 0.3 0.38 0.55" fill="none" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`,
60
+ angry:
61
+ `<path d="M -0.58 -0.32 L -0.22 -0.16" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`
62
+ + `<path d="M 0.58 -0.32 L 0.22 -0.16" stroke="${FACE_INK}" stroke-width="0.09" stroke-linecap="round" opacity="0.85"/>`
63
+ + `<circle cx="-0.32" cy="-0.02" r="0.09" fill="${FACE_INK}" opacity="0.85"/>`
64
+ + `<circle cx="0.32" cy="-0.02" r="0.09" fill="${FACE_INK}" opacity="0.85"/>`
65
+ + `<path d="M -0.36 0.5 L 0.36 0.42" stroke="${FACE_INK}" stroke-width="0.1" stroke-linecap="round" opacity="0.85"/>`,
66
+ scared:
67
+ `<circle cx="-0.4" cy="-0.12" r="0.2" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
68
+ + `<circle cx="-0.4" cy="-0.12" r="0.07" fill="${FACE_INK}" opacity="0.85"/>`
69
+ + `<circle cx="0.4" cy="-0.12" r="0.2" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
70
+ + `<circle cx="0.4" cy="-0.12" r="0.07" fill="${FACE_INK}" opacity="0.85"/>`
71
+ + `<circle cx="0" cy="0.42" r="0.09" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`,
72
+ surprised:
73
+ `<circle cx="-0.38" cy="-0.12" r="0.17" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
74
+ + `<circle cx="-0.38" cy="-0.12" r="0.06" fill="${FACE_INK}" opacity="0.85"/>`
75
+ + `<circle cx="0.38" cy="-0.12" r="0.17" fill="none" stroke="${FACE_INK}" stroke-width="0.07" opacity="0.85"/>`
76
+ + `<circle cx="0.38" cy="-0.12" r="0.06" fill="${FACE_INK}" opacity="0.85"/>`
77
+ + `<ellipse cx="0" cy="0.42" rx="0.16" ry="0.22" fill="${FACE_INK}" opacity="0.7"/>`,
78
+ calm:
79
+ `<path d="M -0.5 -0.1 Q -0.4 -0.2 -0.3 -0.1" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`
80
+ + `<path d="M 0.3 -0.1 Q 0.4 -0.2 0.5 -0.1" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`
81
+ + `<path d="M -0.3 0.4 Q 0 0.5 0.3 0.4" fill="none" stroke="${FACE_INK}" stroke-width="0.08" stroke-linecap="round" opacity="0.85"/>`,
82
+ });
83
+
84
+ /** The mgx:feels property name every emotion-bearing template's
85
+ * `[parameters.emotion]` table names — sprite-templates.mjs never needs
86
+ * to know this constant exists (it reads `param.property` generically),
87
+ * this module only uses it to recognise which parameter table is ITS OWN
88
+ * to expand, the same role sprite-materials.mjs's `param.placeholders`
89
+ * shape check plays for a material table. */
90
+ const FEELS_PROPERTY = "mgx:feels";
91
+
92
+ /**
93
+ * Every `[parameters.emotion].values` string entry, in every template,
94
+ * expanded from a curated emotion-word KEY (e.g. "happy") to the full
95
+ * `<g transform="translate(cx cy) scale(scale)">…</g>` substitution for
96
+ * THIS template's own required `[face]` table — but only for a template
97
+ * whose `[parameters.emotion]` table names `mgx:feels` as its property
98
+ * (sprite-templates.mjs's `param.property`) and that also carries a
99
+ * `[face]` table; a template missing either is left alone (defensive, never
100
+ * throws — spriteTemplateProblems is what flags that pairing as a real
101
+ * authoring mistake). A values entry naming no known palette word is left
102
+ * as the plain string it was, the same never-guess posture
103
+ * expandMaterialReferences already uses for an unknown treatment name.
104
+ * Pure; `templates` is read only.
105
+ */
106
+ export function expandExpressionReferences(templates, palette = EXPRESSION_PALETTE) {
107
+ return (templates || []).map((t) => {
108
+ const emotion = t?.parameters?.emotion;
109
+ if (!emotion?.property || emotion.property !== FEELS_PROPERTY) return t;
110
+ if (!emotion.placeholder || !emotion.values || !t.face) return t;
111
+ const { cx, cy, scale } = t.face;
112
+ const values = {};
113
+ for (const [key, value] of Object.entries(emotion.values)) {
114
+ values[key] = typeof value === "string" && palette[value]
115
+ ? `<g transform="translate(${cx} ${cy}) scale(${scale})">${palette[value]}</g>`
116
+ : value;
117
+ }
118
+ return { ...t, parameters: { ...t.parameters, emotion: { ...emotion, values } } };
119
+ });
120
+ }
@@ -96,20 +96,31 @@ function fillFromValue(svg, param, value) {
96
96
  return null;
97
97
  }
98
98
 
99
- /** Fill a parameterized template's `svg` from the first of its own
100
- * `[parameters.*]` whose observed property value maps to a substitution
101
- * or null when no property fact names a mapped value (never a guess). */
102
- function parameterizedFill(template, propertyFacts) {
99
+ /** Fill a parameterized template's `svg` from EVERY one of its own
100
+ * `[parameters.*]` whose observed property value maps to a substitution,
101
+ * accumulating each successful fill onto the RUNNING svg string rather
102
+ * than stopping at the first hit — so a template declaring both e.g.
103
+ * `[parameters.material]` and `[parameters.emotion]` fills both dimensions
104
+ * in one pass, one parameter's substitution never undoing another's.
105
+ * Returns null when not even one parameter filled (never a guess, and
106
+ * never a half-filled template with a leftover placeholder token) — the
107
+ * caller (resolveAtTerm) falls through to a less specific template exactly
108
+ * as it did before this function tried more than one dimension. */
109
+ function parameterizedFillAll(template, propertyFacts) {
110
+ let svg = template.svg;
111
+ let filledCount = 0;
103
112
  for (const param of Object.values(template.parameters || {})) {
104
113
  const values = param?.values || {};
105
114
  const hit = (propertyFacts || []).find(
106
115
  (f) => f.predicate === param.property && Object.prototype.hasOwnProperty.call(values, f.object),
107
116
  );
108
117
  if (!hit) continue;
109
- const filled = fillFromValue(template.svg, param, values[hit.object]);
110
- if (filled) return filled;
118
+ const filled = fillFromValue(svg, param, values[hit.object]);
119
+ if (!filled) continue;
120
+ svg = filled;
121
+ filledCount += 1;
111
122
  }
112
- return null;
123
+ return filledCount > 0 ? svg : null;
113
124
  }
114
125
 
115
126
  /** Resolve ONE class term (no ancestor walk here — the caller repeats this
@@ -123,7 +134,7 @@ function resolveAtTerm(term, propertyFacts, templates) {
123
134
  if (matched) return matched.svg;
124
135
  for (const t of candidates) {
125
136
  if (t.match || !t.parameters) continue;
126
- const filled = parameterizedFill(t, propertyFacts);
137
+ const filled = parameterizedFillAll(t, propertyFacts);
127
138
  if (filled) return filled;
128
139
  }
129
140
  const plain = candidates.find((t) => !t.match && !t.parameters);
@@ -174,7 +185,12 @@ export function resolveSpriteAsset(className, factRows, propertyFacts, templates
174
185
  * names a `property` and exactly one of `placeholder`/`placeholders` (every
175
186
  * token named appears in `svg`), its `values` map is non-empty and every
176
187
  * entry matches the shape its own `placeholder`/`placeholders` choice
177
- * expects, and a `[match]` table names both `property` and `value`. */
188
+ * expects, a `[match]` table names both `property` and `value`, and
189
+ * `[face]`/`[parameters.emotion]` are always declared TOGETHER — a face
190
+ * anchor with nothing to select it, or an emotion parameter with nowhere to
191
+ * position its face fragment, is a real authoring mistake either way
192
+ * (sprite-expressions.mjs's own header explains why the face fragment
193
+ * needs the pairing). */
178
194
  export function spriteTemplateProblems(template) {
179
195
  const problems = [];
180
196
  const t = template || {};
@@ -218,5 +234,11 @@ export function spriteTemplateProblems(template) {
218
234
  if (t.match && (!t.match.property || t.match.value === undefined)) {
219
235
  problems.push("match is missing property or value");
220
236
  }
237
+ if (t.face && !t.parameters?.emotion) {
238
+ problems.push("face is declared without parameters.emotion — a face anchor with nothing to select it is dead data");
239
+ }
240
+ if (t.parameters?.emotion && !t.face) {
241
+ problems.push("parameters.emotion is declared without a face — an emotion parameter needs its own [face] anchor to position the fragment it fills");
242
+ }
221
243
  return problems;
222
244
  }
@@ -0,0 +1,398 @@
1
+ // chat-page-viz.mjs — "Talk to it"'s full-screen destination
2
+ // (PLAN_GAMES_UPLIFT_V3.md Part C.4 item 1): a self-contained document shaped
3
+ // exactly like spider-fly-viz.mjs/adventure-viz.mjs's own page-builders — one
4
+ // inlined <style> importing viz-theme.mjs's shared tokens, behaviour as an
5
+ // inlined IIFE — reusing the SAME chat engine the home page's embedded
6
+ // #tmct-chat widget runs (chat-browser.bundle.js's globalThis.tmctChat,
7
+ // chat-seed.json, public/reference-pack/), referenced by the same same-origin
8
+ // relative paths chat-ui.mjs already uses. This module does not import or
9
+ // re-render chat-ui.mjs — it is a second, independent consumer of the same
10
+ // engine/bundle, the same relationship spider-fly-viz.mjs's own inlined chat
11
+ // dock already has with createSpiderFlySession (both call the shared
12
+ // session.turn(line), neither reimplements it).
13
+ //
14
+ // What's actually new here, versus the embedded widget: full-screen
15
+ // post-ChatGPT-style chrome (centered message column, bottom-fixed composer,
16
+ // message bubbles) and this page's own signature element — a quiet
17
+ // per-message PROVENANCE CHIP (taught / corpus / entailed) next to every
18
+ // grounded answer, tmct's actual differentiator versus an LLM chatbot. The
19
+ // chip is read straight off the SAME "(source: ...)" citation chat.mjs's own
20
+ // factPhrase/renderFactLine convention already appends to most answers (see
21
+ // e.g. `dog is a kind of animal (source: corpus:conceptnet ...)` — already
22
+ // asserted by e2e/pages-chat.test.mjs against the embedded widget), never a
23
+ // second provenance computation against memory internals: `provBucketFor`
24
+ // (ledger-viz.mjs) is spliced in unmodified and applied to whatever citation
25
+ // text the answer already carries, so this page's chip and the ledger's own
26
+ // per-row color always agree by construction. A miss carries no citation and
27
+ // gets no chip — the absence IS the signal, matching the product's own
28
+ // honest-miss posture rather than inventing a fourth "trust tier" to badge it.
29
+ //
30
+ // renderChatHtml() is pure: no I/O, deterministic output for identical
31
+ // input. scripts/build-demo-site.mjs calls it directly and writes the result
32
+ // to public/chat.html, after chat-browser.bundle.js/chat-seed.json already
33
+ // exist (both built earlier in that same script, for the embedded widget).
34
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
35
+ import { provBucketFor } from "./ledger-viz.mjs";
36
+
37
+ const DEFAULT_TITLE = "tmct — talk to it";
38
+
39
+ // Reads left-to-right as the reader meets each tier: what you taught it
40
+ // directly, what its bundled corpus already knew, what it worked out itself.
41
+ // Mirrors ledger-viz.mjs's own PROVS legend labels verbatim, so the same
42
+ // three words mean the same thing on both pages.
43
+ const PROV_LEGEND = [
44
+ ["taught", "you taught"],
45
+ ["corpus", "corpus"],
46
+ ["entailed", "entailed"],
47
+ ];
48
+
49
+ /** This page's own tiny CSS-class fold — ledger-viz.mjs's `--entail` CSS
50
+ * custom property (viz-theme.mjs's own token name) is one letter short of
51
+ * the "entailed" bucket provBucketFor returns; every other tier's token name
52
+ * already matches its bucket name exactly. */
53
+ function provKey(tier) {
54
+ return tier === "entailed" ? "entail" : tier;
55
+ }
56
+
57
+ /**
58
+ * The chip tier for one chat turn — "taught" | "corpus" | "entailed" | null
59
+ * (no chip: a miss, or an answer that grounds in nothing citable, e.g. /help
60
+ * output or a focus-set confirmation).
61
+ *
62
+ * Reads the SAME "(source: ...)" citation(s) the visible answer text already
63
+ * carries, via `bucketFor` (the page's own spliced copy of ledger-viz.mjs's
64
+ * `provBucketFor`, injected rather than imported so this function stays
65
+ * `.toString()`-splice safe — the same discipline spider-fly-viz.mjs's own
66
+ * `threadCellsForSpiderPlan` holds its own injected `geometry` to).
67
+ * `provBucketFor`'s fallback branch (empty sourceTypes) classifies a raw
68
+ * legacy provenance TAG string directly — exactly the string chat.mjs embeds
69
+ * after "(source: " (memory/trust.mjs's `provenanceTagToSource` parses the
70
+ * identical shape) — so no Fact/individuals lookup is needed here at all.
71
+ *
72
+ * A teach-lane confirmation ("noted — remembered: ...") cites no fact yet to
73
+ * read back — nothing has been asked of it — so its own `record.via ===
74
+ * "assert"` stands in for a citation: the user just taught this, "taught" is
75
+ * the whole point of the reply.
76
+ *
77
+ * Multiple citations (a multi-step proof chain) resolve by the same
78
+ * taught-over-entailed-over-corpus precedence provBucketFor's own header
79
+ * documents for one fact's sourceTypes, applied across citations instead:
80
+ * a chain resting on any taught premise reads as "taught" overall.
81
+ *
82
+ * Self-contained (no outer refs beyond the injected `bucketFor`),
83
+ * `.toString()`-splice safe.
84
+ */
85
+ export function provenanceChipFor(answer, record, bucketFor) {
86
+ if (!record || record.miss) return null;
87
+ const cites = [...String(answer || "").matchAll(/\(source: ([^)]+)\)/g)].map((m) => m[1]);
88
+ if (!cites.length) return record.via === "assert" ? "taught" : null;
89
+ const buckets = cites.map((c) => bucketFor([], c));
90
+ if (buckets.includes("taught")) return "taught";
91
+ if (buckets.includes("entailed")) return "entailed";
92
+ return "corpus";
93
+ }
94
+
95
+ /** The self-contained "talk to it" full-screen page. Pure — the same output
96
+ * for the same `title` every time; every other piece of state (the session,
97
+ * every message, every chip) is computed live in the browser once the
98
+ * sibling chat bundle loads, exactly as the embedded widget already works. */
99
+ export function renderChatHtml({ title = DEFAULT_TITLE } = {}) {
100
+ const legendHtml = PROV_LEGEND.map(
101
+ ([key, label]) => `<span class="legend-item"><i class="dot dot-${provKey(key)}"></i>${escapeHtml(label)}</span>`,
102
+ ).join("");
103
+
104
+ return `<!doctype html>
105
+ <html lang="en">
106
+ <head>
107
+ <meta charset="utf-8">
108
+ <meta name="viewport" content="width=device-width, initial-scale=1">
109
+ <title>${escapeHtml(title)}</title>
110
+ <style>
111
+ ${THEME_TOKENS_CSS}
112
+ html, body { height: 100%; }
113
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; flex-direction: column; overflow: hidden; }
114
+ .mono { font-family: ${MONO_STACK}; }
115
+ button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
116
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
117
+ a { color: var(--corpus); }
118
+
119
+ .topbar { flex: 0 0 auto; display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
120
+ .brand { display: flex; align-items: baseline; gap: .55rem; }
121
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
122
+ .topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
123
+ .legend { display: flex; gap: .8rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
124
+ .legend-item { display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; }
125
+ .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
126
+ .dot-taught { background: var(--taught); } .dot-corpus { background: var(--corpus); } .dot-entail { background: var(--entail); }
127
+
128
+ main.chatMain { flex: 1 1 auto; overflow-y: auto; overscroll-behavior: contain; }
129
+ .messages { max-width: 720px; margin: 0 auto; padding: 1.2rem 1rem 1.6rem; display: flex; flex-direction: column; gap: .15rem; min-height: 100%; }
130
+
131
+ .msg-row { display: flex; flex-direction: column; margin: .35rem 0; max-width: 100%; }
132
+ .msg-row.user { align-items: flex-end; }
133
+ .msg-row.assistant { align-items: flex-start; }
134
+ .msg-row.system { align-items: center; margin: .6rem 0; }
135
+
136
+ .bubble { max-width: 80%; padding: .55rem .85rem; border-radius: 16px; white-space: pre-wrap; word-break: break-word; }
137
+ .bubble.user { background: var(--ink); color: var(--bg); border-bottom-right-radius: 4px; }
138
+ .bubble.user .prompt { opacity: .6; font-family: ${MONO_STACK}; font-size: .82em; }
139
+ .bubble.assistant { background: var(--card); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
140
+ .bubble.assistant.pending { color: var(--muted); font-style: italic; }
141
+ .bubble.assistant.miss { color: var(--muted); border-style: dashed; }
142
+ .bubble.system { max-width: 100%; background: none; border: none; color: var(--muted); font-family: ${MONO_STACK}; font-size: .76rem; text-align: center; padding: .2rem .5rem; }
143
+
144
+ .provchip { align-self: flex-start; margin: .28rem 0 0 .15rem; font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .05em; text-transform: uppercase; padding: .12rem .55rem; border-radius: 99px; cursor: default; }
145
+ .pc-taught { color: var(--taught); background: var(--taught-soft); }
146
+ .pc-corpus { color: var(--corpus); background: var(--corpus-soft); }
147
+ .pc-entail { color: var(--entail); background: var(--entail-soft); }
148
+
149
+ form.composer { flex: 0 0 auto; border-top: 1px solid var(--line); background: var(--bg); }
150
+ .composer-inner { max-width: 720px; margin: 0 auto; padding: .7rem 1rem; display: flex; gap: .5rem; align-items: center; }
151
+ .composer-inner input { flex: 1; min-width: 0; font-family: ${SERIF_STACK}; font-size: .95rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 20px; padding: .55rem 1rem; }
152
+ .composer-inner input::placeholder { color: var(--muted); }
153
+ .composer-inner input:disabled { opacity: .55; }
154
+ .composer-inner button[type="submit"] { width: 2.3rem; height: 2.3rem; border-radius: 50%; background: var(--ink); color: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 1rem; flex: 0 0 auto; }
155
+ .composer-inner button[type="submit"]:disabled { opacity: .4; cursor: default; }
156
+ .statusline { max-width: 720px; margin: 0 auto; padding: 0 1.1rem .6rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
157
+
158
+ @media (max-width: 560px) {
159
+ .legend { display: none; }
160
+ .bubble { max-width: 92%; }
161
+ }
162
+ @media (prefers-reduced-motion: reduce) {
163
+ * { scroll-behavior: auto !important; }
164
+ }
165
+ </style>
166
+ </head>
167
+ <body>
168
+ <header class="topbar">
169
+ <div class="brand">
170
+ <span class="eyebrow">tmct</span>
171
+ <h1>Talk to it</h1>
172
+ </div>
173
+ <div class="legend" aria-hidden="true">${legendHtml}</div>
174
+ </header>
175
+ <main class="chatMain">
176
+ <div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
177
+ </main>
178
+ <form class="composer" id="composer">
179
+ <div class="composer-inner">
180
+ <input id="composerInput" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
181
+ placeholder="loading the engine…" aria-label="Ask tmct something" disabled>
182
+ <button type="submit" id="composerSend" aria-label="Send" disabled>&#8594;</button>
183
+ </div>
184
+ </form>
185
+ <div class="statusline" id="status">loading the engine&hellip;</div>
186
+ <script src="./chat-browser.bundle.js"></script>
187
+ <script>
188
+ (function () {
189
+ "use strict";
190
+ const provBucketFor = ${provBucketFor.toString()};
191
+ const provenanceChipFor = ${provenanceChipFor.toString()};
192
+ const el = (id) => document.getElementById(id);
193
+
194
+ const messagesEl = el("messages");
195
+ const composerForm = el("composer");
196
+ const inputEl = el("composerInput");
197
+ const sendBtn = el("composerSend");
198
+ const statusEl = el("status");
199
+
200
+ function scrollToEnd() {
201
+ messagesEl.parentElement.scrollTop = messagesEl.parentElement.scrollHeight;
202
+ }
203
+
204
+ function addSystemLine(text) {
205
+ const row = document.createElement("div");
206
+ row.className = "msg-row system";
207
+ const bubble = document.createElement("div");
208
+ bubble.className = "bubble system";
209
+ bubble.textContent = text;
210
+ row.appendChild(bubble);
211
+ messagesEl.appendChild(row);
212
+ scrollToEnd();
213
+ }
214
+
215
+ function addUserBubble(text) {
216
+ const row = document.createElement("div");
217
+ row.className = "msg-row user";
218
+ const bubble = document.createElement("div");
219
+ bubble.className = "bubble user";
220
+ const prompt = document.createElement("span");
221
+ prompt.className = "prompt";
222
+ prompt.textContent = "tmct> ";
223
+ bubble.appendChild(prompt);
224
+ bubble.appendChild(document.createTextNode(text));
225
+ row.appendChild(bubble);
226
+ messagesEl.appendChild(row);
227
+ scrollToEnd();
228
+ return row;
229
+ }
230
+
231
+ function addPendingAssistantBubble() {
232
+ const row = document.createElement("div");
233
+ row.className = "msg-row assistant";
234
+ const bubble = document.createElement("div");
235
+ bubble.className = "bubble assistant pending";
236
+ bubble.textContent = "thinking\\u2026";
237
+ row.appendChild(bubble);
238
+ messagesEl.appendChild(row);
239
+ scrollToEnd();
240
+ return row;
241
+ }
242
+
243
+ const CHIP_TITLE = {
244
+ taught: "you taught tmct this fact directly",
245
+ corpus: "grounded in tmct's bundled corpus",
246
+ entailed: "tmct derived this from taught facts, not read back verbatim",
247
+ };
248
+
249
+ function settleAssistantBubble(row, answer, record) {
250
+ const bubble = row.querySelector(".bubble");
251
+ bubble.classList.remove("pending");
252
+ const missed = !record || Boolean(record.miss);
253
+ bubble.classList.toggle("miss", missed);
254
+ bubble.textContent = answer;
255
+ const tier = provenanceChipFor(answer, record, provBucketFor);
256
+ if (tier) {
257
+ const key = tier === "entailed" ? "entail" : tier;
258
+ const chip = document.createElement("span");
259
+ chip.className = "provchip pc-" + key;
260
+ chip.title = CHIP_TITLE[tier] || "";
261
+ chip.textContent = tier;
262
+ row.appendChild(chip);
263
+ }
264
+ scrollToEnd();
265
+ }
266
+
267
+ // ---- engine boot -------------------------------------------------------
268
+ // Mirrors chat-ui.mjs's own boot sequence against the SAME bundle/seed/pack
269
+ // — a second consumer of the shared engine, not a fork of it.
270
+ const WINK_LOAD_TIMEOUT_MS = 8000;
271
+ const timeoutAfter = (ms, reason) => new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
272
+
273
+ let winkStatus = "pending";
274
+ async function tryLoadWink() {
275
+ try {
276
+ const [{ default: winkNLP }, { default: model }] = await Promise.race([
277
+ Promise.all([import("wink-nlp"), import("wink-eng-lite-web-model")]),
278
+ timeoutAfter(WINK_LOAD_TIMEOUT_MS, "wink-nlp CDN load timed out"),
279
+ ]);
280
+ window.tmctChat.registerWinkModel(() => ({ winkNLP, model }));
281
+ winkStatus = "loaded";
282
+ } catch (err) {
283
+ winkStatus = "unavailable";
284
+ console.warn("tmct chat: wink-nlp CDN load failed, continuing without the lemma/POS tier", err);
285
+ }
286
+ }
287
+
288
+ let seedPayload = null;
289
+ let seedFacts = 0;
290
+ async function fetchSeed() {
291
+ try {
292
+ const res = await fetch("./chat-seed.json");
293
+ if (!res.ok) throw new Error("HTTP " + res.status);
294
+ seedPayload = await res.json();
295
+ seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
296
+ } catch (err) {
297
+ seedPayload = null;
298
+ console.warn("tmct chat: chat-seed.json unavailable — starting unseeded", err);
299
+ }
300
+ }
301
+ const cloneSeed = () => {
302
+ if (!seedPayload) return null;
303
+ try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
304
+ };
305
+ function newSession() {
306
+ return window.tmctChat.createChatSession({ seedPayload: cloneSeed(), vocabSeeded: Boolean(seedPayload) });
307
+ }
308
+
309
+ let packIndexPromise = null;
310
+ function fetchPackIndex() {
311
+ if (!packIndexPromise) {
312
+ packIndexPromise = fetch("./reference-pack/index.json")
313
+ .then((res) => (res.ok ? res.json() : null))
314
+ .catch(() => null);
315
+ }
316
+ return packIndexPromise;
317
+ }
318
+ const fetchPackProvider = {
319
+ async lookup(normTerm) {
320
+ const index = await fetchPackIndex();
321
+ const id = index && index.terms ? index.terms[String(normTerm || "")] : null;
322
+ if (!id) return null;
323
+ try {
324
+ const res = await fetch("./reference-pack/articles/" + id + ".json");
325
+ return res.ok ? await res.json() : null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ },
330
+ };
331
+
332
+ function renderStatus() {
333
+ const seedPart = seedPayload
334
+ ? "starter memory: " + seedFacts + " facts"
335
+ : "starter memory unavailable — starting empty";
336
+ const winkPart = winkStatus === "loaded"
337
+ ? "wink-nlp lemma/POS tier: loaded"
338
+ : winkStatus === "unavailable"
339
+ ? "wink-nlp unavailable — curated + fuzzy tiers only (still zero guesses, zero LLM)"
340
+ : "wink-nlp: loading\\u2026";
341
+ statusEl.textContent = seedPart + " \\u00b7 " + winkPart;
342
+ }
343
+
344
+ let busy = true;
345
+ function setBusy(v) {
346
+ busy = v;
347
+ const ready = Boolean(window.tmctChatSession);
348
+ inputEl.disabled = v || !ready;
349
+ sendBtn.disabled = v || !ready;
350
+ }
351
+
352
+ composerForm.addEventListener("submit", (e) => {
353
+ e.preventDefault();
354
+ const q = inputEl.value.trim();
355
+ if (!q || busy || !window.tmctChatSession) return;
356
+ inputEl.value = "";
357
+ addUserBubble(q);
358
+ const pendingRow = addPendingAssistantBubble();
359
+ setBusy(true);
360
+ window.tmctChatSession.turn(q)
361
+ .then((result) => settleAssistantBubble(pendingRow, result.answer, result.record))
362
+ .catch((err) => settleAssistantBubble(pendingRow,
363
+ "something went wrong answering that (" + (err && err.message ? err.message : err) + ") \\u2014 try rephrasing",
364
+ { miss: true }))
365
+ .finally(() => {
366
+ setBusy(false);
367
+ inputEl.focus();
368
+ });
369
+ });
370
+
371
+ async function boot() {
372
+ if (!window.tmctChat) {
373
+ statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
374
+ inputEl.placeholder = "chat engine unavailable";
375
+ return;
376
+ }
377
+ await Promise.all([fetchSeed(), tryLoadWink()]);
378
+ window.tmctChat.registerReferencePackProvider(fetchPackProvider);
379
+ window.tmctChatSession = newSession();
380
+ addSystemLine(seedPayload
381
+ ? "tmct \\u2014 the real engine, running in this page \\u2014 " + seedFacts + " starter facts loaded"
382
+ : "tmct \\u2014 the real engine, running in this page \\u2014 no starter memory; starting empty");
383
+ inputEl.placeholder = seedPayload ? 'try "what is a dog"' : window.tmctChat.vocabExampleHint(false);
384
+ renderStatus();
385
+ setBusy(false);
386
+ inputEl.focus();
387
+ }
388
+
389
+ window.tmctChatReady = boot().catch((err) => {
390
+ console.error("tmct chat failed to boot", err);
391
+ statusEl.textContent = "the chat failed to start (" + (err && err.message ? err.message : err) + ")";
392
+ });
393
+ })();
394
+ </script>
395
+ </body>
396
+ </html>
397
+ `;
398
+ }
@@ -0,0 +1,395 @@
1
+ // sprite-catalog-viz.mjs — `public/sprites.html` (PLAN_GAMES_UPLIFT_V3.md
2
+ // Part C.2's "Sprite library" link-card item): every class the sprite
3
+ // library actually resolves a sprite for, at both tiers (data/sprites/
4
+ // *-icon.toml, 44px; data/sprites-large/*.toml, 400px), grouped for
5
+ // browsing, each swatch carrying its own real ontology mapping — the class
6
+ // name, its resolved template, and the rdfs:subClassOf ancestor chain the
7
+ // engine would actually walk to reach it — computed through the SAME
8
+ // resolver code the product itself runs (src/domain/sprite-map.mjs's
9
+ // classAncestorChain, src/domain/sprite-templates.mjs's resolveSpriteAsset),
10
+ // never a hand-simulated stand-in.
11
+ //
12
+ // Three pure/impure-separated pieces, mirroring ledger-viz.mjs's own
13
+ // computeLedgerData / computeLedgerDataFromPayload / renderLedgerHtml split:
14
+ // - loadSpriteOntologyFactRows() — I/O: the real ancestor-fact source
15
+ // - buildSpriteCatalogEntries(...) — pure derivation over templates+facts
16
+ // - renderSpriteCatalogHtml(...) — pure string builder
17
+ //
18
+ // ---- Ancestor facts: real, not invented ----
19
+ // classAncestorChain needs a flat {subject, predicate:"rdfs:subClassOf",
20
+ // object} row set to walk. Two REAL, already-committed sources are combined:
21
+ // - the spider-and-fly world's own SEED_TAXONOMY (src/domain/
22
+ // spider-fly-world.mjs) — poodle/dog/animal, spider/arachnid/animal,
23
+ // fly/insect/animal — the exact worked example sprite-map.mjs's own
24
+ // header names.
25
+ // - corpus/wordnet/wordnet-xl.jsonl (23,805 rows), the SAME opt-in
26
+ // "wordnet-xl" corpus extension the product itself ships (src/services/
27
+ // extensions.mjs), converted to rdfs:subClassOf facts through the
28
+ // existing src/adapters/corpus/conceptnet.mjs loader (loadSlice/loadMap/
29
+ // toFacts) — no bespoke parsing invented for this page.
30
+ // corpus/wordnet/wordnet-full.jsonl (192k rows, every WordNet sense
31
+ // unfiltered) was tried and rejected: with no word-sense disambiguation its
32
+ // hypernym graph conflates a word's every sense onto one node, so a class as
33
+ // ordinary as "poodle" walks into 3000+ unrelated ancestors and the walk
34
+ // itself takes minutes. wordnet-xl's own "prioritized subset" curation
35
+ // avoids most of that; what's left is capped for DISPLAY (see
36
+ // MAX_CHAIN_DISPLAY below) rather than hidden — still the real chain,
37
+ // just not printed to its full, occasionally very long, length.
38
+ //
39
+ // Every class catalogued here already carries its own template (that's
40
+ // what put it in the catalog), so live sprite resolution always stops at
41
+ // the chain's own first link — the fuller ancestor chain this page prints
42
+ // is real ancestry ON RECORD in the corpus, not a claim that resolution
43
+ // walks that far for THESE classes (it would, for an unregistered subtype
44
+ // like "sheepdog" — sprite-map.mjs's own worked example — which is exactly
45
+ // why the mechanism exists, just not exercised by any class shown here).
46
+
47
+ import { classAncestorChain, SPRITE_REGISTRY } from "../domain/sprite-map.mjs";
48
+ import { resolveSpriteAsset } from "../domain/sprite-templates.mjs";
49
+ import { MATERIAL_PALETTE } from "../domain/sprite-materials.mjs";
50
+ import { SEED_TAXONOMY } from "../domain/spider-fly-world.mjs";
51
+ import { loadSlice, loadMap, toFacts, WORDNET_DIR } from "../adapters/corpus/conceptnet.mjs";
52
+ import { join } from "node:path";
53
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
54
+
55
+ const DEFAULT_TITLE = "tmct — the sprite library";
56
+ const MAX_CHAIN_DISPLAY = 6;
57
+
58
+ /** The real rdfs:subClassOf fact rows this catalog's ancestor chains walk —
59
+ * see this module's own header for why these two sources and not a third.
60
+ * I/O (reads corpus/wordnet/wordnet-xl.jsonl + its relation map); never
61
+ * called from renderSpriteCatalogHtml itself, which stays pure. */
62
+ export async function loadSpriteOntologyFactRows() {
63
+ const seedRows = SEED_TAXONOMY.map(([subject, object]) => ({ subject, predicate: "rdfs:subClassOf", object }));
64
+ const assertions = await loadSlice(join(WORDNET_DIR, "wordnet-xl.jsonl"));
65
+ const map = await loadMap();
66
+ const wordnetRows = toFacts(assertions, map, "corpus:wordnet-xl")
67
+ .filter((f) => f.predicate === "rdfs:subClassOf")
68
+ .map((f) => ({ subject: f.subject, predicate: f.predicate, object: f.object }));
69
+ return [...seedRows, ...wordnetRows];
70
+ }
71
+
72
+ // ---- grouping (presentation only — every class still resolves through the
73
+ // real resolver above; this only decides which section of the page a
74
+ // class's card lands in) ----
75
+
76
+ // The icon tier's own two world families (sprite-map.mjs's header): the
77
+ // spider-and-fly board's creatures share the flat SPRITE_REGISTRY with the
78
+ // adventure world's own props. The creatures fold into "physical objects,
79
+ // creatures & places" below with every other animal; what's left after
80
+ // removing them (and "person", generic to both worlds) is Ashcombe Hall's
81
+ // own unique cast and furniture — never shared with another game.
82
+ const SPIDER_FLY_CREATURE_CLASSES = Object.freeze(["spider", "fly", "egg", "poodle", "dog", "animal"]);
83
+
84
+ // A curated closed list (the SOURCE_PRIOR/SPRITE_REGISTRY flat-table idiom
85
+ // this project already uses elsewhere) of every data/sprites-large/ class
86
+ // that reads as a person, a family relation, a social role/occupation, or a
87
+ // collective of people. A class named neither here nor an icon-tier
88
+ // adventure prop nor detected as an emoji-fallback class (both below) falls
89
+ // through to "physical objects, creatures & places" by default, so a future
90
+ // added class is never left uncategorized — it just lands in the generic
91
+ // bucket rather than vanishing.
92
+ export const PERSON_ROLE_CLASSES = Object.freeze([
93
+ "person", "human", "adult", "baby", "child", "boy", "girl", "man", "woman",
94
+ "mother", "father", "parent", "grandfather", "grandmother", "brother", "sister",
95
+ "son", "daughter", "husband", "wife", "family", "friend", "neighbor", "stranger",
96
+ "guest", "visitor", "customer", "employee", "boss", "manager", "leader",
97
+ "president", "king", "queen", "judge", "lawyer", "priest", "doctor", "nurse",
98
+ "teacher", "student", "engineer", "artist", "writer", "farmer", "driver",
99
+ "soldier", "officer", "servant", "worker", "volunteer", "citizen", "resident",
100
+ "champion", "crowd", "audience", "team",
101
+ ]);
102
+
103
+ export const GROUP_ADVENTURE = "adventure";
104
+ export const GROUP_PERSON = "person";
105
+ export const GROUP_OBJECT = "object";
106
+ export const GROUP_EMOJI = "emoji";
107
+
108
+ export const CATALOG_GROUPS = Object.freeze([
109
+ 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" }),
110
+ Object.freeze({ id: GROUP_PERSON, label: "Person roles" }),
111
+ Object.freeze({ id: GROUP_OBJECT, label: "Physical objects, creatures & places" }),
112
+ Object.freeze({ id: GROUP_EMOJI, label: "Emotions & events", note: "abstract concepts with no honest single physical picture — rendered as the ubiquitous emoji instead" }),
113
+ ]);
114
+
115
+ /** Which catalog section `cls` belongs in. Pure. `isIconTierClass`/`isEmoji`
116
+ * are handed in rather than recomputed here so this stays a one-line
117
+ * decision over already-known facts about the class. */
118
+ export function groupForClass(cls, { isIconTierClass, isEmoji }) {
119
+ if (isEmoji) return GROUP_EMOJI;
120
+ if (isIconTierClass && !SPIDER_FLY_CREATURE_CLASSES.includes(cls) && cls !== "person") return GROUP_ADVENTURE;
121
+ if (PERSON_ROLE_CLASSES.includes(cls)) return GROUP_PERSON;
122
+ return GROUP_OBJECT;
123
+ }
124
+
125
+ function templatesForClass(cls, templates) {
126
+ return (templates || []).filter((t) => Array.isArray(t?.classes) && t.classes.includes(cls));
127
+ }
128
+
129
+ /** The MATERIAL_PALETTE treatment key whose {light,base,dark} triple exactly
130
+ * matches `value` — the reverse of sprite-materials.mjs's own
131
+ * expandMaterialReferences, so a swatch can show e.g. "gold -> metal
132
+ * treatment" (sprite-materials.mjs's own header: "gold and metal both read
133
+ * as the same warm shiny-metal treatment"). null for a plain single-
134
+ * placeholder colour value (a string, not an object) or a one-off hand-
135
+ * authored triple that matches no shared treatment. Pure. */
136
+ export function paletteTreatmentFor(value) {
137
+ if (!value || typeof value !== "object") return null;
138
+ for (const [name, triple] of Object.entries(MATERIAL_PALETTE)) {
139
+ if (triple.light === value.light && triple.base === value.base && triple.dark === value.dark) return name;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** Every real value a template's own [parameters.*] declares, as
145
+ * {paramName, property, rawValue, treatment} rows — one row per value key,
146
+ * read directly off the template's own data, never invented. Pure. */
147
+ export function parameterVariantsFor(template) {
148
+ const out = [];
149
+ for (const [paramName, param] of Object.entries(template?.parameters || {})) {
150
+ for (const [rawValue, value] of Object.entries(param?.values || {})) {
151
+ out.push({ paramName, property: param.property, rawValue, treatment: paletteTreatmentFor(value) });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** Swatches for one tier's template set: a plain swatch ONLY when a real
158
+ * `{class}.toml`-shaped plain template exists (no [parameters]/[match] at
159
+ * all) — never a synthesized "plain" label for a material-only class,
160
+ * since with zero taught facts that class's real resolved output is the
161
+ * generic root-fallback shape, not its own silhouette (this module's own
162
+ * header names the 19 large-tier classes this applies to). Every
163
+ * [parameters.*] value and every [match] variant renders through the real
164
+ * resolveSpriteAsset with the exact property fact it declares. A class
165
+ * with templates but no plain among them also gets one extra swatch,
166
+ * labeled `fallback: true`, showing exactly what the real resolver returns
167
+ * for that class with NO taught fact — honest engine behaviour, never
168
+ * hidden, just never mislabeled "plain". Pure given `templates` (already
169
+ * loaded) and `registry` (SPRITE_REGISTRY). */
170
+ export function tierSwatchesFor(cls, templates, registry, tier) {
171
+ const forClass = templatesForClass(cls, templates);
172
+ if (!forClass.length) return [];
173
+ const plain = forClass.find((t) => !t.parameters && !t.match);
174
+ const swatches = [];
175
+ if (plain) {
176
+ swatches.push({
177
+ tier, label: "plain", kind: "plain",
178
+ svg: resolveSpriteAsset(cls, [], [], templates, registry, { instanceKey: `${cls}-${tier}-plain` }),
179
+ });
180
+ }
181
+ for (const t of forClass) {
182
+ if (t.match) {
183
+ const propertyFacts = [{ predicate: t.match.property, object: t.match.value }];
184
+ swatches.push({
185
+ tier, label: t.match.value, kind: "variant", property: t.match.property,
186
+ svg: resolveSpriteAsset(cls, [], propertyFacts, templates, registry, { instanceKey: `${cls}-${tier}-match-${t.match.value}` }),
187
+ });
188
+ }
189
+ for (const v of parameterVariantsFor(t)) {
190
+ const propertyFacts = [{ predicate: v.property, object: v.rawValue }];
191
+ swatches.push({
192
+ tier, label: v.rawValue, kind: "material", property: v.property, treatment: v.treatment,
193
+ svg: resolveSpriteAsset(cls, [], propertyFacts, templates, registry, { instanceKey: `${cls}-${tier}-${v.paramName}-${v.rawValue}` }),
194
+ });
195
+ }
196
+ }
197
+ if (!plain && swatches.length) {
198
+ swatches.push({
199
+ tier, label: "no material taught", kind: "fallback", fallback: true,
200
+ svg: resolveSpriteAsset(cls, [], [], templates, registry, { instanceKey: `${cls}-${tier}-fallback` }),
201
+ });
202
+ }
203
+ return swatches;
204
+ }
205
+
206
+ /** The full catalog: one entry per class the icon and/or large tier
207
+ * actually carries a template for, each with its real ancestor chain and
208
+ * every real tier/material swatch. Pure given the three loaded inputs.
209
+ * `factRows` defaults to `[]` (every chain then reads as just the class's
210
+ * own name — an honest, if less illustrative, chain rather than a crash)
211
+ * so a caller that hasn't loaded the ontology facts yet still gets a
212
+ * working catalog. */
213
+ export function buildSpriteCatalogEntries({ iconTemplates = [], largeTemplates = [], factRows = [] } = {}) {
214
+ const iconClasses = new Set(iconTemplates.flatMap((t) => t?.classes || []));
215
+ const largeClasses = new Set(largeTemplates.flatMap((t) => t?.classes || []));
216
+ const allClasses = [...new Set([...iconClasses, ...largeClasses])].sort();
217
+
218
+ return allClasses.map((cls) => {
219
+ const chain = classAncestorChain(cls, factRows);
220
+ const iconSwatches = tierSwatchesFor(cls, iconTemplates, SPRITE_REGISTRY, "icon");
221
+ const largeSwatches = tierSwatchesFor(cls, largeTemplates, SPRITE_REGISTRY, "large");
222
+ const isEmoji = [...iconSwatches, ...largeSwatches].some((s) => s.svg.includes("<text"));
223
+ const group = groupForClass(cls, { isIconTierClass: iconClasses.has(cls), isEmoji });
224
+ return { className: cls, group, chain, iconSwatches, largeSwatches };
225
+ });
226
+ }
227
+
228
+ // ---- rendering ----
229
+
230
+ function chainHtml(chain) {
231
+ const shown = chain.slice(0, MAX_CHAIN_DISPLAY);
232
+ const rest = chain.length - shown.length;
233
+ const links = shown
234
+ .map((term, i) => `<span class="chain-link${i === 0 ? " own" : ""}">${escapeHtml(term)}</span>`)
235
+ .join('<span class="chain-arrow">&rsaquo;</span>');
236
+ const more = rest > 0 ? `<span class="chain-more">+${rest} more on record</span>` : "";
237
+ return `<div class="chain">${links}${more}</div>`;
238
+ }
239
+
240
+ function swatchHtml(s) {
241
+ const parts = [`<span class="swatch-label">${escapeHtml(s.label)}</span>`];
242
+ if (s.treatment) parts.push(`<span class="swatch-treat">&rarr; ${escapeHtml(s.treatment)} treatment</span>`);
243
+ const title = s.property ? `${s.property} = ${s.label}` : s.label;
244
+ const cls = ["swatch", s.tier, s.kind].filter(Boolean).join(" ");
245
+ return `<div class="${cls}" title="${escapeHtml(title)}"><div class="swatch-img">${s.svg}</div><div class="swatch-caption">${parts.join("")}</div></div>`;
246
+ }
247
+
248
+ function tierRowHtml(tierName, swatches) {
249
+ if (!swatches.length) return "";
250
+ return `<div class="tier-row" data-tier="${tierName}">
251
+ <span class="tier-label">${tierName === "icon" ? "icon &middot; 44px" : "sprite &middot; 400px"}</span>
252
+ <div class="swatches">${swatches.map(swatchHtml).join("")}</div>
253
+ </div>`;
254
+ }
255
+
256
+ function cardHtml(entry) {
257
+ return `<article class="card" data-cls="${escapeHtml(entry.className)}" data-group="${escapeHtml(entry.group)}">
258
+ <h3 class="card-name">${escapeHtml(entry.className)}</h3>
259
+ ${chainHtml(entry.chain)}
260
+ ${tierRowHtml("icon", entry.iconSwatches)}
261
+ ${tierRowHtml("large", entry.largeSwatches)}
262
+ </article>`;
263
+ }
264
+
265
+ function sectionHtml(group, entries) {
266
+ const rows = entries.filter((e) => e.group === group.id);
267
+ if (!rows.length) return "";
268
+ const note = group.note ? `<p class="section-note">${escapeHtml(group.note)}</p>` : "";
269
+ return `<section class="group" id="g-${group.id}" aria-label="${escapeHtml(group.label)}">
270
+ <h2>${escapeHtml(group.label)} <span class="count">${rows.length}</span></h2>
271
+ ${note}
272
+ <div class="cards">${rows.map(cardHtml).join("")}</div>
273
+ </section>`;
274
+ }
275
+
276
+ /** The self-contained sprite-catalog page. Pure given `iconTemplates`
277
+ * (readSpriteTemplateFiles' own output), `largeTemplates`
278
+ * (readSpriteLargeTemplateFiles' own output) and `factRows`
279
+ * (loadSpriteOntologyFactRows' own output) — the same "byte-identical for
280
+ * identical input" invariant every other viz page in this project holds.
281
+ * All three default to `[]` so a caller mid-migration (no ontology facts
282
+ * loaded yet, say) still gets a page that renders, just with plainer
283
+ * ancestor chains. */
284
+ export function renderSpriteCatalogHtml({ title = DEFAULT_TITLE, iconTemplates = [], largeTemplates = [], factRows = [] } = {}) {
285
+ const entries = buildSpriteCatalogEntries({ iconTemplates, largeTemplates, factRows });
286
+ const totalSwatches = entries.reduce((n, e) => n + e.iconSwatches.length + e.largeSwatches.length, 0);
287
+ const pageData = embedJson({ classCount: entries.length, swatchCount: totalSwatches });
288
+ const navHtml = CATALOG_GROUPS
289
+ .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>`)
290
+ .join("");
291
+
292
+ return `<!doctype html>
293
+ <html lang="en">
294
+ <head>
295
+ <meta charset="utf-8">
296
+ <meta name="viewport" content="width=device-width, initial-scale=1">
297
+ <title>${escapeHtml(title)}</title>
298
+ <style>
299
+ ${THEME_TOKENS_CSS}
300
+ html { background: var(--bg); }
301
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
302
+ .mono { font-family: ${MONO_STACK}; }
303
+ main { max-width: 1180px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
304
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
305
+ h1 { font-size: 1.4rem; margin: .3rem 0 .6rem; text-wrap: balance; }
306
+ .intro { max-width: 74ch; color: var(--muted); font-size: .92rem; }
307
+ .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; }
308
+ .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; }
309
+ .jump:hover { border-color: var(--taught); }
310
+ .jump .count { color: var(--muted); }
311
+ .filter { margin-left: auto; display: flex; align-items: center; gap: .4rem; }
312
+ .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; }
313
+ .filter .n { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); white-space: nowrap; }
314
+ .group { margin: 2rem 0; content-visibility: auto; contain-intrinsic-size: 800px; }
315
+ .group h2 { font-size: 1.05rem; margin: 0 0 .2rem; display: flex; align-items: baseline; gap: .5rem; }
316
+ .group h2 .count { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); font-weight: 400; }
317
+ .section-note { color: var(--muted); font-size: .82rem; margin: 0 0 .8rem; max-width: 68ch; }
318
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: .7rem; }
319
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem .7rem .7rem; content-visibility: auto; contain-intrinsic-size: 220px; }
320
+ .card[hidden] { display: none; }
321
+ .card-name { font-size: .92rem; margin: 0 0 .3rem; font-weight: 600; }
322
+ .chain { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); margin-bottom: .5rem; display: flex; flex-wrap: wrap; align-items: center; gap: .15rem; }
323
+ .chain-link { padding: .04rem .35rem; border: 1px solid var(--line); border-radius: 99px; }
324
+ .chain-link.own { border-color: var(--taught); color: var(--taught); }
325
+ .chain-arrow { color: var(--muted); opacity: .6; }
326
+ .chain-more { font-style: italic; opacity: .75; }
327
+ .tier-row { margin-top: .4rem; }
328
+ .tier-row:first-of-type { margin-top: 0; }
329
+ .tier-label { font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
330
+ .swatches { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .25rem; }
331
+ .swatch { width: 64px; text-align: center; }
332
+ .swatch.large .swatch-img { width: 64px; height: 64px; }
333
+ .swatch.icon .swatch-img { width: 34px; height: 34px; margin: 0 auto; }
334
+ .swatch-img svg { width: 100%; height: 100%; display: block; }
335
+ .swatch.fallback { opacity: .55; }
336
+ .swatch.fallback .swatch-img { outline: 1px dashed var(--line); outline-offset: 2px; }
337
+ .swatch-caption { font-family: ${MONO_STACK}; font-size: .58rem; color: var(--muted); line-height: 1.25; margin-top: .15rem; word-break: break-word; }
338
+ .swatch-treat { display: block; opacity: .8; }
339
+ 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); }
340
+ @media (prefers-reduced-motion: no-preference) { .jump, .swatch { transition: border-color .12s ease, opacity .12s ease; } }
341
+ </style>
342
+ </head>
343
+ <body>
344
+ <main>
345
+ <div class="eyebrow">tmct &middot; the sprite library</div>
346
+ <h1>Every shape the sprite library can draw, and why</h1>
347
+ <p class="intro">Two tiers share one resolver: a 44px icon set for the live games, a 400px
348
+ gradient-shaded set for a closer look. Each card below shows a class's real
349
+ <span class="mono">rdfs:subClassOf</span> ancestor chain and every swatch the real resolver
350
+ (<span class="mono">resolveSpriteAsset</span>) actually returns for it &mdash; a material-bearing
351
+ class shows its real taught-material variants, never an invented one. Every class shown here
352
+ already carries its own template, so live resolution always stops at the chain's own first
353
+ link (marked); the fuller chain is real ancestry on record in this catalog's corpus slice, kept
354
+ for context.</p>
355
+ <div class="topbar">
356
+ <nav aria-label="Jump to group">${navHtml}</nav>
357
+ <div class="filter">
358
+ <input id="q" type="text" placeholder="filter by class or group&hellip;" aria-label="Filter the catalog">
359
+ <span class="n mono" id="qcount"></span>
360
+ </div>
361
+ </div>
362
+ ${CATALOG_GROUPS.map((g) => sectionHtml(g, entries)).join("")}
363
+ <footer class="page">${entries.length} classes &middot; ${totalSwatches} swatches &middot; icon tier 44px, sprite tier 400px</footer>
364
+ </main>
365
+ <script>
366
+ const SPRITE_CATALOG = ${pageData};
367
+ </script>
368
+ <script>
369
+ (function () {
370
+ "use strict";
371
+ const q = document.getElementById("q");
372
+ const qcount = document.getElementById("qcount");
373
+ const cards = Array.from(document.querySelectorAll(".card"));
374
+ function apply() {
375
+ const needle = q.value.trim().toLowerCase();
376
+ let shown = 0;
377
+ for (const card of cards) {
378
+ const hit = !needle || card.dataset.cls.includes(needle) || card.dataset.group.includes(needle);
379
+ card.hidden = !hit;
380
+ if (hit) shown += 1;
381
+ }
382
+ for (const section of document.querySelectorAll(".group")) {
383
+ const anyShown = section.querySelectorAll(".card:not([hidden])").length > 0;
384
+ section.style.display = anyShown ? "" : "none";
385
+ }
386
+ qcount.textContent = needle ? shown + " / " + cards.length : "";
387
+ }
388
+ q.addEventListener("input", apply);
389
+ apply();
390
+ })();
391
+ </script>
392
+ </body>
393
+ </html>
394
+ `;
395
+ }