@polycode-projects/the-mechanical-code-talker 2.8.5 → 2.8.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.8.5",
3
+ "version": "2.8.7",
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.",
@@ -128,6 +128,54 @@ export function facingDegreesFor(plan, previousDegrees) {
128
128
  return previousDegrees ?? 0;
129
129
  }
130
130
 
131
+ /** The instance-level `mgx:feels` emotion for one spider-fly agent this
132
+ * tick — a PURE derivation from state spider-fly.mjs's own `runSpiderFlyTick`
133
+ * already returns on the agent (`goal`/`mass`), never a new persisted fact,
134
+ * the same derive-don't-duplicate posture `hasActiveWebAt` already holds for
135
+ * web state (PLAN_GAMES_UPLIFT_V3.md §B.2.4). Operator-confirmed mapping: a
136
+ * spider that just ate is happy; a spider carrying an uncaught fly or
137
+ * mid-chase (chasing, or co-located and about to catch) is angry (predatory
138
+ * focus); a spider avoiding another spider is scared; a spider holding
139
+ * position or building a web is calm. A fly evading a believed-visible
140
+ * spider is scared, and so is the sharper form of the same fear — just
141
+ * caught, or already being carried; a fly with no threat visible (wandering,
142
+ * or trapped with none in sight) is calm.
143
+ *
144
+ * `agent.goal` is spider-fly.mjs's own fixed-template text (`goalLineFor`'s
145
+ * literal phrasing) — the only reliable per-tick discriminator this
146
+ * function's caller has, and every real goal string that engine can ever
147
+ * produce is matched below by its own distinguishing prefix. A goal this
148
+ * function doesn't recognize (a freshly hatched/spawned agent's "no goal
149
+ * yet", or the transient "X is gone — re-evaluating" scrub once a named
150
+ * agent dies) falls through to "calm" — the 6-word vocabulary's own
151
+ * deliberate no-strong-emotion baseline (sprite-expressions.mjs's B.2.1
152
+ * design), not a guess.
153
+ *
154
+ * `maxMass` is accepted for parity with this page's own per-class mass-bar
155
+ * denominators (`SPIDERFLY.maxSpiderMass`/`maxFlyMass`) but unused today —
156
+ * no state in the operator's own confirmed table keys off a mass ratio,
157
+ * only off the goal text above. Self-contained (no outer refs),
158
+ * `.toString()`-splice safe. */
159
+ export function emotionFor(agent, kind, maxMass) {
160
+ const goal = agent?.goal || "";
161
+ if (kind === "spider") {
162
+ if (goal.startsWith("just ate")) return "happy";
163
+ if (goal.startsWith("carrying") || goal.startsWith("chasing") || goal.startsWith("co-located with")) return "angry";
164
+ if (goal.startsWith("avoiding")) return "scared";
165
+ return "calm";
166
+ }
167
+ if (kind === "fly") {
168
+ if (
169
+ goal.startsWith("evading")
170
+ || goal.startsWith("being carried by")
171
+ || goal.startsWith("just caught by")
172
+ || goal.startsWith("trapped in an active web")
173
+ ) return "scared";
174
+ return "calm";
175
+ }
176
+ return "calm";
177
+ }
178
+
131
179
  /**
132
180
  * The updated corpse set for one redraw (§A.2.5 — visual-only, entirely
133
181
  * client-side; the actual starve/eat removal already happened in the
@@ -161,10 +209,14 @@ export function nextCorpses(prevCorpses, prevAgents, agents, turn, lingerTurns =
161
209
  * same `title`/`spriteTemplates` every time; every other piece of state
162
210
  * this page shows is computed live in the browser once the sibling bundle
163
211
  * loads. `spriteTemplates` is the build step's own read of
164
- * data/sprites/*.toml (sprite-template-files.mjs), embedded as page data
165
- * the same reason adventure-viz.mjs's own worldPayload is the browser
166
- * bundle stays fs-free. Defaults to `[]` (every agent falls back to the
167
- * flat SPRITE_REGISTRY, unchanged from before this module existed).
212
+ * data/sprites-large/*.toml (sprite-large-template-files.mjs) the large
213
+ * tier, not the small icon tier every other embedder reads, because it's
214
+ * the only one `spider-with-emotion.toml`/`fly-with-emotion.toml` (the live
215
+ * `mgx:feels` wiring below resolves against) actually exist in — embedded
216
+ * as page data the same reason adventure-viz.mjs's own worldPayload is —
217
+ * the browser bundle stays fs-free. Defaults to `[]` (every agent falls
218
+ * back to the flat SPRITE_REGISTRY, unchanged from before this module
219
+ * existed).
168
220
  * `?preview=1` on the page's own URL switches it into the small, auto-
169
221
  * playing, non-interactive mode the home page's hero iframe embeds (§11) —
170
222
  * one file serves both the hero and the "open full-screen" link, matching
@@ -203,11 +255,42 @@ ${THEME_TOKENS_CSS}
203
255
  @media (prefers-color-scheme: dark) { :root { --fly: #D9A94B; } }
204
256
  :root[data-theme="dark"] { --fly: #D9A94B; }
205
257
  :root[data-theme="light"] { --fly: #A6791F; }
258
+ /* The dashboard chrome (Part C.4.2): a beveled control-panel palette
259
+ WRAPPING the dusty-window board scene above, never touching it — every
260
+ rule below this block styles .side/.controls-row/.tuning/.status only,
261
+ all of which are hidden in ?preview=1 mode alongside .side itself, so
262
+ none of this bleeds into the embedded home-page hero. --chrome-face is
263
+ the panel's own warm plastic face; --chrome-edge-hi/-lo are its raised-
264
+ bevel light/shadow edges; --chrome-brass is the console's metal accent
265
+ (borders, active/hover states); --chrome-well/-well-ink are the dark
266
+ "LCD" readout a real panel's numeric window would use, deliberately
267
+ near-black in both themes (a lit readout reads dark-with-a-glow whether
268
+ the room around it is light or dark). --chrome-shadow-raised/-inset
269
+ compose the two bevel directions once so every panel/button/well below
270
+ just references one of them, never re-deriving the edge colors. */
271
+ :root {
272
+ --chrome-face: #DCD3B8; --chrome-face-hi: #EFE8CC; --chrome-edge-hi: #FBF7E6; --chrome-edge-lo: #8B7F5C;
273
+ --chrome-brass: #8A6A26; --chrome-brass-soft: rgba(138, 106, 38, .16);
274
+ --chrome-well: #201A10; --chrome-well-ink: #E8C876;
275
+ --chrome-shadow-raised: inset 0 1px 0 var(--chrome-edge-hi), inset 0 -1px 0 var(--chrome-edge-lo), 0 1px 2px rgba(0, 0, 0, .18);
276
+ --chrome-shadow-inset: inset 0 1px 3px var(--chrome-edge-lo), inset 0 -1px 0 var(--chrome-edge-hi);
277
+ }
278
+ @media (prefers-color-scheme: dark) {
279
+ :root { --chrome-face: #2B2E23; --chrome-face-hi: #363A29; --chrome-edge-hi: #4C5138; --chrome-edge-lo: #14150D;
280
+ --chrome-brass: #D3AE5C; --chrome-brass-soft: rgba(211, 174, 92, .16);
281
+ --chrome-well: #0B0904; --chrome-well-ink: #F0D392; }
282
+ }
283
+ :root[data-theme="dark"] { --chrome-face: #2B2E23; --chrome-face-hi: #363A29; --chrome-edge-hi: #4C5138; --chrome-edge-lo: #14150D;
284
+ --chrome-brass: #D3AE5C; --chrome-brass-soft: rgba(211, 174, 92, .16);
285
+ --chrome-well: #0B0904; --chrome-well-ink: #F0D392; }
286
+ :root[data-theme="light"] { --chrome-face: #DCD3B8; --chrome-face-hi: #EFE8CC; --chrome-edge-hi: #FBF7E6; --chrome-edge-lo: #8B7F5C;
287
+ --chrome-brass: #8A6A26; --chrome-brass-soft: rgba(138, 106, 38, .16);
288
+ --chrome-well: #201A10; --chrome-well-ink: #E8C876; }
206
289
  html { background: var(--bg); }
207
290
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
208
291
  .mono { font-family: ${MONO_STACK}; }
209
292
  main { max-width: 1120px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
210
- .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
293
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--chrome-brass); }
211
294
  h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
212
295
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
213
296
  button:focus-visible, input:focus-visible, .sprite:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@@ -246,35 +329,54 @@ ${THEME_TOKENS_CSS}
246
329
  .sprite.corpse .sprite-face { transform: none !important; }
247
330
  .thread-tip { position: absolute; transform: translate(-50%, -130%); font-family: ${MONO_STACK}; font-size: .68rem; background: var(--ink); color: var(--bg); padding: .1rem .4rem; border-radius: 3px; pointer-events: none; white-space: nowrap; display: none; }
248
331
  .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
249
- .hud, .chat, .tuning { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
250
- .hud h2, .chat h2, .tuning h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
332
+ /* The console panel shell: a raised beveled plastic face (chrome-shadow-
333
+ raised), never the flat card/hairline the rest of the site uses the
334
+ nameplate header below bleeds to this same panel's own edges via a
335
+ negative margin equal to this padding, so it reads as one welded unit,
336
+ not a label floating inside a box. */
337
+ .hud, .chat, .tuning { background: var(--chrome-face); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-raised); border-radius: 2px; padding: .6rem .75rem; }
338
+ .hud h2, .chat h2, .tuning h2 {
339
+ font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; font-weight: 600;
340
+ margin: -.6rem -.75rem .5rem; padding: .42rem .75rem;
341
+ background: var(--chrome-face-hi); border-bottom: 1px solid var(--chrome-edge-lo);
342
+ box-shadow: inset 0 1px 0 var(--chrome-edge-hi);
343
+ color: var(--ink); text-shadow: 0 1px 0 var(--chrome-edge-hi);
344
+ }
251
345
  /* Fixed-height, internally-scrolling — a hatch can mint several spiders
252
346
  at once now, so the agent count (and a naive card list's own height)
253
347
  can jump sharply; the panel must never grow the page underneath it. */
254
348
  .hud-list { max-height: 420px; overflow-y: auto; }
255
- .hud-row { display: flex; flex-direction: column; gap: .1rem; padding: .4rem 0; border-top: 1px solid var(--line); }
256
- .hud-row:first-of-type { border-top: none; }
257
- .hud-id { font-family: ${MONO_STACK}; font-size: .74rem; }
349
+ .hud-row { display: flex; flex-direction: column; gap: .1rem; padding: .4rem 0; border-top: 1px solid var(--chrome-edge-lo); box-shadow: inset 0 1px 0 var(--chrome-edge-hi); }
350
+ .hud-row:first-of-type { border-top: none; box-shadow: none; }
351
+ .hud-id { font-family: ${MONO_STACK}; font-size: .74rem; font-weight: 600; }
258
352
  .hud-id.spider { color: var(--taught); } .hud-id.fly { color: var(--fly); } .hud-id.egg { color: var(--muted); }
259
353
  .hud-goal { font-size: .85rem; }
260
- .hud-plan, .hud-belief { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); margin-top: .2rem; line-height: 1.4; }
261
- .mass-track { height: 4px; margin-top: .3rem; background: var(--line); border-radius: 2px; overflow: hidden; }
262
- .mass-fill { height: 100%; background: var(--taught); }
263
- .mass-fill.fly { background: var(--fly); }
354
+ .hud-plan, .hud-belief { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); margin-top: .25rem; line-height: 1.4; padding-left: .5rem; border-left: 2px solid var(--chrome-brass); }
355
+ /* A stat-readout track: an inset "LCD" well, filled with a segmented pip
356
+ texture (repeating-linear-gradient) instead of a smooth gradient bar —
357
+ discrete resource units, the same reading-at-a-glance language a 90s
358
+ strategy game's own gold/mana meters use. */
359
+ .mass-track { height: 7px; margin-top: .35rem; background: var(--chrome-well); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-inset); border-radius: 1px; overflow: hidden; }
360
+ .mass-fill { height: 100%; background: repeating-linear-gradient(90deg, var(--taught) 0 6px, rgba(0, 0, 0, .25) 6px 7px); }
361
+ .mass-fill.fly { background: repeating-linear-gradient(90deg, var(--fly) 0 6px, rgba(0, 0, 0, .25) 6px 7px); }
264
362
  .hud-empty { color: var(--muted); font-size: .85rem; }
