@polycode-projects/the-mechanical-code-talker 5.0.4 → 5.0.6

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.
@@ -266,7 +266,7 @@ export function createP2pRoom({
266
266
  // is a change worth broadcasting even though its provenance never moved.
267
267
  let cachedRetractions = [];
268
268
  const seenRetractionValueById = new Map();
269
- const retractionDiffValue = (fact) => `${fact.provenance}${fact.object}`;
269
+ const retractionDiffValue = (fact) => `${fact.provenance}\u0000${fact.object}`;
270
270
 
271
271
  // Store-touching work runs one job at a time, in arrival order. Every path
272
272
  // that reads or writes memoryDir/seenProvenanceById/cachedRows crosses at
@@ -18,7 +18,7 @@
18
18
  // inline script degrades honestly when that sibling script is absent or
19
19
  // fails to load — the live controls disable themselves rather than pretend
20
20
  // to work.
21
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, countLabel } from "./viz-theme.mjs";
21
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, countLabel, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
22
22
  import { planToPddl } from "./plan-pddl.mjs";
23
23
 
24
24
  const BOARD_W = 640;
@@ -334,6 +334,7 @@ button { font: inherit; }
334
334
  /* ---- head strip: the nameplate ---- */
335
335
  .headStrip { display: flex; justify-content: space-between; align-items: flex-end; gap: .6rem; flex-wrap: wrap; padding: .9rem 1.1rem .75rem; background: linear-gradient(180deg, var(--strip2), var(--strip)); border-bottom: 1px solid var(--line); border-radius: 9px 9px 0 0; }
336
336
  .eyebrow { font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); margin: 0 0 .35rem; }
337
+ ${EYEBROW_LINKS_CSS}
337
338
  h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0; color: var(--ink); }
338
339
  .chip { font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .02em; color: var(--muted); border: 1px solid var(--line); border-radius: 3px; padding: .22rem .6rem; background: var(--well); text-align: right; }
339
340
 
@@ -432,7 +433,7 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
432
433
  <div class="rack">
433
434
  <div class="headStrip">
434
435
  <div>
435
- <div class="eyebrow">tmct &middot; plan</div>
436
+ <div class="eyebrow">${demoEyebrowHtml("plan", "plan")}</div>
436
437
  <h1 id="pageTitle">${escapeHtml(pageTitle)}</h1>
437
438
  </div>
438
439
  <span class="chip">blocks archetype · ${pageData.layouts.length} snapshots · plan: findActionPath</span>
@@ -14,7 +14,7 @@
14
14
  // which is board-size agnostic and models vision as plain Chebyshev distance
15
15
  // with no line of sight — a building blocks movement, never sight.
16
16
  //
17
- // Three mechanics are OPT-IN, off unless `config` asks for them by name, so a
17
+ // Four mechanics are OPT-IN, off unless `config` asks for them by name, so a
18
18
  // cast that does not want them sees the same board it always did:
19
19
  //
20
20
  // - `carryPreyToWeb` splits catching from eating. A predator that shares a
@@ -27,6 +27,11 @@
27
27
  // - `layEggs` adds the reproduction stage: an egg (mgx:laid-at-turn) laid in
28
28
  // a web once mass crosses a threshold, hatching (mgx:hatched-into) into
29
29
  // hatchlings that split the egg's mass.
30
+ // - `blendPreyDecision` replaces the prey's strict evade-then-forage order
31
+ // with one weighted score over both distances (`preyThreatWeight`), so a
32
+ // prey can take a crumb that costs it nothing. Off, prey abandon food the
33
+ // moment anything is in view. scripts/compare-prey-decision.mjs measures
34
+ // the two against each other.
30
35
 
