@polycode-projects/the-mechanical-code-talker 3.3.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 (84) 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 +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  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-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -32,7 +32,7 @@ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-the
32
32
  import { provBucketFor } from "./ledger-viz.mjs";
33
33
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
34
34
  import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
35
- import { bandLabelFor, statsSummaryLine, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
35
+ import { bandLabelFor, statsSummaryLine, clearSiteAssetCaches, fetchWithProgress, renderStatsPanelInto } from "./memory-panel-viz.mjs";
36
36
 
37
37
  const DEFAULT_TITLE = "the-mechanical-code-talker — talk to it";
38
38
 
@@ -92,6 +92,203 @@ export function provenanceChipFor(answer, record, bucketFor) {
92
92
  return "corpus";
93
93
  }
94
94
 
95
+ /**
96
+ * One connection state as something a person can read: the headline word, the
97
+ * sentence under it, and the tone the page styles it by.
98
+ *
99
+ * The tones matter as much as the words. `sharing` and `answering` are
100
+ * open-ended BY DESIGN — until a blob is pasted nothing is in flight, so there
101
+ * is no network activity to time out on, and they get the calm "waiting" tone
102
+ * rather than error styling. `failed` is the opposite case: both blobs were
103
+ * exchanged and ICE gave up, which is a real fault and reads as one.
104
+ *
105
+ * Self-contained (no outer refs), `.toString()`-splice safe.
106
+ */
107
+ export function wireStateLabel(state) {
108
+ switch (state) {
109
+ case "sharing":
110
+ return {
111
+ tone: "waiting",
112
+ pill: "waiting",
113
+ word: "waiting for their reply",
114
+ note: "nothing is in flight yet. this stays live as long as you leave the tab open.",
115
+ };
116
+ case "answering":
117
+ return {
118
+ tone: "waiting",
119
+ pill: "reply sent",
120
+ word: "send your reply back",
121
+ note: "they connect the moment they paste it. leave this tab open.",
122
+ };
123
+ case "connecting":
124
+ return {
125
+ tone: "working",
126
+ pill: "connecting",
127
+ word: "connecting",
128
+ note: "both halves are exchanged. this settles either way in a few seconds.",
129
+ };
130
+ case "connected":
131
+ return {
132
+ tone: "live",
133
+ pill: "connected",
134
+ word: "connected",
135
+ note: "what you teach from here reaches every node below, and theirs reaches you.",
136
+ };
137
+ case "failed":
138
+ return {
139
+ tone: "failed",
140
+ pill: "can't connect",
141
+ word: "couldn't connect",
142
+ note: "your two machines can't reach each other directly. this works on the same network, or between machines that can already see each other.",
143
+ };
144
+ default:
145
+ return {
146
+ tone: "idle",
147
+ pill: "not shared",
148
+ word: "not shared",
149
+ note: "this browser holds the only copy of what you teach it.",
150
+ };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * One wire message as a row for the traffic tape: its type, which colour
156
+ * family it belongs to, and the one number or name worth showing beside it.
157
+ *
158
+ * The families reuse the page's own provenance colours rather than inventing a
159
+ * fourth palette — facts crossing the wire wear the same green as the "taught"
160
+ * chip they will end up carrying, bulk state wears the corpus blue, and the
161
+ * introductions that get two peers talking wear the entailed amber.
162
+ *
163
+ * Self-contained (no outer refs), `.toString()`-splice safe.
164
+ */
165
+ export function tapeRowFor(direction, message) {
166
+ const type = message && typeof message.type === "string" ? message.type : "unknown";
167
+ const FAMILIES = {
168
+ op: "facts",
169
+ "sync-response": "facts",
170
+ "sync-request": "state",
171
+ hello: "greeting",
172
+ "peer-list": "greeting",
173
+ "intro-offer": "signal",
174
+ "intro-answer": "signal",
175
+ };
176
+ const count = (list, one, many) => {
177
+ const n = Array.isArray(list) ? list.length : 0;
178
+ return n + " " + (n === 1 ? one : many);
179
+ };
180
+ let detail = "";
181
+ if (type === "op" || type === "sync-response") detail = count(message.facts, "fact", "facts");
182
+ else if (type === "peer-list") detail = count(message.peers, "node", "nodes");
183
+ else if (type === "hello") detail = String(message.displayName || "");
184
+ else if (type === "intro-offer" || type === "intro-answer") detail = String(message.to || "").slice(0, 8);
185
+ return { type: type, direction: direction, detail: detail, family: FAMILIES[type] || "link" };
186
+ }
187
+
188
+ /**
189
+ * The node list: every peer this graph knows about, each with the node name it
190
+ * chose and the timestamp of the most recent fact it contributed, most
191
+ * recently active first.
192
+ *
193
+ * Activity is read off the provenance the wire already carries. A fact a peer
194
+ * broadcast arrives tagged `teach:peer:<their node name>@<ts>`, so the tag
195
+ * names both who contributed it and when — no separate activity ledger to
196
+ * keep. This node's own row reads its local `teach:`/`ace:` tags instead,
197
+ * because a fact never leaves here relabelled in its own store.
198
+ *
199
+ * `nameFor` and `latestTimestampOf` are injected (the room's own
200
+ * `displayNameFor` and the P2P layer's `latestProvenanceTimestamp`) rather
201
+ * than imported, so this stays `.toString()`-splice safe — the same discipline
202
+ * provenanceChipFor's injected `bucketFor` holds.
203
+ */
204
+ export function nodeRowsFor({ peers, factRows, myPeerId, myDisplayName, nameFor, latestTimestampOf }) {
205
+ const activeByName = new Map();
206
+ let mineLastActive = null;
207
+ for (const row of factRows || []) {
208
+ for (const segment of String(row.provenance || "").split(" | ")) {
209
+ if (!segment) continue;
210
+ const at = latestTimestampOf(segment);
211
+ if (at === null) continue;
212
+ if (segment.indexOf("teach:peer:") === 0) {
213
+ const marker = segment.lastIndexOf("@");
214
+ if (marker < 0) continue;
215
+ const name = segment.slice("teach:peer:".length, marker);
216
+ const prior = activeByName.get(name);
217
+ if (prior === undefined || at > prior) activeByName.set(name, at);
218
+ } else if (segment.indexOf("teach:") === 0 || segment.indexOf("ace:") === 0) {
219
+ if (mineLastActive === null || at > mineLastActive) mineLastActive = at;
220
+ }
221
+ }
222
+ }
223
+ const rows = [{
224
+ peerId: myPeerId,
225
+ name: myDisplayName,
226
+ connected: true,
227
+ isSelf: true,
228
+ lastActiveAt: mineLastActive,
229
+ }];
230
+ for (const peer of peers || []) {
231
+ const name = nameFor(peer.peerId);
232
+ rows.push({
233
+ peerId: peer.peerId,
234
+ name: name,
235
+ connected: Boolean(peer.connected),
236
+ isSelf: false,
237
+ lastActiveAt: activeByName.has(name) ? activeByName.get(name) : null,
238
+ });
239
+ }
240
+ rows.sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0));
241
+ return rows;
242
+ }
243
+
244
+ /**
245
+ * A node's monogram: the first letter of each of the two words its name is
246
+ * made of ("mossy-acorn" -> "ma"), falling back to the first two characters
247
+ * of anything that isn't shaped that way. Never empty, so a row never draws
248
+ * a blank circle.
249
+ *
250
+ * Self-contained (no outer refs), `.toString()`-splice safe.
251
+ */
252
+ export function nodeInitials(name) {
253
+ const words = String(name || "").split(/[^a-z0-9]+/i).filter(Boolean);
254
+ if (words.length >= 2) return (words[0][0] + words[1][0]).toLowerCase();
255
+ if (words.length === 1) return words[0].slice(0, 2).toLowerCase();
256
+ return "??";
257
+ }
258
+
259
+ /**
260
+ * The invite link: this page's own address carrying the offer blob, the world
261
+ * id and the world's name. Any query or fragment the current address already
262
+ * had is dropped, so inviting from a page that was itself opened from an
263
+ * invite mints a clean link rather than stacking two offers.
264
+ *
265
+ * Self-contained (no outer refs), `.toString()`-splice safe.
266
+ */
267
+ export function inviteLinkFor(pageUrl, { blob, world, worldName }) {
268
+ const url = new URL(String(pageUrl));
269
+ url.search = "";
270
+ url.hash = "";
271
+ url.searchParams.set("offer", blob);
272
+ url.searchParams.set("world", world);
273
+ if (worldName) url.searchParams.set("name", worldName);
274
+ return url.toString();
275
+ }
276
+
277
+ /**
278
+ * The invite an address carries, or null when it carries none. The world id
279
+ * and name are read here only to show the joiner what they were invited to
280
+ * before anything runs — the offer blob's own envelope is what actually
281
+ * decides, and it wins wherever the two disagree.
282
+ *
283
+ * Self-contained (no outer refs), `.toString()`-splice safe.
284
+ */
285
+ export function inviteParamsFrom(search) {
286
+ const params = new URLSearchParams(String(search || ""));
287
+ const offer = params.get("offer");
288
+ if (!offer) return null;
289
+ return { offer: offer, world: params.get("world") || "", worldName: params.get("name") || "" };
290
+ }
291
+
95
292
  /**
96
293
  * The boot statusline while the big assets stream in — "loading the engine…
97
294
  * X MB / Y MB", aggregated across every asset currently downloading. `parts`
@@ -182,7 +379,7 @@ export function transcriptMarkdown(turns, meta, headerMd, turnMd) {
182
379
  * live digest-bank twin (see chat-browser-entry.mjs) rather than to a
183
380
  * client-side digest panel of this page's own; an empty list degrades to the
184
381
  * flat list exactly as before this page could digest at all. */
185
- export function renderChatHtml({ title = DEFAULT_TITLE, digestStructures = [] } = {}) {
382
+ export function renderChatHtml({ title = DEFAULT_TITLE, digestStructures = [], seedStamp = "" } = {}) {
186
383
  const digestStructuresJson = JSON.stringify(Array.isArray(digestStructures) ? digestStructures : []);
187
384
  const legendHtml = PROV_LEGEND.map(
188
385
  ([key, label]) => `<span class="legend-item"><i class="dot dot-${provKey(key)}"></i>${escapeHtml(label)}</span>`,
@@ -217,16 +414,25 @@ ${THEME_TOKENS_CSS}
217
414
  stacked), so this page keeps working exactly as before, just inside one
218
415
  more layer. */
219
416
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; overflow: hidden; }
220
- .chatCol { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
417
+ /* position: relative so the wave burst can anchor to the conversation
418
+ column; main.chatMain scrolls, and a burst anchored inside it would
419
+ scroll away mid-wave. */
420
+ .chatCol { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; position: relative; }
221
421
  .mono { font-family: ${MONO_STACK}; }
422
+ /* every display rule below would otherwise beat the hidden attribute, and a
423
+ hidden-but-displayed overlay still swallows clicks meant for the page. */
424
+ [hidden] { display: none !important; }
222
425
  button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
223
426
  button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
224
427
  a { color: var(--corpus); }
225
428
 
226
- .topbar { flex: 0 0 auto; display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: .7rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
429
+ /* brand, then the controls, then the legend pushed to the far right the
430
+ legend is passive and takes whatever room is left, so the controls never
431
+ get folded onto a second line by a wide one. */
432
+ .topbar { flex: 0 0 auto; display: flex; align-items: center; gap: 1rem; padding: .55rem 1.1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
227
433
  .brand { display: flex; align-items: baseline; gap: .55rem; }
228
434
  .eyebrow { font-family: ${MONO_STACK}; font-size: .78rem; letter-spacing: .08em; color: var(--muted); }
229
- .legend { display: flex; gap: .8rem; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
435
+ .legend { display: flex; gap: .8rem; margin-left: auto; font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); }
230
436
  .legend-item { display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; }
231
437
  .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
232
438
  .dot-taught { background: var(--taught); } .dot-corpus { background: var(--corpus); } .dot-entail { background: var(--entail); }
@@ -335,15 +541,176 @@ ${THEME_TOKENS_CSS}
335
541
  .statsPanel .researched-facts li { margin: .12rem 0; }
336
542
  .statsPanel .researched-none { color: var(--muted); font-style: italic; margin: .3rem 0 0; }
337
543
 
544
+ /* ---- the page chrome's network controls -------------------------------
545
+ The connection's state belongs in the chrome, not only in a rail that a
546
+ narrow window hides: the pill, the wave, the invite and the help link stay
547
+ reachable at every width. */
548
+ /* The composer is already this page's most familiar-chat element — a pill
549
+ input and a round send button. The chrome's controls take the same shape,
550
+ so the page's networking reads as ordinary chat furniture rather than as
551
+ an instrument bolted on. */
552
+ /* the live fact count, in the topbar rather than the statusline: it is the
553
+ one number that says what this session actually knows, and a small line of
554
+ grey mono under the composer is where a wrong one goes unnoticed. Reads as
555
+ a pill like its neighbours, with the number itself at reading size. */
556
+ .fact-pill { display: inline-flex; align-items: baseline; gap: .34rem; font-family: ${MONO_STACK}; font-size: .68rem; 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; }
557
+ .fact-pill .fact-pill-value { font-size: .96rem; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 600; color: var(--ink); }
558
+
559
+ .chrome { display: flex; align-items: center; gap: .4rem; }
560
+ .chrome-btn { font-family: ${SERIF_STACK}; font-size: .8rem; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .22rem .75rem; background: var(--card); text-decoration: none; display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; line-height: 1.35; }
561
+ .chrome-btn:hover { color: var(--ink); border-color: var(--ink); }
562
+ .chrome-btn.share { color: var(--ink); }
563
+ .chrome-btn.help, .chrome-btn.icon { width: 1.7rem; height: 1.7rem; justify-content: center; padding: 0; }
564
+ .chrome-btn .hand { font-size: .9rem; line-height: 1; }
565
+
566
+ .state-pill { display: inline-flex; align-items: center; gap: .4rem; font-family: ${SERIF_STACK}; font-size: .8rem; line-height: 1.35; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .22rem .78rem; background: var(--card); white-space: nowrap; }
567
+ .state-pill .pill-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); flex: 0 0 auto; }
568
+ .state-pill[data-tone="waiting"] .pill-dot, .state-pill[data-tone="working"] .pill-dot { background: var(--corpus); animation: wire-breathe 2.4s ease-in-out infinite; }
569
+ .state-pill[data-tone="working"] .pill-dot { animation-duration: .9s; }
570
+ .state-pill[data-tone="live"] { color: var(--taught); border-color: var(--taught-t1); }
571
+ .state-pill[data-tone="live"] .pill-dot { background: var(--taught); }
572
+ .state-pill[data-tone="failed"] { color: var(--alert); border-color: var(--alert); }
573
+ .state-pill[data-tone="failed"] .pill-dot { background: var(--alert); }
574
+
575
+ /* ---- the network rail --------------------------------------------------
576
+ The docks encode the two halves of a shared graph: the room on the left
577
+ (who else holds this graph, and what is crossing the wire between you),
578
+ the mind on the right (what it knows), the conversation between them.
579
+ Mono throughout — the conversation is prose, the network is telemetry,
580
+ and the register shift is the point. */
581
+ .netPanel { flex: 0 0 288px; max-width: 288px; overflow-y: auto; border-right: 1px solid var(--line); padding: 1rem 1rem 1.6rem; font-family: ${MONO_STACK}; font-size: .72rem; line-height: 1.5; display: flex; flex-direction: column; gap: 1.15rem; }
582
+ .net-block { display: flex; flex-direction: column; gap: .45rem; }
583
+ .netPanel h2 { font-size: .6rem; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin: 0; display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
584
+ .netPanel h2 .h2-count { letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; }
585
+ .net-field { display: flex; flex-direction: column; gap: .2rem; }
586
+ .net-label { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--muted); }
587
+ .net-name-input { font-family: ${SERIF_STACK}; font-size: .86rem; color: var(--ink); background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .32rem .6rem; width: 100%; box-sizing: border-box; }
588
+ .net-name-input:read-only { background: none; border-color: transparent; padding-left: 0; color: var(--muted); }
589
+ .net-note { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--muted); margin: 0; }
590
+ .net-help { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--corpus); }
591
+ .net-btn { font-family: ${SERIF_STACK}; font-size: .82rem; line-height: 1.35; color: var(--ink); border: 1px solid var(--line); border-radius: 99px; padding: .32rem .85rem; background: var(--card); align-self: flex-start; }
592
+ .net-btn:hover { border-color: var(--ink); }
593
+ .net-btn:disabled { opacity: .5; cursor: default; }
594
+ .net-btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
595
+ .net-btn.ghost { border-color: transparent; color: var(--muted); padding-left: 0; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; }
596
+ .net-btn.ghost:hover { color: var(--ink); text-decoration-color: var(--ink); }
597
+ /* the blobs stay mono: they are machine text a person only ever copies, and
598
+ a serif face on base64 is a lie about what it is. */
599
+ .net-blob { width: 100%; box-sizing: border-box; font-family: ${MONO_STACK}; font-size: .58rem; line-height: 1.35; color: var(--muted); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: .4rem .5rem; resize: vertical; word-break: break-all; }
600
+ .net-blob:focus { color: var(--ink); }
601
+ .net-problem { margin: 0; font-family: ${SERIF_STACK}; font-size: .78rem; color: var(--alert); border-left: 2px solid var(--alert); padding-left: .5rem; }
602
+ .net-invite { display: flex; flex-direction: column; gap: .4rem; padding-top: .2rem; }
603
+
604
+ /* connection state, as a real visual state at every point: a calm slow
605
+ breath while nothing is in flight, a quicker one while ICE runs, a solid
606
+ green edge when the channel is open, and a static dashed red when it
607
+ failed — a fault should not twitch. */
608
+ .wire-state { position: relative; overflow: hidden; border: 1px solid var(--line); border-left-width: 3px; border-radius: 8px; padding: .55rem .7rem .6rem .75rem; background: var(--card); font-family: ${SERIF_STACK}; }
609
+ .wire-state-word { display: block; font-size: .95rem; color: var(--ink); }
610
+ .wire-state-note { display: block; margin-top: .25rem; font-size: .74rem; line-height: 1.4; color: var(--muted); }
611
+ .wire-state[data-tone="waiting"], .wire-state[data-tone="working"] { border-left-color: var(--corpus); }
612
+ .wire-state[data-tone="waiting"]::before, .wire-state[data-tone="working"]::before { content: ""; position: absolute; left: -3px; top: 0; bottom: 0; width: 3px; background: var(--corpus); animation: wire-breathe 2.4s ease-in-out infinite; }
613
+ .wire-state[data-tone="working"]::before { animation-duration: .9s; }
614
+ .wire-state[data-tone="live"] { border-left-color: var(--taught); background: var(--taught-soft); }
615
+ .wire-state[data-tone="live"] .wire-state-word { color: var(--taught); }
616
+ .wire-state[data-tone="failed"] { border-style: dashed; border-left-style: solid; border-left-color: var(--alert); background: var(--alert-soft); }
617
+ .wire-state[data-tone="failed"] .wire-state-word { color: var(--alert); }
618
+ @keyframes wire-breathe { 0%, 100% { opacity: .2; } 50% { opacity: 1; } }
619
+
620
+ /* a member list, the way every chat app already draws one: a monogram, a
621
+ presence badge on it, the name, and when they were last heard from. The
622
+ monogram is neutral on purpose — on this page colour means provenance,
623
+ and an avatar palette would spend that meaning on decoration. */
624
+ .node-list { list-style: none; margin: 0; padding: 0; }
625
+ .node-row { display: flex; align-items: center; gap: .55rem; padding: .3rem 0; }
626
+ .node-avatar { position: relative; flex: 0 0 auto; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--line); color: var(--muted); display: flex; align-items: center; justify-content: center; font-family: ${MONO_STACK}; font-size: .58rem; letter-spacing: .04em; text-transform: uppercase; }
627
+ .node-row[data-self="true"] .node-avatar { background: var(--ink); color: var(--bg); }
628
+ .node-dot { position: absolute; right: -1px; bottom: -1px; width: 7px; height: 7px; border-radius: 50%; background: var(--taught); box-shadow: 0 0 0 2px var(--bg); }
629
+ .node-row[data-away="true"] .node-dot { background: var(--muted); }
630
+ .node-name { font-family: ${SERIF_STACK}; color: var(--ink); font-size: .86rem; flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
631
+ .node-row[data-self="true"] .node-name::after { content: " (you)"; color: var(--muted); font-size: .74rem; }
632
+ .node-row[data-away="true"] .node-name { color: var(--muted); }
633
+ .node-when { color: var(--muted); font-family: ${MONO_STACK}; font-size: .62rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
634
+ .node-hand { display: inline-block; font-size: .88rem; opacity: 0; transform-origin: 70% 80%; }
635
+ .node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
636
+ .node-empty { font-family: ${SERIF_STACK}; color: var(--muted); font-size: .76rem; line-height: 1.4; margin: 0; }
637
+ @keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
638
+
639
+ /* the wire tape — this page's own instrument. Every message in and out, in
640
+ arrival order, newest first so the latest is readable without scrolling.
641
+ The 3px bar carries the message family in the page's own provenance
642
+ colours; the type is spelled out beside it, so colour is never the only
643
+ thing distinguishing one row from another. */
644
+ .net-tape-block { flex: 1 1 auto; min-height: 0; }
645
+ .tape-meter { display: flex; flex-wrap: wrap; gap: .15rem .7rem; margin: 0 0 .3rem; }
646
+ .meter-item { display: inline-flex; align-items: center; gap: .3rem; font-size: .6rem; color: var(--muted); }
647
+ .meter-bar { width: 5px; height: 5px; border-radius: 1px; flex: 0 0 auto; }
648
+ .meter-n { color: var(--ink); font-variant-numeric: tabular-nums; }
649
+ .tape { list-style: none; margin: 0; padding: 0; max-height: 20rem; overflow-y: auto; border-top: 1px solid var(--line); }
650
+ .tape-row { display: grid; grid-template-columns: 3px auto minmax(0, 1fr) auto; align-items: center; gap: .4rem; padding: .18rem 0 .18rem .2rem; border-bottom: 1px dotted var(--line); font-size: .6rem; font-variant-numeric: tabular-nums; }
651
+ .tape-row:first-child { animation: tape-arrive .6s ease-out; }
652
+ .tape-bar { align-self: stretch; border-radius: 1px; background: var(--muted); }
653
+ .tape-clock { color: var(--muted); }
654
+ .tape-type { color: var(--ink); overflow-wrap: anywhere; }
655
+ .tape-detail { color: var(--muted); text-align: right; white-space: nowrap; }
656
+ .tape-row[data-dir="out"] .tape-type::before { content: "\\2192 "; color: var(--muted); }
657
+ .tape-row[data-dir="in"] .tape-type::before { content: "\\2190 "; color: var(--muted); }
658
+ .tape-row[data-dir="note"] .tape-type::before { content: "\\00b7 "; color: var(--muted); }
659
+ .tape-row[data-family="facts"] .tape-bar { background: var(--taught); }
660
+ .tape-row[data-family="state"] .tape-bar { background: var(--corpus); }
661
+ .tape-row[data-family="greeting"] .tape-bar { background: var(--entail); }
662
+ .tape-row[data-family="signal"] .tape-bar { background: var(--entail-t1); }
663
+ .tape-row[data-family="fault"] .tape-bar { background: var(--alert); }
664
+ .tape-row[data-family="fault"] .tape-type { color: var(--alert); }
665
+ .tape-empty { font-family: ${SERIF_STACK}; color: var(--muted); font-size: .76rem; line-height: 1.4; padding: .45rem 0 0; margin: 0; }
666
+ @keyframes tape-arrive { from { background: var(--corpus-soft); } to { background: transparent; } }
667
+
668
+ .netPanel-close { display: none; align-self: flex-end; font-family: ${MONO_STACK}; font-size: .8rem; color: var(--muted); padding: 0 .2rem; }
669
+
670
+ /* the join card: the only thing a joiner sees until they act. The world's
671
+ generated two-word name is the one place it gets to be a headline. */
672
+ .joinCard { position: fixed; inset: 0; z-index: 40; background: rgba(0, 0, 0, .45); display: flex; align-items: center; justify-content: center; padding: 1.2rem; }
673
+ .joinCard-inner { background: var(--card); border: 1px solid var(--line); border-radius: 8px; width: 100%; max-width: 27rem; padding: 1.5rem 1.6rem 1.3rem; box-shadow: 0 18px 48px rgba(0, 0, 0, .3); display: flex; flex-direction: column; gap: .8rem; }
674
+ .joinCard-eyebrow { font-size: .82rem; color: var(--muted); margin: 0; }
675
+ .joinCard-world { font-size: 1.75rem; line-height: 1.1; margin: -.45rem 0 0; color: var(--ink); font-weight: 600; overflow-wrap: anywhere; }
676
+ .joinCard-body { font-size: .9rem; color: var(--muted); margin: 0; max-width: 36ch; }
677
+ .joinCard .net-btn.primary { font-size: .95rem; padding: .5rem 1.15rem; }
678
+ .joinCard-reply { display: flex; flex-direction: column; gap: .45rem; }
679
+
680
+ /* a wave, on every page it reaches: the waver's node name, over the
681
+ conversation, for as long as the wave is recent. Anchored under the
682
+ topbar, where a chat app puts a presence toast — the foot of the column
683
+ belongs to the composer, and a burst there lands behind it. */
684
+ .waveBurst { position: absolute; left: 50%; top: 3.4rem; transform: translateX(-50%); z-index: 20; display: flex; flex-direction: column; align-items: center; gap: .3rem; pointer-events: none; }
685
+ .wave-pill { display: flex; align-items: center; gap: .45rem; background: var(--card); border: 1px solid var(--line); border-radius: 99px; padding: .3rem .9rem .3rem .7rem; font-family: ${SERIF_STACK}; font-size: .85rem; color: var(--ink); box-shadow: 0 4px 14px rgba(0, 0, 0, .16); animation: wave-rise .3s ease-out; }
686
+ .wave-pill .hand { display: inline-block; font-size: 1rem; transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; }
687
+ @keyframes wave-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
688
+
689
+ .copyTip { position: fixed; z-index: 60; background: var(--ink); color: var(--bg); font-family: ${SERIF_STACK}; font-size: .78rem; padding: .3rem .7rem; border-radius: 99px; pointer-events: none; box-shadow: 0 3px 10px rgba(0, 0, 0, .22); animation: wave-rise .14s ease-out; }
690
+
691
+ @media (max-width: 1080px) {
692
+ /* the rail becomes a drawer rather than disappearing: the invite flow has
693
+ to stay reachable on a laptop and a phone alike. */
694
+ .netPanel { position: fixed; left: 0; top: 0; bottom: 0; width: 288px; max-width: 86vw; flex: none; z-index: 30; background: var(--bg); transform: translateX(-101%); transition: transform .18s ease-out; }
695
+ body.net-open .netPanel { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, .3); }
696
+ .netPanel-close { display: block; }
697
+ }
698
+ @media (max-width: 1360px) {
699
+ /* the legend is decorative and the controls are not, so the legend goes
700
+ first rather than folding the controls onto a second row. */
701
+ .legend { display: none; }
702
+ }
338
703
  @media (max-width: 860px) {
339
704
  .statsPanel { display: none; }
340
705
  }
341
706
  @media (max-width: 560px) {
342
- .legend { display: none; }
343
707
  .bubble { max-width: 92%; }
344
708
  }
345
709
  @media (prefers-reduced-motion: reduce) {
346
710
  * { scroll-behavior: auto !important; }
711
+ .wire-state[data-tone="waiting"]::before, .wire-state[data-tone="working"]::before,
712
+ .state-pill .pill-dot, .tape-row:first-child, .netPanel { animation: none; transition: none; }
713
+ .wave-pill, .wave-pill .hand, .node-row[data-waving="true"] .node-hand, .copyTip { animation: none; }
347
714
  }
348
715
 
349
716
  /* print: the WHOLE transcript, not the scrolled-into-view slice — the
@@ -359,17 +726,97 @@ ${THEME_TOKENS_CSS}
359
726
  main.chatMain { overflow: visible; height: auto; }
360
727
  .messages { min-height: 0; }
361
728
  form.composer, .statusline, .statsPanel, .legend { display: none; }
729
+ .netPanel, .joinCard, .waveBurst, .chrome, .copyTip { display: none; }
362
730
  }
363
731
  </style>
364
732
  </head>
365
733
  <body>
734
+ <aside class="netPanel" id="netPanel" aria-label="the shared world: this node, its connection, the other nodes, and the wire">
735
+ <button type="button" class="netPanel-close" id="netPanelClose" aria-label="close the network panel">&times;</button>
736
+ <section class="net-block">
737
+ <h2>this node</h2>
738
+ <div class="net-field">
739
+ <label class="net-label" for="nodeNameInput">your node name</label>
740
+ <input class="net-name-input" id="nodeNameInput" type="text" autocomplete="off" spellcheck="false"
741
+ placeholder="two words from the graph's own vocabulary">
742
+ </div>
743
+ <div class="net-field">
744
+ <label class="net-label" for="worldNameInput">the graph you are sharing</label>
745
+ <input class="net-name-input" id="worldNameInput" type="text" autocomplete="off" spellcheck="false"
746
+ placeholder="named when you first invite someone">
747
+ </div>
748
+ <p class="net-note">both names are facts in the graph, not settings. change yours whenever you like.</p>
749
+ </section>
750
+
751
+ <section class="net-block">
752
+ <h2>connection</h2>
753
+ <div class="wire-state" id="wireState" data-tone="idle" role="status">
754
+ <span class="wire-state-word" id="wireStateWord">not shared</span>
755
+ <span class="wire-state-note" id="wireStateNote">this browser holds the only copy of what you teach it.</span>
756
+ </div>
757
+ <div class="net-invite" id="sharePanel" hidden>
758
+ <div class="net-field">
759
+ <label class="net-label" for="shareLink">the link, in case the copy didn&#8217;t take</label>
760
+ <textarea class="net-blob" id="shareLink" rows="2" readonly></textarea>
761
+ </div>
762
+ <p class="net-note">each link invites one person. invite again for the next.</p>
763
+ <div class="net-field">
764
+ <label class="net-label" for="replyBox">paste their reply here</label>
765
+ <textarea class="net-blob" id="replyBox" rows="3" placeholder="the reply they send back"></textarea>
766
+ </div>
767
+ <button type="button" class="net-btn" id="replyBtn">connect</button>
768
+ <p class="net-problem" id="replyProblem" role="alert" hidden></p>
769
+ </div>
770
+ <div class="net-invite" id="answerPanel" hidden>
771
+ <div class="net-field">
772
+ <label class="net-label" for="replyOut">your reply &#8212; send it back the same way the invite reached you</label>
773
+ <textarea class="net-blob" id="replyOut" rows="3" readonly></textarea>
774
+ </div>
775
+ <button type="button" class="net-btn" id="copyReplyBtn">copy it again</button>
776
+ </div>
777
+ <a class="net-help" href="./help.html#sharing" target="_blank" rel="noopener">how sharing works &#8599;</a>
778
+ </section>
779
+
780
+ <section class="net-block">
781
+ <h2>nodes <span class="h2-count" id="nodeCount"></span></h2>
782
+ <ul class="node-list" id="nodeList"></ul>
783
+ <p class="node-empty" id="nodeEmpty">nobody else yet. invite someone and their node appears here.</p>
784
+ </section>
785
+
786
+ <section class="net-block net-tape-block">
787
+ <h2>wire <span class="h2-count" id="tapeTotal"></span></h2>
788
+ <div class="tape-meter" id="tapeMeter"></div>
789
+ <ol class="tape" id="tape"></ol>
790
+ <p class="tape-empty" id="tapeEmpty">every message this browser sends or receives lands here, as it happens.</p>
791
+ </section>
792
+ </aside>
366
793
  <div class="chatCol">
367
794
  <header class="topbar">
368
795
  <div class="brand">
369
796
  <span class="eyebrow">the-mechanical-code-talker</span>
370
797
  </div>
798
+ <div class="chrome">
799
+ <span class="fact-pill" id="factPill" aria-live="polite"
800
+ title="every fact this session's memory holds right now — the starter memory it shipped with plus anything you have taught, researched or ingested">
801
+ <span class="fact-pill-value" id="factPillValue">&mdash;</span> facts
802
+ </span>
803
+ <button type="button" class="state-pill" id="statePill" data-tone="idle"
804
+ title="the shared-world connection; click to open the network panel">
805
+ <i class="pill-dot"></i><span id="statePillWord">not shared</span>
806
+ </button>
807
+ <button type="button" class="chrome-btn icon" id="waveBtn"
808
+ title="wave to everyone connected to this graph" aria-label="wave to everyone connected to this graph">
809
+ <span class="hand">&#128075;</span>
810
+ </button>
811
+ <button type="button" class="chrome-btn share" id="shareBtn" title="copy a link that invites one person into this graph">
812
+ invite
813
+ </button>
814
+ <a class="chrome-btn help" href="./help.html#chat" target="_blank" rel="noopener"
815
+ title="how this page works, in a new tab" aria-label="help, opens in a new tab">?</a>
816
+ </div>
371
817
  <div class="legend" aria-hidden="true">${legendHtml}</div>
372
818
  </header>
819
+ <div class="waveBurst" id="waveBurst" aria-live="polite"></div>
373
820
  <main class="chatMain">
374
821
  <div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
375
822
  </main>
@@ -414,6 +861,22 @@ ${THEME_TOKENS_CSS}
414
861
  <div id="statsPanelStats"><p class="empty">loading memory stats&hellip;</p></div>
415
862
  <div id="researchedPanel"></div>
416
863
  </aside>
864
+ <div class="joinCard" id="joinCard" role="dialog" aria-labelledby="joinWorld" hidden>
865
+ <div class="joinCard-inner">
866
+ <p class="joinCard-eyebrow" id="joinEyebrow">you&#8217;ve been invited to</p>
867
+ <h1 class="joinCard-world" id="joinWorld"></h1>
868
+ <p class="joinCard-body" id="joinBody">Nothing has run yet. The button below makes your reply and copies it &#8212; send it back the same way this invite reached you, and leave this tab open.</p>
869
+ <button type="button" class="net-btn primary" id="joinBtn">create my reply</button>
870
+ <p class="net-problem" id="joinProblem" role="alert" hidden></p>
871
+ <div class="joinCard-reply" id="joinReplyWrap" hidden>
872
+ <label class="net-label" for="joinReply">your reply, copied &#8212; send it back now</label>
873
+ <textarea class="net-blob" id="joinReply" rows="3" readonly></textarea>
874
+ <button type="button" class="net-btn" id="joinCopyBtn">copy it again</button>
875
+ </div>
876
+ <button type="button" class="net-btn ghost" id="joinDismiss">start talking on your own instead</button>
877
+ <a class="net-help" href="./help.html#sharing" target="_blank" rel="noopener">how sharing works &#8599;</a>
878
+ </div>
879
+ </div>
417
880
  <script src="./chat-browser.bundle.js"></script>
418
881
  <script>
419
882
  (function () {
@@ -428,10 +891,17 @@ ${THEME_TOKENS_CSS}
428
891
  const transcriptMarkdown = ${transcriptMarkdown.toString()};
429
892
  const bandLabelFor = ${bandLabelFor.toString()};
430
893
  const statsSummaryLine = ${statsSummaryLine.toString()};
894
+ const clearSiteAssetCaches = ${clearSiteAssetCaches.toString()};
431
895
  const fetchWithProgress = ${fetchWithProgress.toString()};
432
896
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
433
897
  const createTicker = ${createTicker.toString()};
434
898
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
899
+ const wireStateLabel = ${wireStateLabel.toString()};
900
+ const tapeRowFor = ${tapeRowFor.toString()};
901
+ const nodeRowsFor = ${nodeRowsFor.toString()};
902
+ const nodeInitials = ${nodeInitials.toString()};
903
+ const inviteLinkFor = ${inviteLinkFor.toString()};
904
+ const inviteParamsFrom = ${inviteParamsFrom.toString()};
435
905
  const DIGEST_STRUCTURES = ${digestStructuresJson};
436
906
  const el = (id) => document.getElementById(id);
437
907
 
@@ -442,6 +912,7 @@ ${THEME_TOKENS_CSS}
442
912
  const inputEl = el("composerInput");
443
913
  const sendBtn = el("composerSend");
444
914
  const statusEl = el("status");
915
+ const factPillValueEl = el("factPillValue");
445
916
  const statsPanelEl = el("statsPanelStats");
446
917
  const researchedPanelEl = el("researchedPanel");
447
918
  const wikiModeFieldset = el("wikiMode");
@@ -644,11 +1115,18 @@ ${THEME_TOKENS_CSS}
644
1115
  }
645
1116
  }
646
1117
 
1118
+ // The build's own content hash for the seed. It rides in the URL this page
1119
+ // fetches the seed by, so the service worker's cache-first read can only
1120
+ // ever return the copy this page asked for. Empty in a build that had no
1121
+ // seed to hash (the desktop shell's own render).
1122
+ const SEED_STAMP = ${JSON.stringify(seedStamp)};
1123
+ const SEED_QUERY = SEED_STAMP ? "?b=" + SEED_STAMP : "";
1124
+
647
1125
  let seedPayload = null;
648
1126
  let seedFacts = 0;
649
1127
  async function fetchSeed() {
650
1128
  try {
651
- const blob = await fetchWithProgress("./chat-seed.json", (loaded, total) => noteProgress("seed", loaded, total));
1129
+ const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
652
1130
  seedPayload = JSON.parse(await blob.text());
653
1131
  seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
654
1132
  } catch (err) {
@@ -698,8 +1176,9 @@ ${THEME_TOKENS_CSS}
698
1176
  // Best-effort IndexedDB (window.tmctChat.openPersistedStore): the whole
699
1177
  // Backend-B payload snapshots after each teach turn, debounced so a burst
700
1178
  // of teaching costs one multi-MB write, not one per fact. The stamp ties a
701
- // snapshot to this deploy (site version) AND this seed (fact count) — either
702
- // changing discards the snapshot in favour of the fresh seed.
1179
+ // snapshot to this deploy (site version) AND this seed (its fact count and
1180
+ // content hash) — any of them changing discards the snapshot in favour of
1181
+ // the fresh seed.
703
1182
  let persist = null;
704
1183
  let saveTimer = null;
705
1184
  let restoredCount = 0;
@@ -731,6 +1210,10 @@ ${THEME_TOKENS_CSS}
731
1210
  saveTimer = null;
732
1211
  if (persist) await persist.clear();
733
1212
  restoredCount = 0;
1213
+ // The room holds the OLD session's store, so it can't outlive the swap —
1214
+ // it would keep merging peers' facts into a store nothing reads any more.
1215
+ // Rejoining is a fresh invite, which is what a dropped node needs anyway.
1216
+ dropRoom();
734
1217
  window.tmctChatSession = newSession();
735
1218
  const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
736
1219
  addSystemLine("forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").");
@@ -755,6 +1238,7 @@ ${THEME_TOKENS_CSS}
755
1238
  try { stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir); }
756
1239
  catch { return; }
757
1240
  }
1241
+ factPillValueEl.textContent = Number(stats.total || 0).toLocaleString();
758
1242
  renderStatsPanelInto(statsPanelEl, stats, {
759
1243
  bandLabel: bandLabelFor,
760
1244
  onForget: persist ? forgetEverything : null,
@@ -872,7 +1356,10 @@ ${THEME_TOKENS_CSS}
872
1356
  : "wink-nlp: loading\\u2026";
873
1357
  const liveReference = window.tmctChatSession ? window.tmctChatSession.liveReference : liveReferenceForMode(checkedWikiMode());
874
1358
  const livePart = "live wikipedia: " + liveStatusWord(liveReference);
875
- statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart;
1359
+ const netPart = room
1360
+ ? " \\u00b7 world \\u201c" + worldName + "\\u201d: " + wireStateLabel(room.state).word
1361
+ : "";
1362
+ statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart + netPart;
876
1363
  }
877
1364
 
878
1365
  // A "/wiki on|off|supplement|always" turn flips the session's own state;
@@ -935,7 +1422,12 @@ ${THEME_TOKENS_CSS}
935
1422
  // were lost on reload when only via==="assert" saved. Commands write
936
1423
  // nothing, so they stay out. The save is debounced, so a read-through
937
1424
  // that changed nothing costs at most one coalesced write.
938
- if (result.record && result.record.via !== "command") scheduleSave();
1425
+ if (result.record && result.record.via !== "command") {
1426
+ scheduleSave();
1427
+ // Whatever this turn wrote goes out to every connected node. The room
1428
+ // diffs the store itself, so a turn that stored nothing costs nothing.
1429
+ if (room) room.afterLocalChange().catch(function () { /* a dead channel reports itself through its own close */ });
1430
+ }
939
1431
  await renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