265
363
  .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 220px; overflow-y: auto; margin-bottom: .5rem; }
266
364
  .chatlog:empty { display: none; margin-bottom: 0; }
267
365
  .chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
268
366
  .chatlog .u::before { content: "tmct> "; color: var(--taught); }
269
367
  .chatlog .a { font-size: .88rem; line-height: 1.4; white-space: pre-wrap; }
270
- .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
368
+ .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--chrome-edge-lo); padding-top: .5rem; }
271
369
  .chatlog:empty + .chatask { border-top: none; padding-top: 0; }
272
370
  .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
273
- .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
371
+ /* An inset "command slot" the same LCD-well treatment the turn counter
372
+ and every live tuning readout below use, so typed input, live stats and
373
+ the turn plaque all read as one instrument panel, not three styles. */
374
+ .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--chrome-well); color: var(--chrome-well-ink); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: .32rem .55rem; min-width: 0; box-sizing: border-box; }
375
+ .chatask input::placeholder { color: var(--chrome-edge-hi); opacity: .55; }
274
376
  .chatask input:disabled { opacity: .5; }
275
377
  .chatpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; }
276
- .pill { font-family: ${MONO_STACK}; font-size: .68rem; padding: .2rem .6rem; border: 1px solid var(--line); border-radius: 99px; background: var(--bg); color: var(--ink); white-space: nowrap; }
277
- .pill:hover:not(:disabled) { border-color: var(--taught); }
378
+ .pill { font-family: ${MONO_STACK}; font-size: .68rem; padding: .2rem .6rem; border: 1px solid var(--chrome-edge-lo); border-radius: 99px; background: var(--chrome-face); color: var(--ink); box-shadow: inset 0 1px 0 var(--chrome-edge-hi); white-space: nowrap; }
379
+ .pill:hover:not(:disabled) { border-color: var(--chrome-brass); }
278
380
  .pill:disabled { opacity: .45; cursor: default; }