31
36
  import {
32
37
  DEFAULT_FACING, TOWN_SQUARE_LAYOUTS,
@@ -58,6 +63,12 @@ const PLACEMENT_PREDICATE = "mgx:currently-in";
58
63
  const MASS_PREDICATE = "mgx:hasMass";
59
64
  const MOOD_PREDICATE = "mgx:feels";
60
65
  const FACING_PREDICATE = "mgx:facing";
66
+ // The facing a visitor's own hand turned an agent to, written only on a driven
67
+ // turn. It folds into the same facing map as the ordinary row below, which is
68
+ // what makes a hand turn hold while the agent stands still and lose the moment
69
+ // the planner takes a step of its own: the step's own facing row is stamped
70
+ // with a later turn, and a later turn outranks.
71
+ const DRIVEN_FACING_PREDICATE = "mgx:driven-facing";
61
72
  const EATEN_BY_PREDICATE = "mgx:eaten-by";
62
73
  const STARVED_PREDICATE = "mgx:starved";
63
74
  const PLACED_BY_PREDICATE = "mgx:placed-by";
@@ -86,7 +97,7 @@ const TURN_PLAYED_PREDICATE = "mgx:turn-played";
86
97
  const BOARD_SUBJECT = "square";
87
98
 
88
99
  const MUDIII_STATE_PREDICATE_SET = new Set([
89
- PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE,
100
+ PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE, DRIVEN_FACING_PREDICATE,
90
101
  EATEN_BY_PREDICATE, STARVED_PREDICATE, PLACED_BY_PREDICATE,
91
102
  MODEL_PREDICATE, ROTATION_PREDICATE, TURN_PLAYED_PREDICATE,
92
103
  CARRYING_PREDICATE, PREY_EATEN_PREDICATE, WEB_BUILT_PREDICATE,
@@ -208,6 +219,7 @@ export function foldTownSquareState(factRows) {
208
219
  if (snap && rowEpoch === epoch) tickCount = Math.max(tickCount, turn);
209
220
  break;
210
221
  case FACING_PREDICATE:
222
+ case DRIVEN_FACING_PREDICATE:
211
223
  if (outranks(rowEpoch, turn, facing.get(base))) facing.set(base, { value: row.object, turn, epoch: rowEpoch });
212
224
  break;
213
225
  case EATEN_BY_PREDICATE:
@@ -389,6 +401,29 @@ export function greedyToward(fromCell, towardCell, applyActions) {
389
401
  );
390
402
  }
391
403
 
404
+ /** One-ply greedy over BOTH distances at once, rather than one rung after the
405
+ * other: the reachable cell (or staying put) that maximizes
406
+ *
407
+ * weight * distance-from-`awayFrom` - (1 - weight) * distance-to-`towardCell`
408
+ *
409
+ * At weight 1 this is greedyAway and at weight 0 it is greedyToward. In
410
+ * between it lets an agent take a step toward food that costs it little or no
411
+ * distance from the thing hunting it, which strict priority rungs cannot do.
412
+ *
413
+ * Falls back to whichever single term it still has when the other cell is
414
+ * null, so a caller never has to check first. */
415
+ export function greedyBlend(fromCell, awayFrom, towardCell, applyActions, weight) {
416
+ if (!awayFrom) return greedyToward(fromCell, towardCell, applyActions);
417
+ if (!towardCell) return greedyAway(fromCell, awayFrom, applyActions);
418
+ const w = Number.isFinite(Number(weight)) ? Number(weight) : DEFAULT_GAME_CONFIG.mudiii.preyThreatWeight;
419
+ return bestOneStepBy(
420
+ fromCell, applyActions,
421
+ (cell) => w * chebyshevDistance(cell.x, cell.y, awayFrom.x, awayFrom.y)
422
+ - (1 - w) * chebyshevDistance(cell.x, cell.y, towardCell.x, towardCell.y),
423
+ (score, bestScore) => score > bestScore,
424
+ );
425
+ }
426
+
392
427
  /** A seeded, uniform pick among staying put or any one-ply reachable cell.
393
428
  * Deterministic and replayable, and it looks random to somebody watching.
394
429
  * Both roles' last rung: a motionless predator reads as a broken page. */
@@ -448,6 +483,29 @@ function stepPlan(fromCell, toCell) {
448
483
  return direction ? [direction] : [];
449
484
  }
450
485
 
486
+ /**
487
+ * The hand-driven move `entry` asks of an agent standing at `fromCell`, as
488
+ * `{ cell, facing }` with `cell` a parsed cell — or null when nothing was
489
+ * asked, or when what was asked is not a legal one-step move.
490
+ *
491
+ * `entry` is a target cell id, or `{ cell, facing }` with both parts optional:
492
+ * no cell holds the agent where it stands, which is what a turn on the spot
493
+ * is, and no facing leaves the facing to the step itself.
494
+ *
495
+ * The legality table is oneStepOptions — the very list the wander, evade and
496
+ * chase rungs pick from — so a wall or a prop is a missing exit here too,
497
+ * never a second notion of blocked, and a refusal reads as "no such move"
498
+ * rather than as a rule this seam invented. Pure.
499
+ */
500
+ function acceptedManualMove(entry, fromCell, applyActions) {
501
+ if (!entry) return null;
502
+ const request = typeof entry === "string" ? { cell: entry } : entry;
503
+ const facing = typeof request.facing === "string" && request.facing ? request.facing : null;
504
+ const target = typeof request.cell === "string" && request.cell ? request.cell : cellId(fromCell.x, fromCell.y);
505
+ const cell = oneStepOptions(fromCell, applyActions).find((option) => cellId(option.x, option.y) === target);
506
+ return cell ? { cell, facing } : null;
507
+ }
508
+
451
509
  const round2 = (n) => Math.round(n * 100) / 100;
452
510
 
453
511
  // ---- the goal line and the mood word -------------------------------------------
@@ -457,8 +515,9 @@ const round2 = (n) => Math.round(n * 100) / 100;
457
515
  // is scared, anything wandering or foraging is calm, and anything that just ate
458
516
  // is happy.
459
517
 
460
- function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false } = {}) {
518
+ function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false, facing = null, held = false } = {}) {
461
519
  switch (kind) {
520
+ case "driven": return held ? `driven by hand — holding at ${cell}, facing ${facing}.` : `driven by hand — stepping to ${cell}.`;
462
521
  case "avoid": return `avoiding ${subject}, last seen at ${cell}.`;
463
522
  case "chase":
464
523
  if (!arrived) return `chasing ${subject}, last seen at ${cell}.`;
@@ -980,11 +1039,20 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
980
1039
  * to where the predator was when it looked), while eating resolves on the
981
1040
  * post-move ones (it is caught where the predator actually ends up).
982
1041
  *
1042
+ * `manualMoves` is the one place a visitor's own hand reaches the world:
1043
+ * `{ agentId: cellId }`, or `{ agentId: { cell, facing } }`, checked before
1044
+ * that agent's belief chain runs. A legal request moves the agent under the
1045
+ * `driven` rung; an illegal one is refused and that agent decides for itself
1046
+ * this turn, so a rejected press never freezes it. A driven turn spends a
1047
+ * turn like any other: the ecology pass runs, and every other agent decides
1048
+ * and moves in this same call.
1049
+ *
983
1050
  * Returns `{ turn, epoch, agents, items, ecology, rungs, activeWebs, writes }`.
984
1051
  * `agents` and `items` and `ecology` are the frozen render payload — see
985
1052
  * townSquareTickPayload, which projects exactly those three plus the turn.
986
- * `rungs` is the decision each live agent reached this turn ("chase", "evade",
987
- * "forage", "avoid", "wander", and — on a cast that carries or spins webs —
1053
+ * `rungs` is the decision each live agent reached this turn ("driven" for a
1054
+ * hand-driven move, then "chase", "evade", "forage", "avoid", "wander", and —
1055
+ * on a cast that carries or spins webs —
988
1056
  * "carry", "deliver", "carried", "trapped", "hold-web", "build-web"); an agent
989
1057
  * that decided and then died still has a rung and no longer has an `agents`
990
1058
  * entry, which is the difference between a decision and a survivor.
@@ -993,6 +1061,7 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
993
1061
  */
994
1062
  export async function runTownSquareTick(memoryDir, {
995
1063
  layout, toldFacts = [], config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
1064
+ manualMoves = {},
996
1065
  } = {}) {
997
1066
  const lay = typeof layout === "string" ? TOWN_SQUARE_LAYOUTS[layout] : layout;
998
1067
  if (!lay) throw new Error(`runTownSquareTick: no such layout "${layout}"`);
@@ -1012,6 +1081,10 @@ export async function runTownSquareTick(memoryDir, {
1012
1081
 
1013
1082
  const carriesPrey = config.carryPreyToWeb === true;
1014
1083
  const buildsWebs = config.buildWebs === true;
1084
+ const blendsPreyDecision = config.blendPreyDecision === true;
1085
+ const preyThreatWeight = Number.isFinite(Number(config.preyThreatWeight))
1086
+ ? Number(config.preyThreatWeight)
1087
+ : DEFAULT_GAME_CONFIG.mudiii.preyThreatWeight;
1015
1088
  const boardNoun = lay.boardNoun ?? "square";
1016
1089
  const webbedAt = (c) => hasActiveWebAt(lay, state, k, c.x, c.y, config.webDurationTurns);
1017
1090
  // Widened in place as predators spin webs this tick, for the renderer's own
@@ -1031,6 +1104,11 @@ export async function runTownSquareTick(memoryDir, {
1031
1104
 
1032
1105
  const decide = (agentId, role) => {
1033
1106
  const fromCell = parseCellId(state.placements.get(agentId).cell);
1107
+ // The visitor's hand, checked before any of the belief chain below. A
1108
+ // refused request leaves `driven` null, and the chain then runs exactly as
1109
+ // it would have on a turn nobody touched.
1110
+ const driven = acceptedManualMove(manualMoves?.[agentId], fromCell, applyActions);
1111
+ const restingFacing = state.facing.get(agentId)?.value ?? DEFAULT_FACING;
1034
1112
  const visionRadius = role === "predator" ? config.predatorVisionRadius : config.preyVisionRadius;
1035
1113
  const beliefOpts = { visionRadius, toldFacts };
1036
1114
  // True unless a caller explicitly turns it off. A config object built by
@@ -1045,13 +1123,14 @@ export async function runTownSquareTick(memoryDir, {
1045
1123
  // lookup below stays on beliefOpts either way: this switch is about food.
1046
1124
  const foodBeliefOpts = foodVisionGated ? beliefOpts : { ...beliefOpts, visionRadius: Infinity };
1047
1125
  const rivals = role === "predator" ? predators.filter((id) => id !== agentId) : predators;
1048
- const threat = nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
1126
+ const threat = driven ? null : nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
1049
1127
 
1050
1128
  let rung;
1051
1129
  let nextCell;
1052
1130
  let plan;
1053
1131
  let goal;
1054
1132
  let mood;
1133
+ let drivenFacing = null;
1055
1134
  // A carried prey and its captor both leave the ordinary chain. A carrying
1056
1135
  // predator never drops its catch to dodge a rival or chase a second one:
1057
1136
  // nothing here eats a predator, so "avoid" is contention, not survival.
@@ -1060,7 +1139,18 @@ export async function runTownSquareTick(memoryDir, {
1060
1139
  const captorId = (carriesPrey && role === "prey") ? (captorOfPrey.get(agentId) ?? null) : null;
1061
1140
  let beliefCell = fromCell;
1062
1141
 
1063
- if (isCarrying && webbedAt(fromCell)) {
1142
+ if (driven) {
1143
+ rung = "driven";
1144
+ nextCell = driven.cell;
1145
+ plan = stepPlan(fromCell, nextCell);
1146
+ drivenFacing = driven.facing;
1147
+ goal = goalLine("driven", {
1148
+ cell: cellId(nextCell.x, nextCell.y),
1149
+ facing: driven.facing ?? restingFacing,
1150
+ held: plan.length === 0,
1151
+ });
1152
+ mood = "calm";
1153
+ } else if (isCarrying && webbedAt(fromCell)) {
1064
1154
  // Already standing in a web: hold, so the pass's own eat gate reads this
1065
1155
  // same cell and resolves the delivery on this exact tick.
1066
1156
  rung = "deliver";
@@ -1094,18 +1184,41 @@ export async function runTownSquareTick(memoryDir, {
1094
1184
  goal = goalLine("trapped");
1095
1185
  mood = "scared";
1096
1186
  } else if (threat) {
1097
- rung = role === "predator" ? "avoid" : "evade";
1098
- // A fleeing prey that already knows where food is should flee toward
1099
- // it, not away from it — among cells that are equally safe, break the
1100
- // tie toward the nearest believed crumb. Prey-only: the predator's own
1101
- // avoid rung passes nothing, so its ties still resolve the old way.
1102
1187
  const towardFood = role === "prey"
1103
1188
  ? nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts)
1104
1189
  : null;
1105
- nextCell = greedyAway(fromCell, threat.cell, applyActions, { towardCell: towardFood?.cell ?? null });
1106
- plan = stepPlan(fromCell, nextCell);
1107
- goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
1108
- mood = "scared";
1190
+ if (blendsPreyDecision && role === "prey" && towardFood) {
1191
+ // The one case the two rungs disagree about: this prey believes a
1192
+ // predator AND food, so a strict order has to pick between them and a
1193
+ // score does not.
1194
+ nextCell = greedyBlend(fromCell, threat.cell, towardFood.cell, applyActions, preyThreatWeight);
1195
+ const closedOnFood = chebyshevDistance(fromCell.x, fromCell.y, towardFood.cell.x, towardFood.cell.y)
1196
+ > chebyshevDistance(nextCell.x, nextCell.y, towardFood.cell.x, towardFood.cell.y);
1197
+ // The score is one number, but the step it buys is still one of two
1198
+ // legible things: this move closed on the crumb, or it did not. The
1199
+ // rung and the goal line say which, so no surface has to render
1200
+ // "mostly evading, somewhat hungry".
1201
+ rung = closedOnFood ? "forage" : "evade";
1202
+ plan = stepPlan(fromCell, nextCell);
1203
+ goal = closedOnFood
1204
+ ? goalLine("forage", {
1205
+ subject: towardFood.subject,
1206
+ cell: cellId(towardFood.cell.x, towardFood.cell.y),
1207
+ arrived: nextCell.x === towardFood.cell.x && nextCell.y === towardFood.cell.y,
1208
+ })
1209
+ : goalLine("evade", { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
1210
+ mood = closedOnFood ? "calm" : "scared";
1211
+ } else {
1212
+ rung = role === "predator" ? "avoid" : "evade";
1213
+ // A fleeing prey that already knows where food is should flee toward
1214
+ // it, not away from it — among cells that are equally safe, break the
1215
+ // tie toward the nearest believed crumb. Prey-only: the predator's own
1216
+ // avoid rung passes nothing, so its ties still resolve the old way.
1217
+ nextCell = greedyAway(fromCell, threat.cell, applyActions, { towardCell: towardFood?.cell ?? null });
1218
+ plan = stepPlan(fromCell, nextCell);
1219
+ goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
1220
+ mood = "scared";
1221
+ }
1109
1222
  } else {
1110
1223
  const quarry = role === "predator"
1111
1224
  ? nearestBelievedTarget(agentId, fromCell, prey, state, beliefOpts)
@@ -1166,7 +1279,10 @@ export async function runTownSquareTick(memoryDir, {
1166
1279
  const foodCandidates = beliefCandidates.filter((id) => foodIds.has(id));
1167
1280
  Object.assign(belief, beliefSnapshotFor(agentId, beliefCell, foodCandidates, state, foodBeliefOpts));
1168
1281
  }
1169
- const facing = plan[0] ?? state.facing.get(agentId)?.value ?? DEFAULT_FACING;
1282
+ // A hand-picked facing beats the step it came with, which is what lets the
1283
+ // page walk an agent backwards without spinning it round.
1284
+ const facing = drivenFacing ?? plan[0] ?? restingFacing;
1285
+ if (rung === "driven") movementWrites.push({ subject: stamp(agentId), predicate: DRIVEN_FACING_PREDICATE, object: facing });
1170
1286
  postMovePlacements.set(agentId, nextCell);
1171
1287
  rungs[agentId] = rung;
1172
1288
  agents[agentId] = { role, cell: cellId(nextCell.x, nextCell.y), facing, goal, mood, plan, mass: 0, belief };
@@ -19,7 +19,7 @@
19
19
  // renderResearchHtml() is pure: no I/O, deterministic output for identical
20
20
  // input. scripts/build-demo-site.mjs calls it directly and writes the result to
21
21
  // public/research.html, after research-browser.bundle.js already exists.
22
- import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
22
+ import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
23
23
  import { fetchWithProgress, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
24
24
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
25
25
  import { loadWinkVendor } from "./viz-boot.mjs";
@@ -108,7 +108,8 @@ ${DASH_DARK_CHROME_CSS}
108
108
  header.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.4rem; flex-wrap: wrap; padding: .6rem 0 1rem; border-bottom: 1px solid var(--line); margin-bottom: 1.3rem; }
109
109
  .brand { display: flex; flex-direction: column; gap: .3rem; max-width: 640px; }
110
110
  .eyebrow { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
111
- .subtitle { font-size: .92rem; color: var(--ink); opacity: .82; max-width: 58ch; }
111
+ ${EYEBROW_LINKS_CSS}
112
+ .subtitle { margin: 0; font-size: .92rem; font-weight: 400; color: var(--ink); opacity: .82; max-width: 58ch; }
112
113
  .statuspanel { display: flex; flex-wrap: wrap; gap: .5rem 1.1rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .9rem; }
113
114
  .statuspanel .stat { display: flex; flex-direction: column; gap: .14rem; min-width: 7rem; }
114
115
  .statuspanel .stat-label { font-family: ${MONO_STACK}; font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
@@ -240,8 +241,8 @@ ${DASH_DARK_CHROME_CSS}
240
241
  <div class="wrap">
241
242
  <header class="topbar">
242
243
  <div class="brand">
243
- <span class="eyebrow">the-mechanical-code-talker &middot; research</span>
244
- <span class="subtitle">Grow one graph three ways. Watch what it learns, then ask a question scoped to the sources you trust.</span>
244
+ <span class="eyebrow">${demoEyebrowHtml("research", "research")}</span>
245
+ <h1 class="subtitle">Grow one graph three ways. Watch what it learns, then ask a question scoped to the sources you trust.</h1>
245
246
  </div>
246
247
  <div class="statuspanel" id="statusPanel" aria-live="polite">
247
248
  <div class="stat stat-facts"><span class="stat-label">facts in the graph</span><span class="stat-value" id="statFacts">&mdash;</span></div>
@@ -32,7 +32,7 @@
32
32
  // bundle's own real ES exports instead, since (unlike ledger-viz, which
33
33
  // reuses a FIXED shared bundle it can't extend for one page's own needs) this
34
34
  // page ships its own dedicated bundle and can just export what it needs.
35
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, meterBarHtml } from "./viz-theme.mjs";
35
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, meterBarHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
36
36
  import { createTicker } from "./viz-ticker.mjs";
37
37
  import { loadWinkVendor } from "./viz-boot.mjs";
38
38
  import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId, agentKindOf } from "../domain/spider-fly-world.mjs";
@@ -250,6 +250,7 @@ ${THEME_TOKENS_CSS}
250
250
  .mono { font-family: ${MONO_STACK}; }
251
251
  main { max-width: 1120px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
252
252
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--chrome-accent); }
253
+ ${EYEBROW_LINKS_CSS}
253
254
  h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
254
255
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
255
256
  button:focus-visible, input:focus-visible, .sprite:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@@ -443,7 +444,7 @@ ${THEME_TOKENS_CSS}
443
444
  <main>
444
445
  <div class="stage page-head">
445
446
  <div class="head-inner">
446
- <div class="eyebrow">tmct &middot; spider and fly</div>
447
+ <div class="eyebrow">${demoEyebrowHtml("spider-fly", "spider and fly")}</div>
447
448
  <h1>Multiple competing planning agents</h1>
448
449
  </div>
449
450
  <div></div>
@@ -51,7 +51,7 @@ import { spriteFactRows } from "../domain/sprite-facts.mjs";
51
51
  import { SEED_TAXONOMY } from "../domain/spider-fly-world.mjs";
52
52
  import { loadSlice, loadMap, toFacts, WORDNET_DIR } from "../adapters/corpus/conceptnet.mjs";
53
53
  import { join } from "node:path";
54
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
54
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
55
55
 
56
56
  const DEFAULT_TITLE = "tmct — the sprite library";
57
57
  const MAX_CHAIN_DISPLAY = 6;
@@ -905,6 +905,7 @@ ${THEME_TOKENS_CSS}
905
905
  .appbar { display: flex; align-items: flex-end; gap: 1rem; margin: 0 -1.2rem; padding: .6rem 1.2rem 0; background: var(--ai-bar); }
906
906
  .appbar h1 { font-family: ${MONO_STACK}; font-size: .84rem; font-weight: 600; letter-spacing: .02em; margin: 0; padding: .32rem .8rem .38rem; background: var(--ai-panel); color: var(--ink); border-radius: 4px 4px 0 0; }
907
907
  .appbar .doc-sub { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: color-mix(in srgb, var(--ai-bar-ink) 65%, transparent); padding-bottom: .5rem; }
908
+ ${EYEBROW_LINKS_CSS}
908
909
  .topbar { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; background: var(--ai-panel); border-bottom: 1px solid var(--ai-edge); margin: 0 -1.2rem 1.4rem; padding: .5rem 1.2rem; }
909
910
  .jump { font-family: ${MONO_STACK}; font-size: .7rem; padding: .2rem .6rem; border: 1px solid var(--ai-edge); border-radius: 3px; background: transparent; color: var(--ink); text-decoration: none; }
910
911
  .jump:hover { border-color: var(--corpus); color: var(--corpus); }
@@ -982,7 +983,7 @@ ${dockCss}</style>
982
983
  <main>
983
984
  <header class="appbar">
984
985
  <h1>Sprites</h1>
985
- <span class="doc-sub">tmct &middot; the sprite library</span>
986
+ <span class="doc-sub">${demoEyebrowHtml("sprites", "the sprite library")}</span>
986
987
  </header>
987
988
  ${composerHtml}
988
989
  ${dockHtml}
@@ -32,6 +32,26 @@ export function embedScriptText(js) {
32
32
  return String(js ?? "").replaceAll("</script", "<\\/script");
33
33
  }
34
34
 
35
+ /** The three-link nav every demo page's header opens with: the site's own
36
+ * name (home), the demo's own name (its own page) and "about" (its about
37
+ * page). `page` is the demo's key in scripts/site-pages.mjs's DEMO_PAGES,
38
+ * which is also its filename stem (e.g. "mud" -> mud.html); the about-page
39
+ * suffix below mirrors that same file's `aboutPageOf`, restated rather than
40
+ * imported since scripts/ sits outside src/ and never ships with the
41
+ * published package. Returns the inner markup only — the caller supplies
42
+ * the wrapping element (div/h1/span) and keeps whatever class and
43
+ * neighbouring markup its own header already carries. Style hook:
44
+ * EYEBROW_LINKS_CSS. */
45
+ export function demoEyebrowHtml(page, label) {
46
+ return `<span class="eyebrow-links"><a href="./index.html">tmct</a> &middot; <a href="./${page}.html">${escapeHtml(label)}</a> &middot; <a href="./${page}-about.html">about</a></span>`;
47
+ }
48
+
49
+ /** Styling every page that calls demoEyebrowHtml splices into its own inline
50
+ * <style>: the links read in the eyebrow's own colour (each page sets that
51
+ * colour itself), underlining only on hover/focus. */
52
+ export const EYEBROW_LINKS_CSS = `.eyebrow-links a { color: inherit; text-decoration: none; }
53
+ .eyebrow-links a:hover, .eyebrow-links a:focus-visible { text-decoration: underline; }`;
54
+
35
55
  /** A world name read as a name in a scenario dropdown: "mud-garden" ->
36
56
  * "mud garden". The world's own hyphenated id is the only thing every caller
37
57
  * is guaranteed to have, so a scenario that wants a hand-written label passes