940
1432
  await noteResearchLearned(result);
941
1433
  } catch (err) {
@@ -960,6 +1452,15 @@ ${THEME_TOKENS_CSS}
960
1452
  const q = inputEl.value.trim();
961
1453
  if (!q || busy || !window.tmctChatSession) return;
962
1454
  inputEl.value = "";
1455
+ const lowered = q.toLowerCase();
1456
+ if (lowered === "wave" || lowered === "/wave") {
1457
+ addUserBubble(q);
1458
+ transcript.push({ role: "you", text: q, chipTier: null, ts: Date.now() });
1459
+ addSystemLine("you waved — everyone connected to this graph sees it.");
1460
+ waveNow();
1461
+ inputEl.focus();
1462
+ return;
1463
+ }
963
1464
  submitLine(q).then(() => inputEl.focus());
964
1465
  });
965
1466
 
@@ -1104,7 +1605,10 @@ ${THEME_TOKENS_CSS}
1104
1605
  } catch (err) {
1105
1606
  addSystemLine("something went wrong ingesting " + file.name + " (" + (err && err.message ? err.message : err) + ").");
1106
1607
  }
1107
- if (grounded) scheduleSave();
1608
+ if (grounded) {
1609
+ scheduleSave();
1610
+ if (room) room.afterLocalChange().catch(function () { /* a dead channel reports itself through its own close */ });
1611
+ }
1108
1612
  const skipped = sentences.length - grounded;