279
381
  .pill[data-role="addr"].active { border-color: var(--taught); color: var(--taught); }
280
382
  /* The dynamic deception-pill rail (§A.2.4): a true/false tag shown ONLY
@@ -282,7 +384,7 @@ ${THEME_TOKENS_CSS}
282
384
  submitted sentence itself (data-sentence, filled into #chatq on click)
283
385
  never carries the tag, so a clicked pill is indistinguishable from a
284
386
  hand-typed claim once it's in the input. */
285
- .dynpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--line); }
387
+ .dynpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--chrome-edge-lo); }
286
388
  .dynpills:empty { display: none; padding-top: 0; border-top: none; }
287
389
  .pill[data-role="dyn-addr"][data-active="1"] { border-color: var(--taught); color: var(--taught); }
288
390
  .pill[data-role="dyn-claim"][data-truth="true"] { border-color: var(--taught-t2, var(--taught)); }
@@ -290,19 +392,50 @@ ${THEME_TOKENS_CSS}
290
392
  .pill[data-role="dyn-claim"][data-truth="false"] { border-style: dashed; border-color: var(--alert); }
291
393
  .pill[data-role="dyn-claim"][data-truth="false"]::before { content: "✕ "; opacity: .6; }
292
394
  .tuning-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
293
- .tuning-col h3 { font-size: .74rem; margin: 0 0 .4rem; font-weight: 600; }
395
+ .tuning-col h3 { font-size: .74rem; margin: 0 0 .4rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
294
396
  .tuning-col.spider h3 { color: var(--taught); } .tuning-col.fly h3 { color: var(--fly); }
295
397
  .tuning-col label { display: block; font-size: .68rem; color: var(--muted); margin-bottom: .6rem; }
296
- .tuning-col .tuning-val { font-family: ${MONO_STACK}; color: var(--ink); float: right; }
297
- .tuning-col input[type="range"] { display: block; width: 100%; margin-top: .2rem; accent-color: var(--taught); }
398
+ /* Every live number on this page (this readout, the turn plaque below)
399
+ shares the same brass-on-well "LED" treatment one readout language,
400
+ not a one-off flourish. */
401
+ .tuning-col .tuning-val { font-family: ${MONO_STACK}; font-variant-numeric: tabular-nums; color: var(--chrome-well-ink); background: var(--chrome-well); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: 0 .35rem; float: right; }
402
+ .tuning-col input[type="range"] {
403
+ display: block; width: 100%; margin-top: .3rem; height: 6px; border-radius: 3px;
404
+ background: var(--chrome-well); box-shadow: var(--chrome-shadow-inset); accent-color: var(--taught);
405
+ -webkit-appearance: none; appearance: none;
406
+ }
298
407
  .tuning-col.fly input[type="range"] { accent-color: var(--fly); }
408
+ /* A lever-look thumb (a molded brass-edged cap) — appearance: none above
409
+ drops the OS's own native slider look in browsers that honor it;
410
+ accent-color just above is the fallback for any that don't. */
411
+ .tuning-col input[type="range"]::-webkit-slider-thumb {
412
+ -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; margin-top: -4px; cursor: pointer;
413
+ background: var(--chrome-face-hi); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-raised);
414
+ }
415
+ .tuning-col.spider input[type="range"]::-webkit-slider-thumb { border-color: var(--taught); }
416
+ .tuning-col.fly input[type="range"]::-webkit-slider-thumb { border-color: var(--fly); }
417
+ .tuning-col input[type="range"]::-moz-range-track { height: 6px; border-radius: 3px; background: var(--chrome-well); box-shadow: var(--chrome-shadow-inset); }
418
+ .tuning-col input[type="range"]::-moz-range-thumb {
419
+ width: 14px; height: 14px; border-radius: 50%; cursor: pointer; border: 1px solid var(--chrome-edge-lo);
420
+ background: var(--chrome-face-hi); box-shadow: var(--chrome-shadow-raised);
421
+ }
422
+ .tuning-col.spider input[type="range"]::-moz-range-thumb { border-color: var(--taught); }
423
+ .tuning-col.fly input[type="range"]::-moz-range-thumb { border-color: var(--fly); }
299
424
  .tuning-col input:disabled { opacity: .45; }
