@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.0.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.
Files changed (68) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +34 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +49 -9
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  13. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  14. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  15. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  16. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  17. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  18. package/data/sprites/animal-icon.toml +11 -5
  19. package/data/sprites/book-icon.toml +9 -5
  20. package/data/sprites/cabinet-icon.toml +10 -5
  21. package/data/sprites/cellar-icon.toml +16 -6
  22. package/data/sprites/container-icon.toml +7 -3
  23. package/data/sprites/desk-icon.toml +8 -5
  24. package/data/sprites/dog-icon.toml +8 -5
  25. package/data/sprites/dog-with-colour-icon.toml +13 -12
  26. package/data/sprites/drawing-room-icon.toml +14 -5
  27. package/data/sprites/egg-icon.toml +6 -3
  28. package/data/sprites/fly-icon.toml +10 -5
  29. package/data/sprites/furniture-icon.toml +5 -3
  30. package/data/sprites/garden-icon.toml +10 -3
  31. package/data/sprites/key-icon.toml +3 -1
  32. package/data/sprites/kitchen-icon.toml +15 -6
  33. package/data/sprites/lamp-icon.toml +9 -4
  34. package/data/sprites/letter-icon.toml +7 -4
  35. package/data/sprites/library-icon.toml +14 -3
  36. package/data/sprites/pan-icon.toml +7 -3
  37. package/data/sprites/person-icon.toml +6 -2
  38. package/data/sprites/poodle-icon.toml +1 -1
  39. package/data/sprites/portable-icon.toml +6 -4
  40. package/data/sprites/portrait-icon.toml +7 -4
  41. package/data/sprites/room-icon.toml +11 -2
  42. package/data/sprites/spider-icon.toml +9 -3
  43. package/data/sprites/study-icon.toml +10 -2
  44. package/package.json +5 -4
  45. package/src/domain/grammar/ace.mjs +40 -4
  46. package/src/domain/grammar/lexicon-core.json +2 -1
  47. package/src/domain/grammar/lexicon.mjs +18 -0
  48. package/src/domain/reference-pack.mjs +31 -7
  49. package/src/domain/spider-fly-world.mjs +1 -1
  50. package/src/domain/sprite-templates.mjs +8 -6
  51. package/src/services/adventure-viz.mjs +130 -47
  52. package/src/services/adventure.mjs +440 -367
  53. package/src/services/chat-page-viz.mjs +37 -9
  54. package/src/services/chat.mjs +99 -18
  55. package/src/services/code-explorer-viz.mjs +52 -14
  56. package/src/services/extract-facts.mjs +4 -7
  57. package/src/services/ingest-viz.mjs +54 -26
  58. package/src/services/ledger-viz.mjs +105 -56
  59. package/src/services/memory-panel-viz.mjs +24 -0
  60. package/src/services/mud-viz.mjs +940 -21
  61. package/src/services/plan-viz.mjs +119 -61
  62. package/src/services/research-viz.mjs +151 -57
  63. package/src/services/spider-fly-turn.mjs +1 -1
  64. package/src/services/spider-fly-viz.mjs +67 -43
  65. package/src/services/sprite-catalog-viz.mjs +175 -51
  66. package/src/services/viz-theme.mjs +15 -0
  67. package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
  68. package/src/surfaces/web/mud-browser-entry.mjs +44 -2
@@ -26,7 +26,7 @@
26
26
  // input. scripts/build-demo-site.mjs calls it directly and writes the result
27
27
  // to public/ingest.html, after ingest-browser.bundle.js already exists.
