@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 +30 -16
- package/corpus/tier2/generate.mjs +1 -0
- package/corpus/tier2/human.jsonl +1 -0
- package/corpus/tier2/manifest.json +3 -3
- package/package.json +1 -1
- package/src/adapters/corpus/sprite-large-template-files.mjs +6 -2
- package/src/domain/sprite-expressions.mjs +120 -0
- package/src/domain/sprite-templates.mjs +31 -9
- package/src/services/chat-page-viz.mjs +398 -0
- package/src/services/sprite-catalog-viz.mjs +395 -0
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
|
|
124
|
-
|
|
125
|
-
drill by clicking the terms inside them,
|
|
126
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1039
|
-
and carries, in the same row, the caveat that changes what it means.
|
|
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.
|
|
1056
|
+
| What it does | Result (2.7.12) | Read the number with this | Method |
|
|
1043
1057
|
|---|---|---|---|
|
|
1044
|
-
| Multi-hop entailment |
|
|
1045
|
-
| Tool-call planning |
|
|
1046
|
-
| Groundedness | Every answer carries a source, and an empty graph reports itself empty
|
|
1047
|
-
| Abstention (the honest miss) | 0% fabrication across
|
|
1048
|
-
| Determinism | Byte-identical on rerun
|
|
1049
|
-
| Dialogue robustness (
|
|
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"],
|
package/corpus/tier2/human.jsonl
CHANGED
|
@@ -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":
|
|
71
|
-
"bytes":
|
|
72
|
-
"sha256": "
|
|
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.
|
|
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
|
|
100
|
-
* `[parameters.*]` whose observed property value maps to a substitution
|
|
101
|
-
*
|
|
102
|
-
|
|
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(
|
|
110
|
-
if (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 =
|
|
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,
|
|
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>→</button>
|
|
183
|
+
</div>
|
|
184
|
+
</form>
|
|
185
|
+
<div class="statusline" id="status">loading the engine…</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">›</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">→ ${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 · 44px" : "sprite · 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 · 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 — 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…" 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 · ${totalSwatches} swatches · 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
|
+
}
|