300
425
  .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
301
- .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
302
- .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
303
- .controls-row button:disabled { opacity: .4; cursor: default; }
304
- .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
305
- .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .5rem; }
426
+ /* Chunky keycaps: raised by default, pressed-in on :active the one place
427
+ this redesign uses interaction (not animation) to sell "physical
428
+ control panel", and it costs nothing under reduced-motion since neither
429
+ state involves a transition, just an instant bevel swap. */
430
+ .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .03em; text-transform: uppercase; padding: .4rem .85rem; border-radius: 2px; border: 1px solid var(--chrome-edge-lo); background: var(--chrome-face); box-shadow: var(--chrome-shadow-raised); color: var(--ink); }
431
+ .controls-row button:hover:not(:disabled) { border-color: var(--chrome-brass); }
432
+ .controls-row button:active:not(:disabled) { box-shadow: var(--chrome-shadow-inset); }
433
+ .controls-row button:disabled { opacity: .4; cursor: default; box-shadow: none; }
434
+ /* The signature element: a brass-framed digital turn counter, the same
435
+ inset-well readout language as every stat above it — the one thing this
436
+ page is remembered by. */
437
+ .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .82rem; letter-spacing: .05em; text-transform: uppercase; font-variant-numeric: tabular-nums; color: var(--chrome-well-ink); background: var(--chrome-well); border: 1px solid var(--chrome-brass); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: .3rem .7rem; }
438
+ .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .5rem; padding-left: .5rem; border-left: 2px solid var(--chrome-brass); }
306
439
  body.preview .side, body.preview .controls-row, body.preview .status, body.preview .tuning { display: none; }