28
28
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
29
- import { bandLabelFor, statsSummaryLine, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
29
+ import { bandLabelFor, statsSummaryLine, clearSiteAssetCaches, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
30
30
 
31
31
  const DEFAULT_TITLE = "the-mechanical-code-talker — ingest";
32
32
 
@@ -69,7 +69,7 @@ export function loadProgressLine(parts) {
69
69
  /** The self-contained ingest page. Pure — the same output for the same
70
70
  * `title` every time; every piece of state (the session, each grounded fact)
71
71
  * is computed live in the browser once the sibling ingest bundle loads. */
72
- export function renderIngestHtml({ title = DEFAULT_TITLE } = {}) {
72
+ export function renderIngestHtml({ title = DEFAULT_TITLE, seedStamp = "" } = {}) {
73
73
  return `<!doctype html>
74
74
  <html lang="en">
75
75
  <head>
@@ -83,8 +83,8 @@ export function renderIngestHtml({ title = DEFAULT_TITLE } = {}) {
83
83
  The wink lemma/POS tier loads from ./vendor/wink.js — the site's own shared
84
84
  first-party bundle (built by scripts/build-wink-vendor.mjs), one cached copy
85
85
  for every page, no CDN. The ingest engine needs wink to split sentences and
86
- to parse each teach frame; a failed load degrades to the honest "nothing
87
- recognized" answer, never an error.
86
+ to parse each teach frame. A failed load still answers "nothing
87
+ recognized" instead of erroring.
88
88
  -->
89
89
  <style>
90
90
  ${THEME_TOKENS_CSS}
@@ -103,6 +103,13 @@ ${THEME_TOKENS_CSS}
103
103
  .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
104
104
  .subtitle { font-size: .82rem; color: var(--muted); }
105
105
 
106
+ /* the live memory count, in the topbar rather than the status line: it is
107
+ the one number that says what this page's memory actually holds, and the
108
+ status line beside the buttons is where a wrong one goes unnoticed. */
109
+ .topbar-right { display: flex; align-items: center; gap: .7rem; }
110
+ .fact-pill { display: inline-flex; align-items: baseline; gap: .34rem; font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--corpus-t1); border-radius: 99px; padding: .2rem .8rem; background: var(--corpus-soft); white-space: nowrap; }
111
+ .fact-pill .fact-pill-value { font-size: .94rem; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 600; color: var(--ink); }
112
+
106
113
  /* mode pills — Text | Document — the translate-tool idiom: two segments in
107
114
  one rounded track, the active one filled. */
108
115
  .pills { display: inline-flex; border: 1px solid var(--line); border-radius: 99px; overflow: hidden; font-family: ${MONO_STACK}; font-size: .72rem; }
@@ -140,7 +147,7 @@ ${THEME_TOKENS_CSS}
140
147
  #facts .fact .pred { color: var(--corpus); white-space: nowrap; }
141
148
  #facts .fact .obj { color: var(--ink); word-break: break-word; }
142
149
  #facts .fact .prov { grid-column: 1 / -1; color: var(--muted); font-size: .66rem; }
143
- #facts .empty { color: var(--muted); padding: .5rem 0; }
150
+ #facts .empty { color: var(--muted); text-align: center; max-width: 24rem; line-height: 1.6; margin: 3rem auto 0; }
144
151
 
145
152
  .actions { flex: 0 0 auto; display: flex; align-items: center; gap: .6rem; padding: .6rem 1.1rem; border-top: 1px solid var(--line); flex-wrap: wrap; }
146
153
  .actions .btn { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .35rem .9rem; background: var(--card); }
@@ -153,7 +160,7 @@ ${THEME_TOKENS_CSS}
153
160
  the same class names and breakpoint chat-page-viz.mjs's own docked panel
154
161
  uses, re-rendered after boot and after every ingest from
155
162
  window.tmctIngest's own memoryStats(). */
156
- .statsPanel { flex: 0 0 300px; max-width: 300px; overflow-y: auto; border-left: 1px solid var(--line); padding: 1.1rem 1.2rem 1.6rem; font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.55; }
163
+ .statsPanel { flex: 0 0 300px; max-width: 300px; overflow-y: auto; border-left: 1px solid var(--line); padding: 1.1rem 1.2rem 1.6rem; font-family: ${MONO_STACK}; font-size: .74rem; line-height: 1.55; display: flex; flex-direction: column; }
157
164
  .statsPanel h2 { font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); margin: 1.3rem 0 .5rem; }
158
165
  .statsPanel h2:first-child { margin-top: 0; }
159
166
  .statsPanel .band-row { display: flex; justify-content: space-between; gap: .6rem; margin: 0; padding: .12rem 0; }
@@ -161,9 +168,12 @@ ${THEME_TOKENS_CSS}
161
168
  .statsPanel .taught-item { margin: 0 0 .7rem; }
162
169
  .statsPanel .taught-tag { display: block; color: var(--muted); font-size: .66rem; margin-top: .15rem; word-break: break-word; }
163
170
  .statsPanel .empty { color: var(--muted); margin: 0; }
164
- .statsPanel .forget-btn { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: .18rem .55rem; margin-top: 1.1rem; background: var(--card); }
171
+ /* The reset action anchors to the bottom of the rail via the auto margin
172
+ on a tall viewport with little taught yet, that reads as a pinned
173
+ footer control instead of leaving a trailing gap under it. */
174
+ .statsPanel .forget-btn { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: .18rem .55rem; margin-top: auto; background: var(--card); flex: none; }
165
175
  .statsPanel .forget-btn:hover { color: var(--ink); }
166
- .statsPanel .persist-note { color: var(--muted); font-size: .64rem; margin: .4rem 0 0; }
176
+ .statsPanel .persist-note { color: var(--muted); font-size: .64rem; margin: .4rem 0 0; flex: none; }
167
177
 
168
178
  @media (max-width: 860px) {
169
179
  .statsPanel { display: none; }
@@ -179,19 +189,25 @@ ${THEME_TOKENS_CSS}
179
189
  <header class="topbar">
180
190
  <div class="brand">
181
191
  <span class="eyebrow">the-mechanical-code-talker</span>
182
- <span class="subtitle">ingest &mdash; paste or drop text; it keeps only the facts it can ground, and skips the rest honestly</span>
192
+ <span class="subtitle">ingest &mdash; paste or drop text. It keeps the facts it can ground and skips the rest.</span>
183
193
  </div>
184
- <div class="pills" role="group" aria-label="Input mode">
185
- <button type="button" id="modeText" aria-pressed="true">Text</button>
186
- <button type="button" id="modeDoc" aria-pressed="false">Document</button>
194
+ <div class="topbar-right">
195
+ <span class="fact-pill" id="factPill" aria-live="polite"
196
+ title="every fact this page's memory holds right now — the starter memory it booted with plus everything it has grounded since">
197
+ <span class="fact-pill-value" id="factPillValue">&mdash;</span> facts in memory
198
+ </span>
199
+ <div class="pills" role="group" aria-label="Input mode">
200
+ <button type="button" id="modeText" aria-pressed="true">Text</button>
201
+ <button type="button" id="modeDoc" aria-pressed="false">Document</button>
202
+ </div>
187
203
  </div>
188
204
  </header>
189
205
  <div class="optionsRow" id="optionsRow">
190
- <label class="optionToggle" title="Loads chat.html's own starter memory (persona, ConceptNet, WordNet and the rest) before ingesting, so a taught fact can link into what it already knows. Off keeps the previous empty-store fast path.">
206
+ <label class="optionToggle" title="Loads chat.html's own starter memory (persona, ConceptNet, WordNet and the rest) before ingesting, so a taught fact can link into what it already knows. Off starts from an empty store, which loads faster.">
191
207
  <input type="checkbox" id="seedToggle" checked>
192
208
  seed with general knowledge
193
209
  </label>
194
- <label class="optionToggle" title="On a miss, also tries a copula or known relation verb flanked by two resolvable entities as a low-trust candidate fact, tagged optimistic-extract below every curated source, and never able to corroborate one.">
210
+ <label class="optionToggle" title="On a miss, also tries a copula or a known relation verb between two resolvable entities, tagged optimistic-extract. It ranks below every curated source and can never corroborate one.">
195
211
  <input type="checkbox" id="fuzzyToggle">
196
212
  fuzzy tier (low-trust candidates)
197
213
  </label>
@@ -234,6 +250,7 @@ ${THEME_TOKENS_CSS}
234
250
  const loadProgressLine = ${loadProgressLine.toString()};
235
251
  const bandLabelFor = ${bandLabelFor.toString()};
236
252
  const statsSummaryLine = ${statsSummaryLine.toString()};
253
+ const clearSiteAssetCaches = ${clearSiteAssetCaches.toString()};
237
254
  const fetchWithProgress = ${fetchWithProgress.toString()};
238
255
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
239
256
  const el = (id) => document.getElementById(id);
@@ -256,6 +273,7 @@ ${THEME_TOKENS_CSS}
256
273
  const seedToggleEl = el("seedToggle");
257
274
  const fuzzyToggleEl = el("fuzzyToggle");
258
275
  const statsPanelEl = el("statsPanel");
276
+ const factPillValueEl = el("factPillValue");
259
277
 
260
278
  let session = null;
261
279
  let grounded = 0; // facts on show in the right pane, from the CURRENT ingest only
@@ -371,9 +389,10 @@ ${THEME_TOKENS_CSS}
371
389
  try { stats = await window.tmctIngest.memoryStats(session.memoryDir); }
372
390
  catch { return; }
373
391
  }
392
+ factPillValueEl.textContent = Number(stats.total || 0).toLocaleString();
374
393
  renderStatsPanelInto(statsPanelEl, stats, {
375
394
  bandLabel: bandLabelFor,
376
- taughtHint: "nothing yet \\u2014 ingest some text and its grounded facts land here, with their source.",
395
+ taughtHint: "nothing yet. Ingest some text and its grounded facts land here, with their source.",
377
396
  onForget: persist ? forgetEverything : null,
378
397
  persistNote: "taught facts are kept best-effort on this device (IndexedDB), never sent anywhere.",
379
398
  });
@@ -391,6 +410,12 @@ ${THEME_TOKENS_CSS}
391
410
  try { localStorage.setItem(SEED_PREF_KEY, on ? "on" : "off"); } catch { /* private mode — this visit still works */ }
392
411
  }
393
412
 
413
+ // The build's own content hash for the seed. It rides in the URL this page
414
+ // fetches the seed by, so the service worker's cache-first read can only
415
+ // ever return the copy this page asked for. Empty in a build with no seed.
416
+ const SEED_STAMP = ${JSON.stringify(seedStamp)};
417
+ const SEED_QUERY = SEED_STAMP ? "?b=" + SEED_STAMP : "";
418
+
394
419
  let seedPayload = null;
395
420
  let seedFacts = 0;
396
421
  const progressParts = {};
@@ -406,7 +431,7 @@ ${THEME_TOKENS_CSS}
406
431
  async function fetchSeedIfWanted() {
407
432
  if (!seedToggleEl.checked) { seedPayload = null; seedFacts = 0; return; }
408
433
  try {
409
- const blob = await fetchWithProgress("./chat-seed.json", (loaded, total) => noteProgress("seed", loaded, total));
434
+ const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
410
435
  seedPayload = JSON.parse(await blob.text());
411
436
  seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
412
437
  } catch (err) {
@@ -488,7 +513,7 @@ ${THEME_TOKENS_CSS}
488
513
  clearFactsPane();
489
514
  updateIngestEnabled();
490
515
  const stats = await window.tmctIngest.memoryStats(session.memoryDir);
491
- statusEl.textContent = "forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").";
516
+ statusEl.textContent = "forgot everything taught on this device. Back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").";
492
517
  await renderStatsPanel(stats);
493
518
  }
494
519
 
@@ -506,7 +531,7 @@ ${THEME_TOKENS_CSS}
506
531
  session = newSession();
507
532
  clearFactsPane();
508
533
  const stats = await window.tmctIngest.memoryStats(session.memoryDir);
509
- statusEl.textContent = statsSummaryLine(stats, bandLabelFor) + " \\u2014 ready.";
534
+ statusEl.textContent = statsSummaryLine(stats, bandLabelFor) + ". Ready.";
510
535
  await renderStatsPanel(stats);
511
536
  updateIngestEnabled();
512
537
  sourceEl.focus();
@@ -514,7 +539,7 @@ ${THEME_TOKENS_CSS}
514
539
 
515
540
  async function boot() {
516
541
  if (!window.tmctIngest) {
517
- statusEl.textContent = "the ingest engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
542
+ statusEl.textContent = "the ingest engine didn't load. This page needs its build step (npm run demo:build)";
518
543
  return;
519
544
  }
520
545
  seedToggleEl.checked = readSeedPref();
@@ -525,7 +550,7 @@ ${THEME_TOKENS_CSS}
525
550
  ]);
526
551
  progressActive = false;
527
552
  if (window.tmctIngest.openPersistedStore) {
528
- persist = window.tmctIngest.openPersistedStore({ storeKey: "ingest", stamp: siteVersion + ":" + seedFacts });
553
+ persist = window.tmctIngest.openPersistedStore({ storeKey: "ingest", stamp: siteVersion + ":" + seedFacts + ":" + SEED_STAMP });
529
554
  }
530
555
  const savedRecord = persist ? await persist.load() : null;
531
556
  session = savedRecord && savedRecord.payload
@@ -536,9 +561,9 @@ ${THEME_TOKENS_CSS}
536
561
  const stats = await window.tmctIngest.memoryStats(session.memoryDir);
537
562
  const winkPart = winkStatus === "loaded"
538
563
  ? "wink-nlp: loaded"
539
- : "wink-nlp unavailable \\u2014 the recognizer can't split sentences without it";
564
+ : "wink-nlp unavailable. The recognizer can't split sentences without it";
540
565
  statusEl.textContent = statsSummaryLine(stats, bandLabelFor) + " \\u00b7 " + winkPart
541
- + (savedRecord ? " \\u2014 restored from your last visit." : " \\u2014 paste or drop text, then ingest.");
566
+ + (savedRecord ? ". Restored from your last visit." : ". Paste or drop text, then ingest.");
542
567
  await renderStatsPanel(stats);
543
568
  sourceEl.focus();
544
569
  }
@@ -562,7 +587,7 @@ ${THEME_TOKENS_CSS}
562
587
  });
563
588
  statusEl.textContent = summary.sentences + " sentence" + (summary.sentences === 1 ? "" : "s")
564
589
  + " read, " + summary.recognized + " grounded, " + summary.skipped + " skipped"
565
- + (summary.skipped ? " (not a recognized fact shape \\u2014 honest, expected)" : "");
590
+ + (summary.skipped ? " (not a recognized fact shape, as expected)" : "");
566
591
  if (!summary.recognized) {
567
592
  const empty = factsEl.querySelector(".empty");
568
593
  if (!empty) {
@@ -605,13 +630,16 @@ ${THEME_TOKENS_CSS}
605
630
  setTimeout(() => URL.revokeObjectURL(url), 1000);
606
631
  });
607
632
 
608
- // "reset to seed" is the full re-initialisation: drop the persisted payload
609
- // outright and reload, so boot re-seeds from the page's shipped seed as if
610
- // on a first visit.
633
+ // "reset to seed" is the full re-initialisation: drop everything this device
634
+ // holds and reload, so boot re-seeds from the page's shipped seed as if on a
635
+ // first visit. Both stores go — what you taught lives in IndexedDB, and the
636
+ // seed asset itself lives in the service worker's Cache Storage, so clearing
637
+ // only the first would re-seed out of a cached copy of an older seed.
611
638
  el("reinitStore").addEventListener("click", async () => {
612
639
  clearTimeout(saveTimer);
613
640
  saveTimer = null;
614
641
  if (persist) await persist.clear();
642
+ await clearSiteAssetCaches();
615
643
  window.location.reload();
616
644
  });
617
645
 
@@ -15,7 +15,7 @@
15
15
  // doesn't do: it reads the checked-in chat-dock engine bundle.
16
16
 
17
17
  import { loadMemory, readFactRows, findContradictions, normFactTerm } from "../adapters/memory/core.mjs";
18
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
18
+ import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
19
19
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
20
20
  import { readFile } from "node:fs/promises";
21
21
  import { fileURLToPath } from "node:url";
@@ -23,6 +23,34 @@ import { dirname, join } from "node:path";
23
23
 
24
24
  const LEDGER_ROW_LIMIT_DEFAULT = 20000;
25
25
 
26
+ // The ledger reads as an observability dashboard (dense stat tiles, bar
27
+ // panels, a log-style fact stream) rather than an editorial page, so its
28
+ // chrome runs the system sans everywhere instead of THEME_TOKENS_CSS's
29
+ // serif body face; MONO_STACK still carries every metric, label, and log row.
30
+ const DASH_SANS_STACK = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
31
+
32
+ // This page pins a dark, New-Relic-style dashboard register in BOTH site
33
+ // themes — a deliberate departure from THEME_TOKENS_CSS's light/dark switch,
34
+ // which every other generated page still follows. --taught/--corpus/--entail/
35
+ // --alert stay the shared semantic provenance tokens; here they're pinned to
36
+ // their dark-theme-friendly values (viz-theme.mjs's own dark token table) so
37
+ // the palette that ships in dark mode elsewhere is what this page always
38
+ // shows. viz-theme.mjs exports only the assembled THEME_TOKENS_CSS string,
39
+ // not its per-mode token table, so the dark set is restated here from that
40
+ // table rather than recomputed.
41
+ const DASH_DARK_CHROME_CSS = `
42
+ :root { color-scheme: dark; }
43
+ body {
44
+ --bg: #15181C; --ink: #E7E5DF; --muted: #9A9E95; --line: #2B3036; --card: #1C2126;
45
+ --taught: #5FBE8B; --corpus: #6C93BF; --entail: #D9A554; --alert: #D08070;
46
+ --taught-t1: rgba(95,190,139,.35); --taught-t2: rgba(95,190,139,.65); --taught-t3: rgba(95,190,139,1);
47
+ --corpus-t1: rgba(108,147,191,.35); --corpus-t2: rgba(108,147,191,.65); --corpus-t3: rgba(108,147,191,1);
48
+ --entail-t1: rgba(217,165,84,.35); --entail-t2: rgba(217,165,84,.65); --entail-t3: rgba(217,165,84,1);
49
+ --taught-soft: rgba(95,190,139,.14); --corpus-soft: rgba(108,147,191,.14);
50
+ --entail-soft: rgba(217,165,84,.16); --alert-soft: rgba(208,128,112,.16);
51
+ }
52
+ `;
53
+
26
54
  /** Read the checked-in browser memory-ask-engine bundle
27
55
  * (`src/surfaces/web/memory-ask-browser.bundle.js`) — the real memory-graph answer engine
28
56
  * (chat.mjs's factAnswer/factReadBack) the chat dock runs on. Returns `""`,
@@ -407,7 +435,7 @@ function dashboardHtml(stats, { fresh = false } = {}) {
407
435
  const total = s.totalFacts || 0;
408
436
  const freshCls = fresh ? " fresh" : "";
409
437
  if (!total) {
410
- return `<section class="dash${freshCls}" id="dash" aria-label="Ledger metrics"><div class="tile"><span class="tile-label">facts.total</span><span class="tile-value">0</span><span class="tile-sub">nothing taught yet</span></div></section>`;
438
+ return `<section class="dash${freshCls}" id="dash" aria-label="Ledger metrics"><div class="kpirow"><div class="tile"><span class="tile-label">facts.total</span><span class="tile-value">0</span><span class="tile-sub">nothing taught yet</span></div></div></section>`;
411
439
  }
412
440
  const terms = s.totalTerms || 0;
413
441
  const qualityCls = s.contradictionCount > 0 ? " tile-alert" : "";
@@ -415,33 +443,42 @@ function dashboardHtml(stats, { fresh = false } = {}) {
415
443
  const predicatesHtml = s.predicates?.length
416
444
  ? microbarsHtml(s.predicates.map((p) => ({ label: p.phrase || p.predicate, count: p.count })))
417
445
  : `<span class="tile-sub">none yet</span>`;
446
+ // Two explicit-column grids — a 4-up KPI row, then a 2-up row of bar-chart
447
+ // tiles — instead of one auto-fill grid: auto-fill's column count depends
448
+ // on viewport width, so a wide-tile mid-row wrap used to strand empty
449
+ // tracks on the right of both rows. Both stay `.tile` (the dashboard-strip
450
+ // e2e test counts six of them under `.dash`, whatever their sub-grid).
418
451
  return `<section class="dash${freshCls}" id="dash" aria-label="Ledger metrics">
419
- <div class="tile">
420
- <span class="tile-label">facts.total</span>
421
- <span class="tile-value">${total}</span>
422
- <span class="tile-sub">${terms} term${terms === 1 ? "" : "s"} tracked</span>
423
- </div>
424
- <div class="tile tile-wide">
425
- <span class="tile-label">facts.by-tier</span>
426
- ${tierTileHtml(s.byProv || {}, total)}
427
- </div>
428
- <div class="tile">
429
- <span class="tile-label">graph.avg-degree</span>
430
- <span class="tile-value">${(s.density || 0).toFixed(1)}</span>
431
- <span class="tile-sub">facts per term</span>
432
- </div>
433
- <div class="tile${qualityCls}">
434
- <span class="tile-label">data.quality</span>
435
- <span class="tile-value">${s.contradictionCount || 0}</span>
436
- <span class="tile-sub">term${s.contradictionCount === 1 ? "" : "s"} with more than one answer</span>
437
- </div>
438
- <div class="tile tile-wide">
439
- <span class="tile-label">corpus.bundles</span>
440
- <div class="bundlebars">${bundlesHtml}</div>
452
+ <div class="kpirow">
453
+ <div class="tile">
454
+ <span class="tile-label">facts.total</span>
455
+ <span class="tile-value">${total}</span>
456
+ <span class="tile-sub">${terms} term${terms === 1 ? "" : "s"} tracked</span>
457
+ </div>
458
+ <div class="tile tile-tier">
459
+ <span class="tile-label">facts.by-tier</span>
460
+ ${tierTileHtml(s.byProv || {}, total)}
461
+ </div>
462
+ <div class="tile">
463
+ <span class="tile-label">graph.avg-degree</span>
464
+ <span class="tile-value">${(s.density || 0).toFixed(1)}</span>
465
+ <span class="tile-sub">facts per term</span>
466
+ </div>
467
+ <div class="tile${qualityCls}">
468
+ <span class="tile-label">data.quality</span>
469
+ <span class="tile-value">${s.contradictionCount || 0}</span>
470
+ <span class="tile-sub">term${s.contradictionCount === 1 ? "" : "s"} with more than one answer</span>
471
+ </div>
441
472
  </div>
442
- <div class="tile tile-wide">
443
- <span class="tile-label">predicate.top</span>
444
- <div class="bundlebars">${predicatesHtml}</div>
473
+ <div class="barpanels">
474
+ <div class="tile tile-bars">
475
+ <span class="tile-label">corpus.bundles</span>
476
+ <div class="bundlebars">${bundlesHtml}</div>
477
+ </div>
478
+ <div class="tile tile-bars">
479
+ <span class="tile-label">predicate.top</span>
480
+ <div class="bundlebars">${predicatesHtml}</div>
481
+ </div>
445
482
  </div>
446
483
  </section>`;
447
484
  }
@@ -534,7 +571,7 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
534
571
  <button type="button" id="ingestToggle" class="dockbtn">ingest text&hellip;</button>
535
572
  <button type="button" id="exportFacts" class="dockbtn">export facts</button>
536
573
  </div>
537
- <div class="researchrow" title="Fetches the topic from Simple English Wikipedia into this graph, then queues the topics its lead section links to each queued topic runs as its own dock turn, paced politely. Asking is the network consent for these fetches.">
574
+ <div class="researchrow" title="Fetches the topic from Simple English Wikipedia into this graph, then queues the topics its lead section links to. Each queued topic runs as its own dock turn, paced politely. Asking is the network consent for these fetches.">
538
575
  <label for="researchTopic" class="mono">research:</label>
539
576
  <input id="researchTopic" type="text" autocomplete="off" spellcheck="false"
540
577
  placeholder="a topic, e.g. owls" aria-label="Topic to research on Simple English Wikipedia">
@@ -544,7 +581,7 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
544
581
  </div>
545
582
  <div class="ingestpanel" id="ingestPanel" hidden>
546
583
  <textarea id="ingestText" spellcheck="false" aria-label="Text to ingest into the graph"
547
- placeholder="Paste text or drop a .txt/.md file. Each sentence it recognizes as a fact is added to the graph; the rest are skipped honestly."></textarea>
584
+ placeholder="Paste text or drop a .txt/.md file. Each sentence it recognizes as a fact is added to the graph. The rest are skipped."></textarea>
548
585
  <div class="ingestrow">
549
586
  <button type="button" class="dockbtn" id="ingestBrowse">browse&hellip;</button>
550
587
  <input type="file" id="ingestFile" accept=".txt,.md,text/plain,text/markdown" hidden>
@@ -562,7 +599,7 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
562
599
  </form>
563
600
  ${ingestHtml}
564
601
  </div>`
565
- : `<div class="chat chat-off"><p class="chatnote">chat unavailable run <span class="mono">npm run build:ask-bundle</span> to enable the in-page ask engine.</p></div>`;
602
+ : `<div class="chat chat-off"><p class="chatnote">Chat unavailable. Run <span class="mono">npm run build:ask-bundle</span> to enable the in-page ask engine.</p></div>`;
566
603
 
567
604
  return `<!doctype html>
568
605
  <html lang="en">
@@ -587,46 +624,52 @@ export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, wo
587
624
  -->
588
625
  <style>
589
626
  ${THEME_TOKENS_CSS}
627
+ ${DASH_DARK_CHROME_CSS}
590
628
  html { background: var(--bg); }
591
- body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
629
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${DASH_SANS_STACK}; font-size: 15px; line-height: 1.5; }
592
630
  .mono { font-family: ${MONO_STACK}; }
593
- main { max-width: 1080px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
631
+ main { max-width: 1200px; margin: 0 auto; padding: 1.4rem 1.2rem 3rem; }
594
632
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); display: flex; flex-wrap: wrap; gap: .4em 1.2em; margin-bottom: .9rem; }
595
633
  button { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; }
596
- button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 4px; }
597
- .topbar { display: flex; flex-wrap: wrap; align-items: center; gap: .8rem; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: .5rem 0; margin-bottom: 1.1rem; }
634
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--corpus); outline-offset: 2px; border-radius: 4px; }
635
+ .topbar { display: flex; flex-wrap: wrap; align-items: center; gap: .8rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .7rem; margin-bottom: 1.1rem; }
598
636
  .crumbs { display: flex; align-items: center; flex-wrap: wrap; gap: .35rem; font-size: .78rem; }
599
637
  .crumbs .sep { color: var(--muted); }
600
- .crumb { font-family: ${MONO_STACK}; font-size: .74rem; padding: .12rem .5rem; border: 1px solid var(--line); border-radius: 99px; background: var(--card); }
638
+ .crumb { font-family: ${MONO_STACK}; font-size: .74rem; padding: .12rem .5rem; border: 1px solid var(--line); border-radius: 99px; background: var(--bg); }
601
639
  .crumb[aria-current="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
602
640
  .search { margin-left: auto; display: flex; align-items: center; gap: .5rem; }
603
- .search input { font-family: ${MONO_STACK}; font-size: .78rem; background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .3rem .6rem; width: 170px; }
641
+ .search input { font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: .3rem .6rem; width: 170px; }
604
642
  .search .miss { font-size: .72rem; color: var(--alert); font-family: ${MONO_STACK}; }
605
- .dash { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: .6rem; margin: 0 0 1.1rem; }
606
- .tile { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .55rem .7rem .6rem; display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
607
- .tile-wide { grid-column: span 2; }
608
- .tile-alert { border-color: var(--alert); }
643
+ /* Two explicit-column grids instead of one auto-fill grid: a 4-up KPI row,
644
+ then a 2-up row of bar-chart tiles, so both rows fill the container's
645
+ full width at any viewport rather than stranding a leftover track. */
646
+ .dash { display: flex; flex-direction: column; gap: .6rem; margin: 0 0 1.1rem; }
647
+ .kpirow { display: grid; grid-template-columns: repeat(4, 1fr); gap: .6rem; }
648
+ .barpanels { display: grid; grid-template-columns: repeat(2, 1fr); gap: .6rem; }
649
+ @media (max-width: 700px) { .kpirow { grid-template-columns: repeat(2, 1fr); } .barpanels { grid-template-columns: 1fr; } }
650
+ .tile { background: var(--card); border: 1px solid var(--line); border-left: 3px solid var(--line); border-radius: 6px; padding: .6rem .75rem .65rem; display: flex; flex-direction: column; gap: .18rem; min-width: 0; }
651
+ .tile-alert { border-left-color: var(--alert); }
609
652
  .tile-label { font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
610
- .tile-value { font-family: ${MONO_STACK}; font-size: 1.5rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.15; }
653
+ .tile-value { font-family: ${MONO_STACK}; font-size: 1.6rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.15; }
611
654
  .tile-alert .tile-value { color: var(--alert); }
612
655
  .tile-sub { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
613
- .tierbar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: var(--line); margin-top: .3rem; }
656
+ .tierbar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: var(--line); margin-top: .35rem; }
614
657
  .tseg { height: 100%; }
615
658
  .tseg.t-taught { background: var(--taught); } .tseg.t-corpus { background: var(--corpus); } .tseg.t-entail { background: var(--entail); }
616
659
  .tierlegend { display: flex; flex-wrap: wrap; gap: .15rem .7rem; margin-top: .35rem; font-family: ${MONO_STACK}; font-size: .65rem; }
617
660
  .tleg.t-taught { color: var(--taught); } .tleg.t-corpus { color: var(--corpus); } .tleg.t-entail { color: var(--entail); }
618
- .bundlebars { display: flex; flex-direction: column; gap: .22rem; margin-top: .35rem; }
619
- .bbar { display: grid; grid-template-columns: minmax(0,1fr) 3.4rem 1.6rem; align-items: center; gap: .4rem; font-family: ${MONO_STACK}; font-size: .68rem; }
661
+ .bundlebars { display: flex; flex-direction: column; gap: .26rem; margin-top: .4rem; }
662
+ .bbar { display: grid; grid-template-columns: minmax(0,1fr) 3.4rem 1.6rem; align-items: center; gap: .4rem; font-family: ${MONO_STACK}; font-size: .7rem; }
620
663
  .bblabel { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
621
664
  .bbtrack { background: var(--line); border-radius: 99px; height: 5px; overflow: hidden; }
622
- .bbfill { display: block; height: 100%; background: var(--ink); opacity: .55; border-radius: 99px; }
665
+ .bbfill { display: block; height: 100%; background: var(--ink); opacity: .6; border-radius: 99px; }
623
666
  .bbn { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
624
667
  .sparkwrap { margin-top: .5rem; }
625
668
  .spark { display: block; width: 100%; height: 44px; }
626
669
  .spark .fill { fill: var(--muted); opacity: .18; stroke: none; }
627
670
  .spark .line { fill: none; stroke: var(--ink); stroke-width: 1.4; }
628
671
  .spark .dot { fill: var(--ink); }
629
- .app { display: grid; grid-template-columns: 190px minmax(0,1fr) 230px; gap: 1.3rem; grid-template-areas: "rail ledger aside"; }
672
+ .app { display: grid; grid-template-columns: 190px minmax(0,1fr) 240px; gap: 1.1rem; grid-template-areas: "rail ledger aside"; align-items: start; }
630
673
  .rail { grid-area: rail; } .ledger { grid-area: ledger; } .aside { grid-area: aside; }
631
674
  @media (max-width: 880px) { .app { grid-template-columns: 1fr; grid-template-areas: "ledger" "aside" "rail"; } .search { margin-left: 0; } }
632
675
  .rail h2, .aside h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 1rem 0 .4rem; }
@@ -641,7 +684,7 @@ ${THEME_TOKENS_CSS}
641
684
  .seg.on.neutral { background: var(--ink); color: var(--bg); } .seg.on.neutral .n { color: var(--bg); }
642
685
  .seg.on.c-taught { background: var(--taught); } .seg.on.c-corpus { background: var(--corpus); } .seg.on.c-entail { background: var(--entail); }
643
686
  .d-taught { background: var(--taught); } .d-corpus { background: var(--corpus); } .d-entail { background: var(--entail); }
644
- .focuscard { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: .8rem 1rem; margin-bottom: 1rem; }
687
+ .focuscard { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .85rem 1rem; margin-bottom: 1rem; }
645
688
  .focuscard .term { font-size: 1.35rem; font-weight: 700; }
646
689
  .focuscard .klass, .focuscard .stats { font-family: ${MONO_STACK}; font-size: .72rem; color: var(--muted); margin-top: .3rem; font-variant-numeric: tabular-nums; }
647
690
  .focuscard .focusdigest { margin-top: .7rem; font-size: .92rem; line-height: 1.5; }
@@ -656,8 +699,13 @@ ${THEME_TOKENS_CSS}
656
699
  .group { margin: 1.1rem 0; }
657
700
  .group h3 { font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .35rem; display: flex; align-items: baseline; gap: .5rem; }
658
701
  .group h3::after { content: ""; flex: 1; border-top: 1px solid var(--line); transform: translateY(-.2em); }
659
- .rows { display: flex; flex-direction: column; gap: .35rem; }
660
- .row { background: var(--card); border: 1px solid var(--line); border-left: 3px solid var(--line); border-radius: 0 8px 8px 0; padding: .45rem .75rem; display: flex; flex-wrap: wrap; align-items: baseline; gap: .3rem .5rem; font-size: .95rem; }
702
+ /* The fact stream reads as a dense query-result log: full-width rows, a
703
+ hairline between each, a provenance-colored left rail, no per-row card
704
+ chrome. */
705
+ .rows { display: flex; flex-direction: column; }
706
+ .row { background: transparent; border: none; border-left: 3px solid var(--line); border-bottom: 1px solid var(--line); border-radius: 0; padding: .42rem .7rem; display: flex; flex-wrap: wrap; align-items: baseline; gap: .3rem .5rem; font-family: ${MONO_STACK}; font-size: .82rem; }
707
+ .rows .row:last-child { border-bottom: none; }
708
+ .row:hover { background: var(--line); }
661
709
  .row.p-taught.t3 { border-left-color: var(--taught-t3); } .row.p-taught.t2 { border-left-color: var(--taught-t2); } .row.p-taught.t1 { border-left-color: var(--taught-t1); }
662
710
  .row.p-corpus.t3 { border-left-color: var(--corpus-t3); } .row.p-corpus.t2 { border-left-color: var(--corpus-t2); } .row.p-corpus.t1 { border-left-color: var(--corpus-t1); }
663
711
  .row.p-entail.t3 { border-left-color: var(--entail-t3); } .row.p-entail.t2 { border-left-color: var(--entail-t2); } .row.p-entail.t1 { border-left-color: var(--entail-t1); }
@@ -668,18 +716,18 @@ ${THEME_TOKENS_CSS}
668
716
  .prov.p-taught { color: var(--taught); background: var(--taught-soft); }
669
717
  .prov.p-corpus { color: var(--corpus); background: var(--corpus-soft); }
670
718
  .prov.p-entail { color: var(--entail); background: var(--entail-soft); }
671
- .contra { border: 1px solid var(--alert); border-radius: 10px; padding: .5rem; margin-top: .35rem; }
719
+ .contra { border: 1px solid var(--alert); border-radius: 6px; padding: .5rem; margin-top: .35rem; }
672
720
  .contra .label { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--alert); margin: 0 0 .35rem .2rem; }
673
- .empty { color: var(--muted); font-size: .9rem; border: 1px dashed var(--line); border-radius: 8px; padding: .8rem 1rem; }
721
+ .empty { color: var(--muted); font-size: .88rem; border: 1px dashed var(--line); border-radius: 6px; padding: .8rem 1rem; }
674
722
  .looks { display: flex; flex-direction: column; gap: .45rem; }
675
- .look { display: block; text-align: left; background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .5rem .65rem; font-size: .8rem; width: 100%; }
723
+ .look { display: block; text-align: left; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .65rem; font-size: .8rem; width: 100%; }
676
724
  .look:hover { border-color: var(--ink); }
677
725
  .look .tag { display: block; font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .06em; text-transform: uppercase; margin-bottom: .18rem; }
678
726
  .look.k-taught .tag { color: var(--taught); } .look.k-alert .tag { color: var(--alert); } .look.k-hub .tag { color: var(--corpus); }
679
- .mapwrap { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .45rem; margin-top: .45rem; }
727
+ .mapwrap { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .45rem; margin-top: .45rem; }
680
728
  .mapwrap canvas { display: block; width: 100%; height: 160px; cursor: pointer; }
681
729
  .mapnote { font-family: ${MONO_STACK}; font-size: .62rem; color: var(--muted); margin: .3rem .2rem 0; }
682
- .chat { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: .65rem .85rem; margin-bottom: 1rem; }
730
+ .chat { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .65rem .85rem; margin-bottom: 1rem; }
683
731
  .chatlog { display: flex; flex-direction: column; gap: .45rem; max-height: 220px; overflow-y: auto; }
684
732
  .chatlog:empty { display: none; }
685
733
  .chatlog .u { font-family: ${MONO_STACK}; font-size: .76rem; color: var(--muted); }
@@ -712,6 +760,7 @@ ${THEME_TOKENS_CSS}
712
760
  .ingeststatus { font-size: .68rem; color: var(--muted); }
713
761
  @media (prefers-reduced-motion: no-preference) {
714
762
  .seg, .chip, .look { transition: border-color .12s ease, background-color .12s ease; }
763
+ .row { transition: background-color .1s ease; }
715
764
  /* A live teach re-render swaps the dash section wholesale (dashboardHtml
716
765
  is called again in full — see the inline script below), so the "this
717
766
  just changed" signal has to be an animation on the fresh markup itself,
@@ -934,7 +983,7 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
934
983
  for (const [, grp] of groupsHere) {
935
984
  if (grp.length < 2) continue;
936
985
  grp.forEach((r) => bracketed.add(r.id));
937
- inner += '<div class="contra"><p class="label">more than one answer on record &mdash; shown, never merged</p><div class="rows">' + grp.map(rowHtml).join("") + "</div></div>";
986
+ inner += '<div class="contra"><p class="label">more than one answer on record, shown, not merged</p><div class="rows">' + grp.map(rowHtml).join("") + "</div></div>";
938
987
  }
939
988
  html += '<div class="group"><h3>' + esc(FAM_LABEL[fam]) + '</h3><div class="rows">' + inner + "</div></div>";
940
989
  }
@@ -952,7 +1001,7 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
952
1001
  looks.push({ cls: "k-taught", tag: "newest teach", target: w.newestTaught.term, body: r ? esc(r.s + " " + r.phrase + " " + r.o) : esc(w.newestTaught.term) });
953
1002
  }
954
1003
  if (w.contradictions && w.contradictions.count) {
955
- looks.push({ cls: "k-alert", tag: w.contradictions.count + " with more than one answer", target: w.contradictions.firstFocusTerm, body: '<span class="mono">' + esc(w.contradictions.firstFocusTerm) + "</span> has answers that differ &mdash; read both" });
1004
+ looks.push({ cls: "k-alert", tag: w.contradictions.count + " with more than one answer", target: w.contradictions.firstFocusTerm, body: '<span class="mono">' + esc(w.contradictions.firstFocusTerm) + "</span> has answers that differ. Read both." });
956
1005
  }
957
1006
  if (w.biggestHub) {
958
1007
  looks.push({ cls: "k-hub", tag: "biggest hub", target: w.biggestHub.term, body: '<span class="mono">' + esc(w.biggestHub.term) + "</span> touches " + w.biggestHub.degree + " facts" });
@@ -49,6 +49,30 @@ export function statsSummaryLine(stats, bandLabel) {
49
49
  : stats.total + " starter facts loaded";
50
50
  }
51
51
 
52
+ /** Drop every asset the site's service worker has cached, and unregister the
53
+ * worker itself, so the next load reads the shipped files off the network.
54
+ * A page's own taught-facts store lives in IndexedDB, a different store
55
+ * entirely — clearing that alone would still re-seed from whatever copy of
56
+ * chat-seed.json the worker cached, which is the whole reason this exists.
57
+ * Best-effort throughout: a browser with no Cache Storage, no worker, or a
58
+ * storage call that throws just leaves the caller to reload as before.
59
+ * Pure of this module's scope, so it stays `.toString()`-splice safe. */
60
+ export async function clearSiteAssetCaches() {
61
+ try {
62
+ if (globalThis.caches) {
63
+ for (const key of await caches.keys()) {
64
+ if (key.startsWith("tmct-precache-")) await caches.delete(key);
65
+ }
66
+ }
67
+ } catch { /* the reload still re-reads whatever the worker will serve */ }
68
+ try {
69
+ if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
70
+ const registrations = await navigator.serviceWorker.getRegistrations();
71
+ await Promise.all(registrations.map((registration) => registration.unregister()));
72
+ }
73
+ } catch { /* an un-unregisterable worker still has an empty cache now */ }
74
+ }
75
+
52
76
  /** Fetch `url` reading the body as a stream, reporting (loadedBytes,
53
77
  * totalBytes) after every chunk — total is 0 when the response carries no
54
78
  * Content-Length. Resolves to a Blob of the whole body. Falls back to a