1109
1613
  addSystemLine("ingested " + file.name + " \\u2014 " + sentences.length + " sentence"
1110
1614
  + (sentences.length === 1 ? "" : "s") + " read, " + grounded + " fact"
@@ -1116,17 +1620,590 @@ ${THEME_TOKENS_CSS}
1116
1620
  inputEl.focus();
1117
1621
  });
1118
1622
 
1119
- // "reset to seed" is the full re-initialisation: drop the persisted payload
1120
- // outright and reload, so boot re-seeds from the page's shipped seed as if on
1121
- // a first visit. Harder than "forget everything", which only swaps the live
1623
+ // "reset to seed" is the full re-initialisation: drop everything this device
1624
+ // holds and reload, so boot re-seeds from the page's shipped seed as if on a
1625
+ // first visit. Harder than "forget everything", which only swaps the live
1122
1626
  // session — this trusts nothing in memory and re-fetches the seed asset.
1627
+ //
1628
+ // Both stores go, not just the payload: what you taught lives in IndexedDB,
1629
+ // and the seed asset itself lives in the service worker's Cache Storage. A
1630
+ // reset that cleared only the first would re-seed straight back out of a
1631
+ // cached copy of an older seed, which is exactly what it promises not to do.
1123
1632
  el("reinitStore").addEventListener("click", async () => {
1124
1633
  clearTimeout(saveTimer);
1125
1634
  saveTimer = null;
1126
1635
  if (persist) await persist.clear();
1636
+ await clearSiteAssetCaches();
1127
1637
  window.location.reload();
1128
1638
  });