307
440
  body.preview main { padding: 0; max-width: none; }
308
441
  body.preview .stage { display: block; }
@@ -386,6 +519,7 @@ const SPIDERFLY = ${gridData};
386
519
  const classOfAgentId = ${classOfAgentId.toString()};
387
520
  const threadCellsForSpiderPlan = ${threadCellsForSpiderPlan.toString()};
388
521
  const facingDegreesFor = ${facingDegreesFor.toString()};
522
+ const emotionFor = ${emotionFor.toString()};
389
523
  const nextCorpses = ${nextCorpses.toString()};
390
524
  const esc = ${escapeHtml.toString()};
391
525
  const el = (id) => document.getElementById(id);
@@ -447,6 +581,7 @@ const SPIDERFLY = ${gridData};
447
581
  const goalById = {};
448
582
  const spriteEls = {};
449
583
  const facingByAgent = {};
584
+ const emotionByAgent = {};
450
585
  let corpses = {};
451
586
  const corpseEls = {};
452
587
  let selectedAddresseeId = null;
@@ -461,11 +596,32 @@ const SPIDERFLY = ${gridData};
461
596
  function removeStaleSprites(agents) {
462
597
  for (const id of Object.keys(spriteEls)) {
463
598
  if (!agents[id]) {
464
- spriteEls[id].remove(); delete spriteEls[id]; delete goalById[id]; delete facingByAgent[id];
599
+ spriteEls[id].remove(); delete spriteEls[id]; delete goalById[id]; delete facingByAgent[id]; delete emotionByAgent[id];
465
600
  }
466
601
  }
467
602
  }
468
603
 
604
+ // maxMassFor/propertyFactsForAgent (emotionFor's own caller-side glue): only
605
+ // spider/fly carry the mgx:feels parameter (data/sprites-large/*-with-
606
+ // emotion.toml exists for those two classes only) — every other class (egg,
607
+ // and any class this page has no template for at all) keeps propertyFacts
608
+ // empty, resolving through its plain template exactly as before this page
609
+ // wired emotion through. maxMassFor mirrors massBarHtml's own per-class
610
+ // denominator below so the HUD's mass bar and the sprite's own expression
611
+ // read the same "how full" scale.
612
+ function maxMassFor(cls) {
613
+ return cls === "spider" ? SPIDERFLY.maxSpiderMass : cls === "fly" ? SPIDERFLY.maxFlyMass : null;
614
+ }
615
+ function propertyFactsForAgent(agent, cls) {
616
+ if (cls !== "spider" && cls !== "fly") return [];
617
+ return [{ predicate: "mgx:feels", object: emotionFor(agent, cls, maxMassFor(cls)) }];
618
+ }
619
+ function resolveSpriteFace(cls, propertyFacts) {
620
+ return window.tmctSpiderFly
621
+ ? tmctSpiderFly.resolveSpriteAsset(cls, (session && session.taxonomyRows) || [], propertyFacts, SPIDERFLY.spriteTemplates, tmctSpiderFly.SPRITE_REGISTRY)
622
+ : "";
623
+ }
624
+
469
625
  function togglePov(id) {
470
626
  povAgentId = povAgentId === id ? null : id;
471
627
  if (!povAgentId) for (const node of Object.values(spriteEls)) node.classList.remove("dimmed");
@@ -475,27 +631,25 @@ const SPIDERFLY = ${gridData};
475
631
  if (e.key === "Escape" && povAgentId) togglePov(povAgentId);
476
632
  });
477
633
 