1129
1639
 
1640
+ // ---- the shared world: nodes, the wire, and the two-paste handshake -----
1641
+ // Every piece of networking arrives from ./vendor/p2p.js, the site's own
1642
+ // shared P2P asset, imported the first time it is actually wanted rather
1643
+ // than at boot — a visitor who never shares never waits for it. The room
1644
+ // owns signaling, the mesh and the merge; this block owns what a person
1645
+ // sees and clicks, and nothing else.
1646
+ const netPanelEl = el("netPanel");
1647
+ const nodeNameInputEl = el("nodeNameInput");
1648
+ const worldNameInputEl = el("worldNameInput");
1649
+ const wireStateEl = el("wireState");
1650
+ const wireStateWordEl = el("wireStateWord");
1651
+ const wireStateNoteEl = el("wireStateNote");
1652
+ const statePillEl = el("statePill");
1653
+ const statePillWordEl = el("statePillWord");
1654
+ const sharePanelEl = el("sharePanel");
1655
+ const shareLinkEl = el("shareLink");
1656
+ const replyBoxEl = el("replyBox");
1657
+ const replyProblemEl = el("replyProblem");
1658
+ const answerPanelEl = el("answerPanel");
1659
+ const replyOutEl = el("replyOut");
1660
+ const nodeListEl = el("nodeList");
1661
+ const nodeEmptyEl = el("nodeEmpty");
1662
+ const nodeCountEl = el("nodeCount");
1663
+ const tapeEl = el("tape");
1664
+ const tapeEmptyEl = el("tapeEmpty");
1665
+ const tapeMeterEl = el("tapeMeter");
1666
+ const tapeTotalEl = el("tapeTotal");
1667
+ const waveBurstEl = el("waveBurst");
1668
+ const joinCardEl = el("joinCard");
1669
+ const joinWorldEl = el("joinWorld");
1670
+ const joinEyebrowEl = el("joinEyebrow");
1671
+ const joinBodyEl = el("joinBody");
1672
+ const joinBtn = el("joinBtn");
1673
+ const joinProblemEl = el("joinProblem");
1674
+ const joinReplyWrapEl = el("joinReplyWrap");
1675
+ const joinReplyEl = el("joinReply");
1676
+ const joinDismissBtn = el("joinDismiss");
1677
+ const shareBtn = el("shareBtn");
1678
+ const waveBtn = el("waveBtn");
1679
+ const copyReplyBtn = el("copyReplyBtn");
1680
+ const joinCopyBtn = el("joinCopyBtn");
1681
+
1682
+ const P2P_ASSET = "./vendor/p2p.js";
1683
+ const NODE_NAME_KEY = "tmct.chat.nodeName";
1684
+ const invite = inviteParamsFrom(window.location.search);
1685
+
1686
+ let p2p = null;
1687
+ let p2pLoad = null;
1688
+ let room = null;
1689
+ let myPeerId = null;
1690
+ let myDisplayName = "";
1691
+ let worldId = "";
1692
+ let worldName = "";
1693
+ let waveTimer = null;
1694
+ let nodeClockTimer = null;
1695
+ let channelCount = 0;
1696
+
1697
+ function loadP2p() {
1698
+ if (!p2pLoad) {
1699
+ p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
1700
+ p2pLoad.catch(function (err) {
1701
+ noteTape("note", "fault", "networking unavailable", err && err.message ? err.message : String(err));
1702
+ });
1703
+ }
1704
+ return p2pLoad;
1705
+ }
1706
+ window.tmctP2pLoad = loadP2p;
1707
+
1708
+ function readStoredNodeName() {
1709
+ try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
1710
+ }
1711
+ function writeStoredNodeName(name) {
1712
+ try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
1713
+ }
1714
+
1715
+ async function ensureIdentity() {
1716
+ const mod = await loadP2p();
1717
+ if (!myPeerId) myPeerId = mod.generatePeerId();
1718
+ if (!myDisplayName) {
1719
+ myDisplayName = readStoredNodeName() || mod.generateDisplayName();
1720
+ nodeNameInputEl.value = myDisplayName;
1721
+ }
1722
+ if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
1723
+ if (!worldName) {
1724
+ worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
1725
+ worldNameInputEl.value = worldName;
1726
+ }
1727
+ }
1728
+
1729
+ // The one place a transport gets made, which makes it the one place every
1730
+ // message crossing one can be seen. The room asks for transports through
1731
+ // this factory and never learns it is being watched.
1732
+ function instrumentedTransport() {
1733
+ const transport = p2p.createTransport({ iceServers: [] });
1734
+ const channel = "ch" + (++channelCount);
1735
+ transport.onMessage(function (message) { noteWire("in", message, channel); });
1736
+ transport.onOpen(function () { noteTape("note", "link", "channel open", channel); });
1737
+ transport.onClose(function () { noteTape("note", "link", "channel closed", channel); });
1738
+ return {
1739
+ createOffer: function () {
1740
+ return transport.createOffer().then(function (sdp) { noteTape("note", "signal", "offer minted", channel); return sdp; });
1741
+ },
1742
+ createAnswerFor: function (offerSdp) {
1743
+ return transport.createAnswerFor(offerSdp).then(function (sdp) { noteTape("note", "signal", "answer minted", channel); return sdp; });
1744
+ },
1745
+ completeWithAnswer: function (answerSdp) {
1746
+ noteTape("note", "signal", "answer accepted", channel);
1747
+ return transport.completeWithAnswer(answerSdp);
1748
+ },
1749
+ send: function (message) { transport.send(message); noteWire("out", message, channel); },
1750
+ onMessage: function (fn) { transport.onMessage(fn); },
1751
+ onOpen: function (fn) { transport.onOpen(fn); },
1752
+ onClose: function (fn) { transport.onClose(fn); },
1753
+ close: function () { transport.close(); },
1754
+ get connectionState() { return transport.connectionState; },
1755
+ };
1756
+ }
1757
+
1758
+ async function ensureRoom() {
1759
+ if (room) return room;
1760
+ const mod = await loadP2p();
1761
+ await ensureIdentity();
1762
+ if (!window.tmctChatSession) await window.tmctChatReady;
1763
+ if (!window.tmctChatSession) throw new Error("the chat engine didn't finish booting");
1764
+ room = mod.createP2pRoom({
1765
+ memoryDir: window.tmctChatSession.memoryDir,
1766
+ myPeerId: myPeerId,
1767
+ myDisplayName: myDisplayName,
1768
+ worldId: worldId,
1769
+ worldName: worldName,
1770
+ transportFactory: instrumentedTransport,
1771
+ syncableFacts: mod.chatSyncableFacts,
1772
+ });
1773
+ room.onStateChanged(function (state) {
1774
+ noteTape("note", state === "failed" ? "fault" : "state", "state " + state, "");
1775
+ // The join card exists to produce one reply. Once the channel is open
1776
+ // the reply has done its job, and a full-screen card over a live
1777
+ // conversation is just something in the way.
1778
+ if (state === "connected") joinCardEl.hidden = true;
1779
+ renderWire();
1780
+ renderStatus();
1781
+ });
1782
+ room.onPeersChanged(function () { renderNodes(); renderStatus(); });
1783
+ room.onFactsChanged(function (payload) {
1784
+ noteTape("note", "facts", "merged", payload.merged + (payload.merged === 1 ? " fact" : " facts"));
1785
+ renderStatsPanel();
1786
+ renderNodes();
1787
+ renderWaves();
1788
+ });
1789
+ await room.start();
1790
+ // The world's name is written into the graph the moment the room starts,
1791
+ // and this version retracts nothing — so the field stops taking edits.
1792
+ worldNameInputEl.readOnly = true;
1793
+ worldNameInputEl.title = "written into the graph when this world started";
1794
+ window.tmctP2pRoom = room;
1795
+ noteTape("note", "link", "world " + worldName, myDisplayName);
1796
+ renderWire();
1797
+ renderNodes();
1798
+ renderStatus();
1799
+ if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
1800
+ return room;
1801
+ }
1802
+
1803
+ function dropRoom() {
1804
+ if (!room) return;
1805
+ room.close();
1806
+ room = null;
1807
+ window.tmctP2pRoom = null;
1808
+ clearInterval(nodeClockTimer);
1809
+ nodeClockTimer = null;
1810
+ shareLinkEl.value = "";
1811
+ replyOutEl.value = "";
1812
+ noteTape("note", "link", "world closed", worldName);
1813
+ renderWire();
1814
+ renderNodes();
1815
+ }
1816
+
1817
+ // ---- the wire tape: every message, as it happens ------------------------
1818
+ const TAPE_CAP = 240;
1819
+ const TAPE_FAMILY_COLOR = {
1820
+ facts: "var(--taught)",
1821
+ state: "var(--corpus)",
1822
+ greeting: "var(--entail)",
1823
+ signal: "var(--entail-t1)",
1824
+ fault: "var(--alert)",
1825
+ link: "var(--muted)",
1826
+ };
1827
+ const tapeCounts = new Map();
1828
+ let wireMessageCount = 0;
1829
+
1830
+ function tapeClock() {
1831
+ const at = new Date();
1832
+ const pad = function (n, width) { return String(n).padStart(width, "0"); };
1833
+ return pad(at.getHours(), 2) + ":" + pad(at.getMinutes(), 2) + ":" + pad(at.getSeconds(), 2) + "." + pad(at.getMilliseconds(), 3);
1834
+ }
1835
+
1836
+ function pushTape(entry) {
1837
+ // The meter counts real wire messages only; the tape below shows those
1838
+ // plus the local notes (state changes, a channel opening) that explain
1839
+ // them. Folding notes into the counts would make "12 op" mean two things.
1840
+ if (entry.dir !== "note") {
1841
+ wireMessageCount += 1;
1842
+ tapeCounts.set(entry.type, (tapeCounts.get(entry.type) || 0) + 1);
1843
+ }
1844
+ tapeEmptyEl.hidden = true;
1845
+ const row = document.createElement("li");
1846
+ row.className = "tape-row";
1847
+ row.dataset.dir = entry.dir;
1848
+ row.dataset.family = entry.family;
1849
+ row.dataset.type = entry.type;
1850
+ const bar = document.createElement("i");
1851
+ bar.className = "tape-bar";
1852
+ const clock = document.createElement("span");
1853
+ clock.className = "tape-clock";
1854
+ clock.textContent = tapeClock();
1855
+ const type = document.createElement("span");
1856
+ type.className = "tape-type";
1857
+ type.textContent = entry.type;
1858
+ const detail = document.createElement("span");
1859
+ detail.className = "tape-detail";
1860
+ detail.textContent = entry.detail || "";
1861
+ row.appendChild(bar);
1862
+ row.appendChild(clock);
1863
+ row.appendChild(type);
1864
+ row.appendChild(detail);
1865
+ tapeEl.insertBefore(row, tapeEl.firstChild);
1866
+ while (tapeEl.childElementCount > TAPE_CAP) tapeEl.removeChild(tapeEl.lastElementChild);
1867
+ renderTapeMeter();
1868
+ }
1869
+
1870
+ function noteWire(direction, message, channel) {
1871
+ const row = tapeRowFor(direction, message);
1872
+ pushTape({ dir: direction, family: row.family, type: row.type, detail: row.detail || channel });
1873
+ }
1874
+ function noteTape(dir, family, type, detail) {
1875
+ pushTape({ dir: dir, family: family, type: type, detail: detail });
1876
+ }
1877
+
1878
+ function renderTapeMeter() {
1879
+ tapeTotalEl.textContent = wireMessageCount + (wireMessageCount === 1 ? " message" : " messages");
1880
+ tapeMeterEl.textContent = "";
1881
+ const counted = [...tapeCounts.entries()].sort(function (a, b) { return b[1] - a[1]; });
1882
+ for (const pair of counted) {
1883
+ const item = document.createElement("span");
1884
+ item.className = "meter-item";
1885
+ item.dataset.type = pair[0];
1886
+ const bar = document.createElement("i");
1887
+ bar.className = "meter-bar";
1888
+ bar.style.background = TAPE_FAMILY_COLOR[tapeRowFor("in", { type: pair[0] }).family] || "var(--muted)";
1889
+ const label = document.createElement("span");
1890
+ label.textContent = pair[0];
1891
+ const count = document.createElement("span");
1892
+ count.className = "meter-n";
1893
+ count.textContent = String(pair[1]);
1894
+ item.appendChild(bar);
1895
+ item.appendChild(label);
1896
+ item.appendChild(count);
1897
+ tapeMeterEl.appendChild(item);
1898
+ }
1899
+ }
1900
+
1901
+ // ---- what a person sees: state, nodes, waves ----------------------------
1902
+ function renderWire() {
1903
+ const label = wireStateLabel(room ? room.state : "idle");
1904
+ wireStateEl.dataset.tone = label.tone;
1905
+ wireStateWordEl.textContent = label.word;
1906
+ wireStateNoteEl.textContent = label.note;
1907
+ statePillEl.dataset.tone = label.tone;
1908
+ statePillWordEl.textContent = label.pill;
1909
+ statePillEl.title = label.note;
1910
+ sharePanelEl.hidden = !shareLinkEl.value;
1911
+ // Once the channel is open the reply has been used; leaving "send this
1912
+ // back" on screen would be asking for something already done.
1913
+ answerPanelEl.hidden = !replyOutEl.value || (room && room.state === "connected");
1914
+ }
1915
+
1916
+ function relativeWhen(at, nowMs) {
1917
+ if (at === null || at === undefined) return "—";
1918
+ const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
1919
+ if (seconds < 5) return "now";
1920
+ if (seconds < 60) return seconds + "s";
1921
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
1922
+ if (seconds < 86400) return Math.round(seconds / 3600) + "h";
1923
+ return Math.round(seconds / 86400) + "d";
1924
+ }
1925
+
1926
+ function renderNodes() {
1927
+ if (!room || !p2p) {
1928
+ nodeCountEl.textContent = "";
1929
+ nodeListEl.textContent = "";
1930
+ nodeEmptyEl.hidden = false;
1931
+ return;
1932
+ }
1933
+ const rows = nodeRowsFor({
1934
+ peers: room.peers(),
1935
+ factRows: room.factRows(),
1936
+ myPeerId: myPeerId,
1937
+ myDisplayName: myDisplayName,
1938
+ nameFor: room.displayNameFor,
1939
+ latestTimestampOf: p2p.latestProvenanceTimestamp,
1940
+ });
1941
+ const nowMs = Date.now();
1942
+ nodeCountEl.textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
1943
+ nodeListEl.textContent = "";
1944
+ for (const entry of rows) {
1945
+ const item = document.createElement("li");
1946
+ item.className = "node-row";
1947
+ item.dataset.self = String(entry.isSelf);
1948
+ item.dataset.away = String(!entry.connected);
1949
+ item.dataset.peer = entry.peerId;
1950
+ item.dataset.waving = String(room.isWaving("peer:" + entry.peerId, nowMs));
1951
+ const avatar = document.createElement("span");
1952
+ avatar.className = "node-avatar";
1953
+ avatar.textContent = nodeInitials(entry.name);
1954
+ avatar.title = entry.connected ? "connected" : "away — closed the tab or dropped offline; everything it contributed stays";
1955
+ const dot = document.createElement("i");
1956
+ dot.className = "node-dot";
1957
+ avatar.appendChild(dot);
1958
+ const name = document.createElement("span");
1959
+ name.className = "node-name";
1960
+ name.textContent = entry.name;
1961
+ const hand = document.createElement("span");
1962
+ hand.className = "node-hand";
1963
+ hand.textContent = "👋";
1964
+ const when = document.createElement("span");
1965
+ when.className = "node-when";
1966
+ when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
1967
+ when.title = entry.lastActiveAt
1968
+ ? "last contributed a fact at " + new Date(entry.lastActiveAt).toLocaleTimeString()
1969
+ : "has contributed no fact yet";
1970
+ item.appendChild(avatar);
1971
+ item.appendChild(name);
1972
+ item.appendChild(hand);
1973
+ item.appendChild(when);
1974
+ nodeListEl.appendChild(item);
1975
+ }
1976
+ nodeEmptyEl.hidden = rows.length > 1;
1977
+ }
1978
+
1979
+ // A wave is a fact like any other, so it reaches every page through the
1980
+ // same merge every other fact does; "currently waving" is a recency read
1981
+ // over that fact, never stored state. Nothing here is a second live-update
1982
+ // path — onFactsChanged is what wakes it, and the timer below only lets a
1983
+ // wave stop rendering once its window has passed.
1984
+ function renderWaves() {
1985
+ if (!room) return;
1986
+ const nowMs = Date.now();
1987
+ const known = [{ peerId: myPeerId, name: myDisplayName }];
1988
+ for (const peer of room.peers()) known.push({ peerId: peer.peerId, name: room.displayNameFor(peer.peerId) });
1989
+ const waving = known.filter(function (entry) { return room.isWaving("peer:" + entry.peerId, nowMs); });
1990
+ waveBurstEl.textContent = "";
1991
+ for (const entry of waving) {
1992
+ const pill = document.createElement("div");
1993
+ pill.className = "wave-pill";
1994
+ const hand = document.createElement("span");
1995
+ hand.className = "hand";
1996
+ hand.textContent = "👋";
1997
+ const label = document.createElement("span");
1998
+ label.textContent = entry.name + " waved";
1999
+ pill.appendChild(hand);
2000
+ pill.appendChild(label);
2001
+ waveBurstEl.appendChild(pill);
2002
+ }
2003
+ for (const item of nodeListEl.children) {
2004
+ item.dataset.waving = String(waving.some(function (entry) { return entry.peerId === item.dataset.peer; }));
2005
+ }
2006
+ clearTimeout(waveTimer);
2007
+ if (waving.length) waveTimer = setTimeout(renderWaves, 1000);
2008
+ }
2009
+
2010
+ async function waveNow() {
2011
+ try {
2012
+ const active = await ensureRoom();
2013
+ await active.wave("peer:" + myPeerId, null);
2014
+ noteTape("note", "facts", "waved", myDisplayName);
2015
+ renderNodes();
2016
+ renderWaves();
2017
+ } catch (err) {
2018
+ addSystemLine("couldn't wave (" + (err && err.message ? err.message : err) + ").");
2019
+ }
2020
+ }
2021
+
2022
+ // ---- copying: one tap, no menu, a tooltip that says it happened ---------
2023
+ async function copyText(text) {
2024
+ try {
2025
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2026
+ await navigator.clipboard.writeText(text);
2027
+ return true;
2028
+ }
2029
+ } catch { /* fall through — the box on the page still holds the text */ }
2030
+ try {
2031
+ const holder = document.createElement("textarea");
2032
+ holder.value = text;
2033
+ holder.setAttribute("readonly", "");
2034
+ holder.style.position = "fixed";
2035
+ holder.style.opacity = "0";
2036
+ document.body.appendChild(holder);
2037
+ holder.select();
2038
+ const copied = document.execCommand("copy");
2039
+ holder.remove();
2040
+ return copied;
2041
+ } catch {
2042
+ return false;
2043
+ }
2044
+ }
2045
+
2046
+ function flashTip(anchor, text) {
2047
+ const tip = document.createElement("div");
2048
+ tip.className = "copyTip";
2049
+ tip.setAttribute("role", "status");
2050
+ tip.textContent = text;
2051
+ document.body.appendChild(tip);
2052
+ const box = anchor.getBoundingClientRect();
2053
+ const width = tip.offsetWidth;
2054
+ tip.style.top = (box.bottom + 6) + "px";
2055
+ tip.style.left = Math.max(6, Math.min(window.innerWidth - width - 6, box.left + box.width / 2 - width / 2)) + "px";
2056
+ setTimeout(function () { tip.remove(); }, 2600);
2057
+ }
2058
+
2059
+ const openNetPanel = () => document.body.classList.add("net-open");
2060
+ const closeNetPanel = () => document.body.classList.remove("net-open");
2061
+ el("netPanelClose").addEventListener("click", closeNetPanel);
2062
+ statePillEl.addEventListener("click", function () {
2063
+ if (document.body.classList.contains("net-open")) closeNetPanel();
2064
+ else openNetPanel();
2065
+ });
2066
+
2067
+ shareBtn.addEventListener("click", async function () {
2068
+ shareBtn.disabled = true;
2069
+ try {
2070
+ const active = await ensureRoom();
2071
+ const minted = await active.startSharing();
2072
+ shareLinkEl.value = inviteLinkFor(window.location.href, { blob: minted.blob, world: worldId, worldName: worldName });
2073
+ replyProblemEl.hidden = true;
2074
+ renderWire();
2075
+ openNetPanel();
2076
+ await copyText(shareLinkEl.value);
2077
+ flashTip(shareBtn, "link copied — send it to one person");
2078
+ } catch (err) {
2079
+ addSystemLine("couldn't create an invite (" + (err && err.message ? err.message : err) + ").");
2080
+ } finally {
2081
+ shareBtn.disabled = false;
2082
+ }
2083
+ });
2084
+
2085
+ // The inviter's page has exactly one paste target, so there is no wrong box
2086
+ // to choose. A rejected paste keeps its text so the copy can be fixed.
2087
+ el("replyBtn").addEventListener("click", async function () {
2088
+ let active = room;
2089
+ if (!active) {
2090
+ try { active = await ensureRoom(); } catch { return; }
2091
+ }
2092
+ const outcome = await active.completeInvite(replyBoxEl.value);
2093
+ if (outcome && outcome.error) {
2094
+ replyProblemEl.textContent = outcome.message;
2095
+ replyProblemEl.hidden = false;
2096
+ noteTape("note", "fault", "reply rejected", outcome.error);
2097
+ return;
2098
+ }
2099
+ replyProblemEl.hidden = true;
2100
+ replyBoxEl.value = "";
2101
+ // The box stays open on purpose. If two people opened the same link, the
2102
+ // second reply still arrives, and it needs somewhere to land so the page
2103
+ // can say the invite has already been used rather than swallowing it.
2104
+ renderWire();
2105
+ });
2106
+
2107
+ copyReplyBtn.addEventListener("click", async function () {
2108
+ await copyText(replyOutEl.value);
2109
+ flashTip(copyReplyBtn, "reply copied");
2110
+ });
2111
+
2112
+ function commitNodeName() {
2113
+ const name = nodeNameInputEl.value.trim();
2114
+ if (!name || name === myDisplayName) {
2115
+ nodeNameInputEl.value = myDisplayName;
2116
+ return;
2117
+ }
2118
+ myDisplayName = name;
2119
+ writeStoredNodeName(name);
2120
+ if (!room) { renderNodes(); return; }
2121
+ room.setMyDisplayName(name)
2122
+ .then(function () { noteTape("note", "facts", "renamed", name); renderNodes(); })
2123
+ .catch(function () { /* the name still holds locally; the next broadcast carries it */ });
2124
+ }
2125
+ nodeNameInputEl.addEventListener("change", commitNodeName);
2126
+ worldNameInputEl.addEventListener("change", function () {
2127
+ if (worldNameInputEl.readOnly) return;
2128
+ const name = worldNameInputEl.value.trim();
2129
+ if (name) worldName = name;
2130
+ worldNameInputEl.value = worldName;
2131
+ });
2132
+
2133
+ waveBtn.addEventListener("click", waveNow);
2134
+
2135
+ // ---- joining: a card, one button, nothing running until it is pressed ---
2136
+ async function prepareJoinCard() {
2137
+ joinCardEl.hidden = false;
2138
+ joinWorldEl.textContent = invite.worldName || "a shared graph";
2139
+ joinBtn.focus();
2140
+ const mod = await loadP2p();
2141
+ const decoded = mod.decodeInviteBlob(invite.offer);
2142
+ if (decoded.error || decoded.value.kind !== "offer") {
2143
+ joinBtn.hidden = true;
2144
+ joinEyebrowEl.textContent = "this link didn't arrive in one piece";
2145
+ joinWorldEl.textContent = invite.worldName || "an invitation";
2146
+ joinBodyEl.textContent = decoded.error
2147
+ ? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
2148
+ : "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
2149
+ joinDismissBtn.textContent = "start talking on your own";
2150
+ return;
2151
+ }
2152
+ if (decoded.value.world) invite.world = decoded.value.world;
2153
+ if (decoded.value.worldName) {
2154
+ invite.worldName = decoded.value.worldName;
2155
+ joinWorldEl.textContent = decoded.value.worldName;
2156
+ }
2157
+ }
2158
+
2159
+ joinBtn.addEventListener("click", async function () {
2160
+ joinBtn.disabled = true;
2161
+ try {
2162
+ const active = await ensureRoom();
2163
+ const outcome = await active.acceptInvite(invite.offer);
2164
+ if (outcome && outcome.error) {
2165
+ joinProblemEl.textContent = outcome.message;
2166
+ joinProblemEl.hidden = false;
2167
+ noteTape("note", "fault", "invite rejected", outcome.error);
2168
+ joinBtn.disabled = false;
2169
+ joinBtn.textContent = "try again";
2170
+ return;
2171
+ }
2172
+ joinProblemEl.hidden = true;
2173
+ joinReplyEl.value = outcome.blob;
2174
+ replyOutEl.value = outcome.blob;
2175
+ joinReplyWrapEl.hidden = false;
2176
+ joinBtn.textContent = "reply created";
2177
+ joinDismissBtn.textContent = "close this and start talking";
2178
+ renderWire();
2179
+ await copyText(outcome.blob);
2180
+ flashTip(joinCopyBtn, "reply copied — send it back the same way");
2181
+ } catch (err) {
2182
+ joinProblemEl.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2183
+ joinProblemEl.hidden = false;
2184
+ joinBtn.disabled = false;
2185
+ }
2186
+ });
2187
+
2188
+ joinCopyBtn.addEventListener("click", async function () {
2189
+ await copyText(joinReplyEl.value);
2190
+ flashTip(joinCopyBtn, "reply copied");
2191
+ });
2192
+ joinDismissBtn.addEventListener("click", function () {
2193
+ joinCardEl.hidden = true;
2194
+ if (replyOutEl.value) openNetPanel();
2195
+ });
2196
+
2197
+ renderWire();
2198
+ renderTapeMeter();
2199
+ if (invite) {
2200
+ prepareJoinCard().catch(function (err) {
2201
+ joinProblemEl.textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
2202
+ joinProblemEl.hidden = false;
2203
+ joinBtn.hidden = true;
2204
+ });
2205
+ }
2206
+
1130
2207
  async function boot() {
1131
2208
  if (!window.tmctChat) {
1132
2209
  statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
@@ -1137,7 +2214,7 @@ ${THEME_TOKENS_CSS}
1137
2214
  progressActive = false;
1138
2215
  window.tmctChat.registerReferencePackProvider(fetchPackProvider);
1139
2216
  if (window.tmctChat.openPersistedStore) {
1140
- persist = window.tmctChat.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts });
2217
+ persist = window.tmctChat.openPersistedStore({ storeKey: "chat", stamp: siteVersion + ":" + seedFacts + ":" + SEED_STAMP });
1141
2218
  }
1142
2219
  const savedRecord = persist ? await persist.load() : null;
1143
2220
  const initialMode = readWikiMode();
@@ -1182,6 +2259,10 @@ ${THEME_TOKENS_CSS}
1182
2259
  renderStatus();
1183
2260
  setBusy(false);
1184
2261
  inputEl.focus();
2262
+ // Off the boot path on purpose: this fetches the shared P2P asset so the
2263
+ // rail can show a real node name and world name before anyone clicks
2264
+ // anything. A failure here costs sharing, never the chat.
2265
+ ensureIdentity().then(renderNodes).catch(function () { /* the tape already carries the reason */ });
1185
2266
  }
1186
2267
 
1187
2268
  window.tmctChatReady = boot().catch((err) => {