478
- function ensureSpriteEl(id, cls) {
634
+ function ensureSpriteEl(id, cls, agent) {
479
635
  let node = spriteEls[id];
480
636
  if (node) return node;
481
637
  node = document.createElement("div");
482
638
  node.className = "sprite";
483
639
  node.dataset.cls = cls;
484
640
  // Property-aware resolution (sprite-templates.mjs's resolveSpriteAsset):
485
- // no agent here carries an mgx:hasProperty fact today, so propertyFacts
486
- // stays empty and every agent resolves through its plain class template
487
- // (or the flat SPRITE_REGISTRY, for a class with none) — the same output
488
- // as before this module existed, just wired for the day an agent does
489
- // carry one.
490
- const sprite = window.tmctSpiderFly
491
- ? tmctSpiderFly.resolveSpriteAsset(cls, (session && session.taxonomyRows) || [], [], SPIDERFLY.spriteTemplates, tmctSpiderFly.SPRITE_REGISTRY)
492
- : "";
641
+ // a spider/fly's live mgx:feels emotion (emotionFor, above) resolves it
642
+ // through the matching data/sprites-large/*-with-emotion.toml template;
643
+ // every other class (egg) keeps propertyFacts empty and resolves through
644
+ // its plain class template (or the flat SPRITE_REGISTRY), unchanged.
645
+ const propertyFacts = propertyFactsForAgent(agent, cls);
646
+ emotionByAgent[id] = propertyFacts[0] ? propertyFacts[0].object : null;
493
647
  // The sprite SVG lives in its own inner wrapper so plan-driven facing
494
648
  // (a CSS rotate on THIS wrapper) never fights the outer .sprite node's
495
649
  // own translate(-50%,-50%) positioning transform.
496
650
  const face = document.createElement("div");
497
651
  face.className = "sprite-face";
498
- face.innerHTML = sprite;
652
+ face.innerHTML = resolveSpriteFace(cls, propertyFacts);
499
653
  node.appendChild(face);
500
654
  if (!preview) {
501
655
  node.tabIndex = 0;
@@ -515,7 +669,7 @@ const SPIDERFLY = ${gridData};
515
669
  removeStaleSprites(agents);
516
670
  for (const [id, a] of Object.entries(agents)) {
517
671
  const cls = classOfAgentId(id);
518
- const node = ensureSpriteEl(id, cls);
672
+ const node = ensureSpriteEl(id, cls, a);
519
673
  const parsed = tmctSpiderFly.parseCellId(a.cell);
520
674
  if (!parsed) continue;
521
675
  const pct = cellCenterPct(parsed.x, parsed.y);
@@ -528,7 +682,20 @@ const SPIDERFLY = ${gridData};
528
682
  // whole point of this page, not a glitch).
529
683
  facingByAgent[id] = facingDegreesFor(a.plan, facingByAgent[id]);
530
684
  const face = node.querySelector(".sprite-face");
531
- if (face) face.style.transform = "rotate(" + facingByAgent[id] + "deg)";
685
+ if (face) {
686
+ face.style.transform = "rotate(" + facingByAgent[id] + "deg)";
687
+ // Emotion changes tick-to-tick (a fly's fear spikes the instant a
688
+ // spider comes into view, a spider's joy fires the instant it eats),
689
+ // so this must re-resolve every redraw, not just at sprite creation —
690
+ // but only actually replace the DOM when the emotion word itself
691
+ // changed since last render, never on every tick regardless.
692
+ const propertyFacts = propertyFactsForAgent(a, cls);
693
+ const emotion = propertyFacts[0] ? propertyFacts[0].object : null;
694
+ if (emotion !== emotionByAgent[id]) {
695
+ emotionByAgent[id] = emotion;
696
+ face.innerHTML = resolveSpriteFace(cls, propertyFacts);
697
+ }
698
+ }
532
699
  if (a.goal) goalById[id] = a.goal;
533
700
  }
534
701
  lastAgents = agents;
@@ -554,9 +721,12 @@ const SPIDERFLY = ${gridData};
554
721
  node.setAttribute("aria-hidden", "true");
555
722
  const face = document.createElement("div");
556
723
  face.className = "sprite-face";
557
- face.innerHTML = window.tmctSpiderFly
558
- ? tmctSpiderFly.resolveSpriteAsset(corpse.cls, (session && session.taxonomyRows) || [], [], SPIDERFLY.spriteTemplates, tmctSpiderFly.SPRITE_REGISTRY)
559
- : "";
724
+ // A corpse's expression is frozen — never re-resolved on later
725
+ // redraws (there's no more agent state to derive one from), and
726
+ // never emotion-parameterized at all (a rotting carcass has none),
727
+ // just its class's own plain template under the grayscale/fade the
728
+ // .sprite.corpse CSS rule already applies.
729
+ face.innerHTML = resolveSpriteFace(corpse.cls, []);
560
730
  node.appendChild(face);
561
731
  spriteLayer.appendChild(node);
562
732
  corpseEls[id] = node;