@polycode-projects/the-mechanical-code-talker 3.2.0 → 4.0.0

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 (34) hide show
  1. package/corpus/sprites/src/sprite-facts.jsonl +28 -0
  2. package/corpus/tier2/generate.mjs +10 -1
  3. package/corpus/tier2/human.jsonl +23 -0
  4. package/corpus/tier2/manifest.json +3 -3
  5. package/corpus/worlds/index.json.gz +0 -0
  6. package/corpus/worlds/manifest.json +15 -5
  7. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  8. package/corpus/worlds/src/mud-garden.jsonl +101 -0
  9. package/package.json +2 -1
  10. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  11. package/src/domain/game-config.mjs +67 -0
  12. package/src/domain/grammar/ace.mjs +11 -3
  13. package/src/domain/grammar/lexicon-core.json +3 -0
  14. package/src/domain/grammar/lexicon.mjs +13 -0
  15. package/src/domain/memory/trust.mjs +15 -0
  16. package/src/domain/p2p/facts.mjs +81 -0
  17. package/src/domain/p2p/peer-id.mjs +32 -0
  18. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  19. package/src/domain/p2p/sync-filter.mjs +31 -0
  20. package/src/domain/p2p/wire.mjs +123 -0
  21. package/src/domain/sprite-map.mjs +10 -2
  22. package/src/services/adventure-editor.mjs +10 -2
  23. package/src/services/adventure-viz.mjs +192 -39
  24. package/src/services/adventure.mjs +989 -69
  25. package/src/services/chat-page-viz.mjs +1060 -7
  26. package/src/services/chat-session.mjs +28 -3
  27. package/src/services/chat.mjs +2 -2
  28. package/src/services/mud-editor.mjs +313 -0
  29. package/src/services/mud-turn.mjs +572 -0
  30. package/src/services/mud-viz.mjs +2055 -0
  31. package/src/services/p2p-room.mjs +559 -0
  32. package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
  33. package/src/surfaces/web/mud-browser-entry.mjs +330 -0
  34. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -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`
@@ -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,169 @@ ${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
+ .chrome { display: flex; align-items: center; gap: .4rem; }
553
+ .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; }
554
+ .chrome-btn:hover { color: var(--ink); border-color: var(--ink); }
555
+ .chrome-btn.share { color: var(--ink); }
556
+ .chrome-btn.help, .chrome-btn.icon { width: 1.7rem; height: 1.7rem; justify-content: center; padding: 0; }
557
+ .chrome-btn .hand { font-size: .9rem; line-height: 1; }
558
+
559
+ .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; }
560
+ .state-pill .pill-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); flex: 0 0 auto; }
561
+ .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; }
562
+ .state-pill[data-tone="working"] .pill-dot { animation-duration: .9s; }
563
+ .state-pill[data-tone="live"] { color: var(--taught); border-color: var(--taught-t1); }
564
+ .state-pill[data-tone="live"] .pill-dot { background: var(--taught); }
565
+ .state-pill[data-tone="failed"] { color: var(--alert); border-color: var(--alert); }
566
+ .state-pill[data-tone="failed"] .pill-dot { background: var(--alert); }
567
+
568
+ /* ---- the network rail --------------------------------------------------
569
+ The docks encode the two halves of a shared graph: the room on the left
570
+ (who else holds this graph, and what is crossing the wire between you),
571
+ the mind on the right (what it knows), the conversation between them.
572
+ Mono throughout — the conversation is prose, the network is telemetry,
573
+ and the register shift is the point. */
574
+ .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; }
575
+ .net-block { display: flex; flex-direction: column; gap: .45rem; }
576
+ .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; }
577
+ .netPanel h2 .h2-count { letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; }
578
+ .net-field { display: flex; flex-direction: column; gap: .2rem; }
579
+ .net-label { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--muted); }
580
+ .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; }
581
+ .net-name-input:read-only { background: none; border-color: transparent; padding-left: 0; color: var(--muted); }
582
+ .net-note { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--muted); margin: 0; }
583
+ .net-help { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--corpus); }
584
+ .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; }
585
+ .net-btn:hover { border-color: var(--ink); }
586
+ .net-btn:disabled { opacity: .5; cursor: default; }
587
+ .net-btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
588
+ .net-btn.ghost { border-color: transparent; color: var(--muted); padding-left: 0; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; }
589
+ .net-btn.ghost:hover { color: var(--ink); text-decoration-color: var(--ink); }
590
+ /* the blobs stay mono: they are machine text a person only ever copies, and
591
+ a serif face on base64 is a lie about what it is. */
592
+ .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; }
593
+ .net-blob:focus { color: var(--ink); }
594
+ .net-problem { margin: 0; font-family: ${SERIF_STACK}; font-size: .78rem; color: var(--alert); border-left: 2px solid var(--alert); padding-left: .5rem; }
595
+ .net-invite { display: flex; flex-direction: column; gap: .4rem; padding-top: .2rem; }
596
+
597
+ /* connection state, as a real visual state at every point: a calm slow
598
+ breath while nothing is in flight, a quicker one while ICE runs, a solid
599
+ green edge when the channel is open, and a static dashed red when it
600
+ failed — a fault should not twitch. */
601
+ .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}; }
602
+ .wire-state-word { display: block; font-size: .95rem; color: var(--ink); }
603
+ .wire-state-note { display: block; margin-top: .25rem; font-size: .74rem; line-height: 1.4; color: var(--muted); }
604
+ .wire-state[data-tone="waiting"], .wire-state[data-tone="working"] { border-left-color: var(--corpus); }
605
+ .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; }
606
+ .wire-state[data-tone="working"]::before { animation-duration: .9s; }
607
+ .wire-state[data-tone="live"] { border-left-color: var(--taught); background: var(--taught-soft); }
608
+ .wire-state[data-tone="live"] .wire-state-word { color: var(--taught); }
609
+ .wire-state[data-tone="failed"] { border-style: dashed; border-left-style: solid; border-left-color: var(--alert); background: var(--alert-soft); }
610
+ .wire-state[data-tone="failed"] .wire-state-word { color: var(--alert); }
611
+ @keyframes wire-breathe { 0%, 100% { opacity: .2; } 50% { opacity: 1; } }
612
+
613
+ /* a member list, the way every chat app already draws one: a monogram, a
614
+ presence badge on it, the name, and when they were last heard from. The
615
+ monogram is neutral on purpose — on this page colour means provenance,
616
+ and an avatar palette would spend that meaning on decoration. */
617
+ .node-list { list-style: none; margin: 0; padding: 0; }
618
+ .node-row { display: flex; align-items: center; gap: .55rem; padding: .3rem 0; }
619
+ .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; }
620
+ .node-row[data-self="true"] .node-avatar { background: var(--ink); color: var(--bg); }
621
+ .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); }
622
+ .node-row[data-away="true"] .node-dot { background: var(--muted); }
623
+ .node-name { font-family: ${SERIF_STACK}; color: var(--ink); font-size: .86rem; flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
624
+ .node-row[data-self="true"] .node-name::after { content: " (you)"; color: var(--muted); font-size: .74rem; }
625
+ .node-row[data-away="true"] .node-name { color: var(--muted); }
626
+ .node-when { color: var(--muted); font-family: ${MONO_STACK}; font-size: .62rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
627
+ .node-hand { display: inline-block; font-size: .88rem; opacity: 0; transform-origin: 70% 80%; }
628
+ .node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
629
+ .node-empty { font-family: ${SERIF_STACK}; color: var(--muted); font-size: .76rem; line-height: 1.4; margin: 0; }
630
+ @keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
631
+
632
+ /* the wire tape — this page's own instrument. Every message in and out, in
633
+ arrival order, newest first so the latest is readable without scrolling.
634
+ The 3px bar carries the message family in the page's own provenance
635
+ colours; the type is spelled out beside it, so colour is never the only
636
+ thing distinguishing one row from another. */
637
+ .net-tape-block { flex: 1 1 auto; min-height: 0; }
638
+ .tape-meter { display: flex; flex-wrap: wrap; gap: .15rem .7rem; margin: 0 0 .3rem; }
639
+ .meter-item { display: inline-flex; align-items: center; gap: .3rem; font-size: .6rem; color: var(--muted); }
640
+ .meter-bar { width: 5px; height: 5px; border-radius: 1px; flex: 0 0 auto; }
641
+ .meter-n { color: var(--ink); font-variant-numeric: tabular-nums; }
642
+ .tape { list-style: none; margin: 0; padding: 0; max-height: 20rem; overflow-y: auto; border-top: 1px solid var(--line); }
643
+ .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; }
644
+ .tape-row:first-child { animation: tape-arrive .6s ease-out; }
645
+ .tape-bar { align-self: stretch; border-radius: 1px; background: var(--muted); }
646
+ .tape-clock { color: var(--muted); }
647
+ .tape-type { color: var(--ink); overflow-wrap: anywhere; }
648
+ .tape-detail { color: var(--muted); text-align: right; white-space: nowrap; }
649
+ .tape-row[data-dir="out"] .tape-type::before { content: "\\2192 "; color: var(--muted); }
650
+ .tape-row[data-dir="in"] .tape-type::before { content: "\\2190 "; color: var(--muted); }
651
+ .tape-row[data-dir="note"] .tape-type::before { content: "\\00b7 "; color: var(--muted); }
652
+ .tape-row[data-family="facts"] .tape-bar { background: var(--taught); }
653
+ .tape-row[data-family="state"] .tape-bar { background: var(--corpus); }
654
+ .tape-row[data-family="greeting"] .tape-bar { background: var(--entail); }
655
+ .tape-row[data-family="signal"] .tape-bar { background: var(--entail-t1); }
656
+ .tape-row[data-family="fault"] .tape-bar { background: var(--alert); }
657
+ .tape-row[data-family="fault"] .tape-type { color: var(--alert); }
658
+ .tape-empty { font-family: ${SERIF_STACK}; color: var(--muted); font-size: .76rem; line-height: 1.4; padding: .45rem 0 0; margin: 0; }
659
+ @keyframes tape-arrive { from { background: var(--corpus-soft); } to { background: transparent; } }
660
+
661
+ .netPanel-close { display: none; align-self: flex-end; font-family: ${MONO_STACK}; font-size: .8rem; color: var(--muted); padding: 0 .2rem; }
662
+
663
+ /* the join card: the only thing a joiner sees until they act. The world's
664
+ generated two-word name is the one place it gets to be a headline. */
665
+ .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; }
666
+ .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; }
667
+ .joinCard-eyebrow { font-size: .82rem; color: var(--muted); margin: 0; }
668
+ .joinCard-world { font-size: 1.75rem; line-height: 1.1; margin: -.45rem 0 0; color: var(--ink); font-weight: 600; overflow-wrap: anywhere; }
669
+ .joinCard-body { font-size: .9rem; color: var(--muted); margin: 0; max-width: 36ch; }
670
+ .joinCard .net-btn.primary { font-size: .95rem; padding: .5rem 1.15rem; }
671
+ .joinCard-reply { display: flex; flex-direction: column; gap: .45rem; }
672
+
673
+ /* a wave, on every page it reaches: the waver's node name, over the
674
+ conversation, for as long as the wave is recent. Anchored under the
675
+ topbar, where a chat app puts a presence toast — the foot of the column
676
+ belongs to the composer, and a burst there lands behind it. */
677
+ .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; }
678
+ .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; }
679
+ .wave-pill .hand { display: inline-block; font-size: 1rem; transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; }
680
+ @keyframes wave-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
681
+
682
+ .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; }
683
+
684
+ @media (max-width: 1080px) {
685
+ /* the rail becomes a drawer rather than disappearing: the invite flow has
686
+ to stay reachable on a laptop and a phone alike. */
687
+ .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; }
688
+ body.net-open .netPanel { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, .3); }
689
+ .netPanel-close { display: block; }
690
+ }
691
+ @media (max-width: 1360px) {
692
+ /* the legend is decorative and the controls are not, so the legend goes
693
+ first rather than folding the controls onto a second row. */
694
+ .legend { display: none; }
695
+ }
338
696
  @media (max-width: 860px) {
339
697
  .statsPanel { display: none; }
340
698
  }
341
699
  @media (max-width: 560px) {
342
- .legend { display: none; }
343
700
  .bubble { max-width: 92%; }
344
701
  }
345
702
  @media (prefers-reduced-motion: reduce) {
346
703
  * { scroll-behavior: auto !important; }
704
+ .wire-state[data-tone="waiting"]::before, .wire-state[data-tone="working"]::before,
705
+ .state-pill .pill-dot, .tape-row:first-child, .netPanel { animation: none; transition: none; }
706
+ .wave-pill, .wave-pill .hand, .node-row[data-waving="true"] .node-hand, .copyTip { animation: none; }
347
707
  }
348
708
 
349
709
  /* print: the WHOLE transcript, not the scrolled-into-view slice — the
@@ -359,17 +719,93 @@ ${THEME_TOKENS_CSS}
359
719
  main.chatMain { overflow: visible; height: auto; }
360
720
  .messages { min-height: 0; }
361
721
  form.composer, .statusline, .statsPanel, .legend { display: none; }
722
+ .netPanel, .joinCard, .waveBurst, .chrome, .copyTip { display: none; }
362
723
  }
363
724
  </style>
364
725
  </head>
365
726
  <body>
727
+ <aside class="netPanel" id="netPanel" aria-label="the shared world: this node, its connection, the other nodes, and the wire">
728
+ <button type="button" class="netPanel-close" id="netPanelClose" aria-label="close the network panel">&times;</button>
729
+ <section class="net-block">
730
+ <h2>this node</h2>
731
+ <div class="net-field">
732
+ <label class="net-label" for="nodeNameInput">your node name</label>
733
+ <input class="net-name-input" id="nodeNameInput" type="text" autocomplete="off" spellcheck="false"
734
+ placeholder="two words from the graph's own vocabulary">
735
+ </div>
736
+ <div class="net-field">
737
+ <label class="net-label" for="worldNameInput">the graph you are sharing</label>
738
+ <input class="net-name-input" id="worldNameInput" type="text" autocomplete="off" spellcheck="false"
739
+ placeholder="named when you first invite someone">
740
+ </div>
741
+ <p class="net-note">both names are facts in the graph, not settings. change yours whenever you like.</p>
742
+ </section>
743
+
744
+ <section class="net-block">
745
+ <h2>connection</h2>
746
+ <div class="wire-state" id="wireState" data-tone="idle" role="status">
747
+ <span class="wire-state-word" id="wireStateWord">not shared</span>
748
+ <span class="wire-state-note" id="wireStateNote">this browser holds the only copy of what you teach it.</span>
749
+ </div>
750
+ <div class="net-invite" id="sharePanel" hidden>
751
+ <div class="net-field">
752
+ <label class="net-label" for="shareLink">the link, in case the copy didn&#8217;t take</label>
753
+ <textarea class="net-blob" id="shareLink" rows="2" readonly></textarea>
754
+ </div>
755
+ <p class="net-note">each link invites one person. invite again for the next.</p>
756
+ <div class="net-field">
757
+ <label class="net-label" for="replyBox">paste their reply here</label>
758
+ <textarea class="net-blob" id="replyBox" rows="3" placeholder="the reply they send back"></textarea>
759
+ </div>
760
+ <button type="button" class="net-btn" id="replyBtn">connect</button>
761
+ <p class="net-problem" id="replyProblem" role="alert" hidden></p>
762
+ </div>
763
+ <div class="net-invite" id="answerPanel" hidden>
764
+ <div class="net-field">
765
+ <label class="net-label" for="replyOut">your reply &#8212; send it back the same way the invite reached you</label>
766
+ <textarea class="net-blob" id="replyOut" rows="3" readonly></textarea>
767
+ </div>
768
+ <button type="button" class="net-btn" id="copyReplyBtn">copy it again</button>
769
+ </div>
770
+ <a class="net-help" href="./help.html#sharing" target="_blank" rel="noopener">how sharing works &#8599;</a>
771
+ </section>
772
+
773
+ <section class="net-block">
774
+ <h2>nodes <span class="h2-count" id="nodeCount"></span></h2>
775
+ <ul class="node-list" id="nodeList"></ul>
776
+ <p class="node-empty" id="nodeEmpty">nobody else yet. invite someone and their node appears here.</p>
777
+ </section>
778
+
779
+ <section class="net-block net-tape-block">
780
+ <h2>wire <span class="h2-count" id="tapeTotal"></span></h2>
781
+ <div class="tape-meter" id="tapeMeter"></div>
782
+ <ol class="tape" id="tape"></ol>
783
+ <p class="tape-empty" id="tapeEmpty">every message this browser sends or receives lands here, as it happens.</p>
784
+ </section>
785
+ </aside>
366
786
  <div class="chatCol">
367
787
  <header class="topbar">
368
788
  <div class="brand">
369
789
  <span class="eyebrow">the-mechanical-code-talker</span>
370
790
  </div>
791
+ <div class="chrome">
792
+ <button type="button" class="state-pill" id="statePill" data-tone="idle"
793
+ title="the shared-world connection; click to open the network panel">
794
+ <i class="pill-dot"></i><span id="statePillWord">not shared</span>
795
+ </button>
796
+ <button type="button" class="chrome-btn icon" id="waveBtn"
797
+ title="wave to everyone connected to this graph" aria-label="wave to everyone connected to this graph">
798
+ <span class="hand">&#128075;</span>
799
+ </button>
800
+ <button type="button" class="chrome-btn share" id="shareBtn" title="copy a link that invites one person into this graph">
801
+ invite
802
+ </button>
803
+ <a class="chrome-btn help" href="./help.html#chat" target="_blank" rel="noopener"
804
+ title="how this page works, in a new tab" aria-label="help, opens in a new tab">?</a>
805
+ </div>
371
806
  <div class="legend" aria-hidden="true">${legendHtml}</div>
372
807
  </header>
808
+ <div class="waveBurst" id="waveBurst" aria-live="polite"></div>
373
809
  <main class="chatMain">
374
810
  <div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
375
811
  </main>
@@ -414,6 +850,22 @@ ${THEME_TOKENS_CSS}
414
850
  <div id="statsPanelStats"><p class="empty">loading memory stats&hellip;</p></div>
415
851
  <div id="researchedPanel"></div>
416
852
  </aside>
853
+ <div class="joinCard" id="joinCard" role="dialog" aria-labelledby="joinWorld" hidden>
854
+ <div class="joinCard-inner">
855
+ <p class="joinCard-eyebrow" id="joinEyebrow">you&#8217;ve been invited to</p>
856
+ <h1 class="joinCard-world" id="joinWorld"></h1>
857
+ <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>
858
+ <button type="button" class="net-btn primary" id="joinBtn">create my reply</button>
859
+ <p class="net-problem" id="joinProblem" role="alert" hidden></p>
860
+ <div class="joinCard-reply" id="joinReplyWrap" hidden>
861
+ <label class="net-label" for="joinReply">your reply, copied &#8212; send it back now</label>
862
+ <textarea class="net-blob" id="joinReply" rows="3" readonly></textarea>
863
+ <button type="button" class="net-btn" id="joinCopyBtn">copy it again</button>
864
+ </div>
865
+ <button type="button" class="net-btn ghost" id="joinDismiss">start talking on your own instead</button>
866
+ <a class="net-help" href="./help.html#sharing" target="_blank" rel="noopener">how sharing works &#8599;</a>
867
+ </div>
868
+ </div>
417
869
  <script src="./chat-browser.bundle.js"></script>
418
870
  <script>
419
871
  (function () {
@@ -432,6 +884,12 @@ ${THEME_TOKENS_CSS}
432
884
  const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
433
885
  const createTicker = ${createTicker.toString()};
434
886
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
887
+ const wireStateLabel = ${wireStateLabel.toString()};
888
+ const tapeRowFor = ${tapeRowFor.toString()};
889
+ const nodeRowsFor = ${nodeRowsFor.toString()};
890
+ const nodeInitials = ${nodeInitials.toString()};
891
+ const inviteLinkFor = ${inviteLinkFor.toString()};
892
+ const inviteParamsFrom = ${inviteParamsFrom.toString()};
435
893
  const DIGEST_STRUCTURES = ${digestStructuresJson};
436
894
  const el = (id) => document.getElementById(id);
437
895
 
@@ -731,6 +1189,10 @@ ${THEME_TOKENS_CSS}
731
1189
  saveTimer = null;
732
1190
  if (persist) await persist.clear();
733
1191
  restoredCount = 0;
1192
+ // The room holds the OLD session's store, so it can't outlive the swap —
1193
+ // it would keep merging peers' facts into a store nothing reads any more.
1194
+ // Rejoining is a fresh invite, which is what a dropped node needs anyway.
1195
+ dropRoom();
734
1196
  window.tmctChatSession = newSession();
735
1197
  const stats = await window.tmctChat.memoryStats(window.tmctChatSession.memoryDir);
736
1198
  addSystemLine("forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").");
@@ -872,7 +1334,10 @@ ${THEME_TOKENS_CSS}
872
1334
  : "wink-nlp: loading\\u2026";
873
1335
  const liveReference = window.tmctChatSession ? window.tmctChatSession.liveReference : liveReferenceForMode(checkedWikiMode());
874
1336
  const livePart = "live wikipedia: " + liveStatusWord(liveReference);
875
- statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart;
1337
+ const netPart = room
1338
+ ? " \\u00b7 world \\u201c" + worldName + "\\u201d: " + wireStateLabel(room.state).word
1339
+ : "";
1340
+ statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart + netPart;
876
1341
  }
877
1342
 
878
1343
  // A "/wiki on|off|supplement|always" turn flips the session's own state;
@@ -935,7 +1400,12 @@ ${THEME_TOKENS_CSS}
935
1400
  // were lost on reload when only via==="assert" saved. Commands write
936
1401
  // nothing, so they stay out. The save is debounced, so a read-through
937
1402
  // that changed nothing costs at most one coalesced write.
938
- if (result.record && result.record.via !== "command") scheduleSave();
1403
+ if (result.record && result.record.via !== "command") {
1404
+ scheduleSave();
1405
+ // Whatever this turn wrote goes out to every connected node. The room
1406
+ // diffs the store itself, so a turn that stored nothing costs nothing.
1407
+ if (room) room.afterLocalChange().catch(function () { /* a dead channel reports itself through its own close */ });
1408
+ }
939
1409
  await renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
940
1410
  await noteResearchLearned(result);
941
1411
  } catch (err) {
@@ -960,6 +1430,15 @@ ${THEME_TOKENS_CSS}
960
1430
  const q = inputEl.value.trim();
961
1431
  if (!q || busy || !window.tmctChatSession) return;
962
1432
  inputEl.value = "";
1433
+ const lowered = q.toLowerCase();
1434
+ if (lowered === "wave" || lowered === "/wave") {
1435
+ addUserBubble(q);
1436
+ transcript.push({ role: "you", text: q, chipTier: null, ts: Date.now() });
1437
+ addSystemLine("you waved — everyone connected to this graph sees it.");
1438
+ waveNow();
1439
+ inputEl.focus();
1440
+ return;
1441
+ }
963
1442
  submitLine(q).then(() => inputEl.focus());
964
1443
  });
965
1444
 
@@ -1104,7 +1583,10 @@ ${THEME_TOKENS_CSS}
1104
1583
  } catch (err) {
1105
1584
  addSystemLine("something went wrong ingesting " + file.name + " (" + (err && err.message ? err.message : err) + ").");
1106
1585
  }
1107
- if (grounded) scheduleSave();
1586
+ if (grounded) {
1587
+ scheduleSave();
1588
+ if (room) room.afterLocalChange().catch(function () { /* a dead channel reports itself through its own close */ });
1589
+ }
1108
1590
  const skipped = sentences.length - grounded;
1109
1591
  addSystemLine("ingested " + file.name + " \\u2014 " + sentences.length + " sentence"
1110
1592
  + (sentences.length === 1 ? "" : "s") + " read, " + grounded + " fact"
@@ -1127,6 +1609,573 @@ ${THEME_TOKENS_CSS}
1127
1609
  window.location.reload();
1128
1610
  });
1129
1611
 
1612
+ // ---- the shared world: nodes, the wire, and the two-paste handshake -----
1613
+ // Every piece of networking arrives from ./vendor/p2p.js, the site's own
1614
+ // shared P2P asset, imported the first time it is actually wanted rather
1615
+ // than at boot — a visitor who never shares never waits for it. The room
1616
+ // owns signaling, the mesh and the merge; this block owns what a person
1617
+ // sees and clicks, and nothing else.
1618
+ const netPanelEl = el("netPanel");
1619
+ const nodeNameInputEl = el("nodeNameInput");
1620
+ const worldNameInputEl = el("worldNameInput");
1621
+ const wireStateEl = el("wireState");
1622
+ const wireStateWordEl = el("wireStateWord");
1623
+ const wireStateNoteEl = el("wireStateNote");
1624
+ const statePillEl = el("statePill");
1625
+ const statePillWordEl = el("statePillWord");
1626
+ const sharePanelEl = el("sharePanel");
1627
+ const shareLinkEl = el("shareLink");
1628
+ const replyBoxEl = el("replyBox");
1629
+ const replyProblemEl = el("replyProblem");
1630
+ const answerPanelEl = el("answerPanel");
1631
+ const replyOutEl = el("replyOut");
1632
+ const nodeListEl = el("nodeList");
1633
+ const nodeEmptyEl = el("nodeEmpty");
1634
+ const nodeCountEl = el("nodeCount");
1635
+ const tapeEl = el("tape");
1636
+ const tapeEmptyEl = el("tapeEmpty");
1637
+ const tapeMeterEl = el("tapeMeter");
1638
+ const tapeTotalEl = el("tapeTotal");
1639
+ const waveBurstEl = el("waveBurst");
1640
+ const joinCardEl = el("joinCard");
1641
+ const joinWorldEl = el("joinWorld");
1642
+ const joinEyebrowEl = el("joinEyebrow");
1643
+ const joinBodyEl = el("joinBody");
1644
+ const joinBtn = el("joinBtn");
1645
+ const joinProblemEl = el("joinProblem");
1646
+ const joinReplyWrapEl = el("joinReplyWrap");
1647
+ const joinReplyEl = el("joinReply");
1648
+ const joinDismissBtn = el("joinDismiss");
1649
+ const shareBtn = el("shareBtn");
1650
+ const waveBtn = el("waveBtn");
1651
+ const copyReplyBtn = el("copyReplyBtn");
1652
+ const joinCopyBtn = el("joinCopyBtn");
1653
+
1654
+ const P2P_ASSET = "./vendor/p2p.js";
1655
+ const NODE_NAME_KEY = "tmct.chat.nodeName";
1656
+ const invite = inviteParamsFrom(window.location.search);
1657
+
1658
+ let p2p = null;
1659
+ let p2pLoad = null;
1660
+ let room = null;
1661
+ let myPeerId = null;
1662
+ let myDisplayName = "";
1663
+ let worldId = "";
1664
+ let worldName = "";
1665
+ let waveTimer = null;
1666
+ let nodeClockTimer = null;
1667
+ let channelCount = 0;
1668
+
1669
+ function loadP2p() {
1670
+ if (!p2pLoad) {
1671
+ p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
1672
+ p2pLoad.catch(function (err) {
1673
+ noteTape("note", "fault", "networking unavailable", err && err.message ? err.message : String(err));
1674
+ });
1675
+ }
1676
+ return p2pLoad;
1677
+ }
1678
+ window.tmctP2pLoad = loadP2p;
1679
+
1680
+ function readStoredNodeName() {
1681
+ try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
1682
+ }
1683
+ function writeStoredNodeName(name) {
1684
+ try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
1685
+ }
1686
+
1687
+ async function ensureIdentity() {
1688
+ const mod = await loadP2p();
1689
+ if (!myPeerId) myPeerId = mod.generatePeerId();
1690
+ if (!myDisplayName) {
1691
+ myDisplayName = readStoredNodeName() || mod.generateDisplayName();
1692
+ nodeNameInputEl.value = myDisplayName;
1693
+ }
1694
+ if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
1695
+ if (!worldName) {
1696
+ worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
1697
+ worldNameInputEl.value = worldName;
1698
+ }
1699
+ }
1700
+
1701
+ // The one place a transport gets made, which makes it the one place every
1702
+ // message crossing one can be seen. The room asks for transports through
1703
+ // this factory and never learns it is being watched.
1704
+ function instrumentedTransport() {
1705
+ const transport = p2p.createTransport({ iceServers: [] });
1706
+ const channel = "ch" + (++channelCount);
1707
+ transport.onMessage(function (message) { noteWire("in", message, channel); });
1708
+ transport.onOpen(function () { noteTape("note", "link", "channel open", channel); });
1709
+ transport.onClose(function () { noteTape("note", "link", "channel closed", channel); });
1710
+ return {
1711
+ createOffer: function () {
1712
+ return transport.createOffer().then(function (sdp) { noteTape("note", "signal", "offer minted", channel); return sdp; });
1713
+ },
1714
+ createAnswerFor: function (offerSdp) {
1715
+ return transport.createAnswerFor(offerSdp).then(function (sdp) { noteTape("note", "signal", "answer minted", channel); return sdp; });
1716
+ },
1717
+ completeWithAnswer: function (answerSdp) {
1718
+ noteTape("note", "signal", "answer accepted", channel);
1719
+ return transport.completeWithAnswer(answerSdp);
1720
+ },
1721
+ send: function (message) { transport.send(message); noteWire("out", message, channel); },
1722
+ onMessage: function (fn) { transport.onMessage(fn); },
1723
+ onOpen: function (fn) { transport.onOpen(fn); },
1724
+ onClose: function (fn) { transport.onClose(fn); },
1725
+ close: function () { transport.close(); },
1726
+ get connectionState() { return transport.connectionState; },
1727
+ };
1728
+ }
1729
+
1730
+ async function ensureRoom() {
1731
+ if (room) return room;
1732
+ const mod = await loadP2p();
1733
+ await ensureIdentity();
1734
+ if (!window.tmctChatSession) await window.tmctChatReady;
1735
+ if (!window.tmctChatSession) throw new Error("the chat engine didn't finish booting");
1736
+ room = mod.createP2pRoom({
1737
+ memoryDir: window.tmctChatSession.memoryDir,
1738
+ myPeerId: myPeerId,
1739
+ myDisplayName: myDisplayName,
1740
+ worldId: worldId,
1741
+ worldName: worldName,
1742
+ transportFactory: instrumentedTransport,
1743
+ syncableFacts: mod.chatSyncableFacts,
1744
+ });
1745
+ room.onStateChanged(function (state) {
1746
+ noteTape("note", state === "failed" ? "fault" : "state", "state " + state, "");
1747
+ // The join card exists to produce one reply. Once the channel is open
1748
+ // the reply has done its job, and a full-screen card over a live
1749
+ // conversation is just something in the way.
1750
+ if (state === "connected") joinCardEl.hidden = true;
1751
+ renderWire();
1752
+ renderStatus();
1753
+ });
1754
+ room.onPeersChanged(function () { renderNodes(); renderStatus(); });
1755
+ room.onFactsChanged(function (payload) {
1756
+ noteTape("note", "facts", "merged", payload.merged + (payload.merged === 1 ? " fact" : " facts"));
1757
+ renderStatsPanel();
1758
+ renderNodes();
1759
+ renderWaves();
1760
+ });
1761
+ await room.start();
1762
+ // The world's name is written into the graph the moment the room starts,
1763
+ // and this version retracts nothing — so the field stops taking edits.
1764
+ worldNameInputEl.readOnly = true;
1765
+ worldNameInputEl.title = "written into the graph when this world started";
1766
+ window.tmctP2pRoom = room;
1767
+ noteTape("note", "link", "world " + worldName, myDisplayName);
1768
+ renderWire();
1769
+ renderNodes();
1770
+ renderStatus();
1771
+ if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
1772
+ return room;
1773
+ }
1774
+
1775
+ function dropRoom() {
1776
+ if (!room) return;
1777
+ room.close();
1778
+ room = null;
1779
+ window.tmctP2pRoom = null;
1780
+ clearInterval(nodeClockTimer);
1781
+ nodeClockTimer = null;
1782
+ shareLinkEl.value = "";
1783
+ replyOutEl.value = "";
1784
+ noteTape("note", "link", "world closed", worldName);
1785
+ renderWire();
1786
+ renderNodes();
1787
+ }
1788
+
1789
+ // ---- the wire tape: every message, as it happens ------------------------
1790
+ const TAPE_CAP = 240;
1791
+ const TAPE_FAMILY_COLOR = {
1792
+ facts: "var(--taught)",
1793
+ state: "var(--corpus)",
1794
+ greeting: "var(--entail)",
1795
+ signal: "var(--entail-t1)",
1796
+ fault: "var(--alert)",
1797
+ link: "var(--muted)",
1798
+ };
1799
+ const tapeCounts = new Map();
1800
+ let wireMessageCount = 0;
1801
+
1802
+ function tapeClock() {
1803
+ const at = new Date();
1804
+ const pad = function (n, width) { return String(n).padStart(width, "0"); };
1805
+ return pad(at.getHours(), 2) + ":" + pad(at.getMinutes(), 2) + ":" + pad(at.getSeconds(), 2) + "." + pad(at.getMilliseconds(), 3);
1806
+ }
1807
+
1808
+ function pushTape(entry) {
1809
+ // The meter counts real wire messages only; the tape below shows those
1810
+ // plus the local notes (state changes, a channel opening) that explain
1811
+ // them. Folding notes into the counts would make "12 op" mean two things.
1812
+ if (entry.dir !== "note") {
1813
+ wireMessageCount += 1;
1814
+ tapeCounts.set(entry.type, (tapeCounts.get(entry.type) || 0) + 1);
1815
+ }
1816
+ tapeEmptyEl.hidden = true;
1817
+ const row = document.createElement("li");
1818
+ row.className = "tape-row";
1819
+ row.dataset.dir = entry.dir;
1820
+ row.dataset.family = entry.family;
1821
+ row.dataset.type = entry.type;
1822
+ const bar = document.createElement("i");
1823
+ bar.className = "tape-bar";
1824
+ const clock = document.createElement("span");
1825
+ clock.className = "tape-clock";
1826
+ clock.textContent = tapeClock();
1827
+ const type = document.createElement("span");
1828
+ type.className = "tape-type";
1829
+ type.textContent = entry.type;
1830
+ const detail = document.createElement("span");
1831
+ detail.className = "tape-detail";
1832
+ detail.textContent = entry.detail || "";
1833
+ row.appendChild(bar);
1834
+ row.appendChild(clock);
1835
+ row.appendChild(type);
1836
+ row.appendChild(detail);
1837
+ tapeEl.insertBefore(row, tapeEl.firstChild);
1838
+ while (tapeEl.childElementCount > TAPE_CAP) tapeEl.removeChild(tapeEl.lastElementChild);
1839
+ renderTapeMeter();
1840
+ }
1841
+
1842
+ function noteWire(direction, message, channel) {
1843
+ const row = tapeRowFor(direction, message);
1844
+ pushTape({ dir: direction, family: row.family, type: row.type, detail: row.detail || channel });
1845
+ }
1846
+ function noteTape(dir, family, type, detail) {
1847
+ pushTape({ dir: dir, family: family, type: type, detail: detail });
1848
+ }
1849
+
1850
+ function renderTapeMeter() {
1851
+ tapeTotalEl.textContent = wireMessageCount + (wireMessageCount === 1 ? " message" : " messages");
1852
+ tapeMeterEl.textContent = "";
1853
+ const counted = [...tapeCounts.entries()].sort(function (a, b) { return b[1] - a[1]; });
1854
+ for (const pair of counted) {
1855
+ const item = document.createElement("span");
1856
+ item.className = "meter-item";
1857
+ item.dataset.type = pair[0];
1858
+ const bar = document.createElement("i");
1859
+ bar.className = "meter-bar";
1860
+ bar.style.background = TAPE_FAMILY_COLOR[tapeRowFor("in", { type: pair[0] }).family] || "var(--muted)";
1861
+ const label = document.createElement("span");
1862
+ label.textContent = pair[0];
1863
+ const count = document.createElement("span");
1864
+ count.className = "meter-n";
1865
+ count.textContent = String(pair[1]);
1866
+ item.appendChild(bar);
1867
+ item.appendChild(label);
1868
+ item.appendChild(count);
1869
+ tapeMeterEl.appendChild(item);
1870
+ }
1871
+ }
1872
+
1873
+ // ---- what a person sees: state, nodes, waves ----------------------------
1874
+ function renderWire() {
1875
+ const label = wireStateLabel(room ? room.state : "idle");
1876
+ wireStateEl.dataset.tone = label.tone;
1877
+ wireStateWordEl.textContent = label.word;
1878
+ wireStateNoteEl.textContent = label.note;
1879
+ statePillEl.dataset.tone = label.tone;
1880
+ statePillWordEl.textContent = label.pill;
1881
+ statePillEl.title = label.note;
1882
+ sharePanelEl.hidden = !shareLinkEl.value;
1883
+ // Once the channel is open the reply has been used; leaving "send this
1884
+ // back" on screen would be asking for something already done.
1885
+ answerPanelEl.hidden = !replyOutEl.value || (room && room.state === "connected");
1886
+ }
1887
+
1888
+ function relativeWhen(at, nowMs) {
1889
+ if (at === null || at === undefined) return "—";
1890
+ const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
1891
+ if (seconds < 5) return "now";
1892
+ if (seconds < 60) return seconds + "s";
1893
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
1894
+ if (seconds < 86400) return Math.round(seconds / 3600) + "h";
1895
+ return Math.round(seconds / 86400) + "d";
1896
+ }
1897
+
1898
+ function renderNodes() {
1899
+ if (!room || !p2p) {
1900
+ nodeCountEl.textContent = "";
1901
+ nodeListEl.textContent = "";
1902
+ nodeEmptyEl.hidden = false;
1903
+ return;
1904
+ }
1905
+ const rows = nodeRowsFor({
1906
+ peers: room.peers(),
1907
+ factRows: room.factRows(),
1908
+ myPeerId: myPeerId,
1909
+ myDisplayName: myDisplayName,
1910
+ nameFor: room.displayNameFor,
1911
+ latestTimestampOf: p2p.latestProvenanceTimestamp,
1912
+ });
1913
+ const nowMs = Date.now();
1914
+ nodeCountEl.textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
1915
+ nodeListEl.textContent = "";
1916
+ for (const entry of rows) {
1917
+ const item = document.createElement("li");
1918
+ item.className = "node-row";
1919
+ item.dataset.self = String(entry.isSelf);
1920
+ item.dataset.away = String(!entry.connected);
1921
+ item.dataset.peer = entry.peerId;
1922
+ item.dataset.waving = String(room.isWaving("peer:" + entry.peerId, nowMs));
1923
+ const avatar = document.createElement("span");
1924
+ avatar.className = "node-avatar";
1925
+ avatar.textContent = nodeInitials(entry.name);
1926
+ avatar.title = entry.connected ? "connected" : "away — closed the tab or dropped offline; everything it contributed stays";
1927
+ const dot = document.createElement("i");
1928
+ dot.className = "node-dot";
1929
+ avatar.appendChild(dot);
1930
+ const name = document.createElement("span");
1931
+ name.className = "node-name";
1932
+ name.textContent = entry.name;
1933
+ const hand = document.createElement("span");
1934
+ hand.className = "node-hand";
1935
+ hand.textContent = "👋";
1936
+ const when = document.createElement("span");
1937
+ when.className = "node-when";
1938
+ when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
1939
+ when.title = entry.lastActiveAt
1940
+ ? "last contributed a fact at " + new Date(entry.lastActiveAt).toLocaleTimeString()
1941
+ : "has contributed no fact yet";
1942
+ item.appendChild(avatar);
1943
+ item.appendChild(name);
1944
+ item.appendChild(hand);
1945
+ item.appendChild(when);
1946
+ nodeListEl.appendChild(item);
1947
+ }
1948
+ nodeEmptyEl.hidden = rows.length > 1;
1949
+ }
1950
+
1951
+ // A wave is a fact like any other, so it reaches every page through the
1952
+ // same merge every other fact does; "currently waving" is a recency read
1953
+ // over that fact, never stored state. Nothing here is a second live-update
1954
+ // path — onFactsChanged is what wakes it, and the timer below only lets a
1955
+ // wave stop rendering once its window has passed.
1956
+ function renderWaves() {
1957
+ if (!room) return;
1958
+ const nowMs = Date.now();
1959
+ const known = [{ peerId: myPeerId, name: myDisplayName }];
1960
+ for (const peer of room.peers()) known.push({ peerId: peer.peerId, name: room.displayNameFor(peer.peerId) });
1961
+ const waving = known.filter(function (entry) { return room.isWaving("peer:" + entry.peerId, nowMs); });
1962
+ waveBurstEl.textContent = "";
1963
+ for (const entry of waving) {
1964
+ const pill = document.createElement("div");
1965
+ pill.className = "wave-pill";
1966
+ const hand = document.createElement("span");
1967
+ hand.className = "hand";
1968
+ hand.textContent = "👋";
1969
+ const label = document.createElement("span");
1970
+ label.textContent = entry.name + " waved";
1971
+ pill.appendChild(hand);
1972
+ pill.appendChild(label);
1973
+ waveBurstEl.appendChild(pill);
1974
+ }
1975
+ for (const item of nodeListEl.children) {
1976
+ item.dataset.waving = String(waving.some(function (entry) { return entry.peerId === item.dataset.peer; }));
1977
+ }
1978
+ clearTimeout(waveTimer);
1979
+ if (waving.length) waveTimer = setTimeout(renderWaves, 1000);
1980
+ }
1981
+
1982
+ async function waveNow() {
1983
+ try {
1984
+ const active = await ensureRoom();
1985
+ await active.wave("peer:" + myPeerId, null);
1986
+ noteTape("note", "facts", "waved", myDisplayName);
1987
+ renderNodes();
1988
+ renderWaves();
1989
+ } catch (err) {
1990
+ addSystemLine("couldn't wave (" + (err && err.message ? err.message : err) + ").");
1991
+ }
1992
+ }
1993
+
1994
+ // ---- copying: one tap, no menu, a tooltip that says it happened ---------
1995
+ async function copyText(text) {
1996
+ try {
1997
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1998
+ await navigator.clipboard.writeText(text);
1999
+ return true;
2000
+ }
2001
+ } catch { /* fall through — the box on the page still holds the text */ }
2002
+ try {
2003
+ const holder = document.createElement("textarea");
2004
+ holder.value = text;
2005
+ holder.setAttribute("readonly", "");
2006
+ holder.style.position = "fixed";
2007
+ holder.style.opacity = "0";
2008
+ document.body.appendChild(holder);
2009
+ holder.select();
2010
+ const copied = document.execCommand("copy");
2011
+ holder.remove();
2012
+ return copied;
2013
+ } catch {
2014
+ return false;
2015
+ }
2016
+ }
2017
+
2018
+ function flashTip(anchor, text) {
2019
+ const tip = document.createElement("div");
2020
+ tip.className = "copyTip";
2021
+ tip.setAttribute("role", "status");
2022
+ tip.textContent = text;
2023
+ document.body.appendChild(tip);
2024
+ const box = anchor.getBoundingClientRect();
2025
+ const width = tip.offsetWidth;
2026
+ tip.style.top = (box.bottom + 6) + "px";
2027
+ tip.style.left = Math.max(6, Math.min(window.innerWidth - width - 6, box.left + box.width / 2 - width / 2)) + "px";
2028
+ setTimeout(function () { tip.remove(); }, 2600);
2029
+ }
2030
+
2031
+ const openNetPanel = () => document.body.classList.add("net-open");
2032
+ const closeNetPanel = () => document.body.classList.remove("net-open");
2033
+ el("netPanelClose").addEventListener("click", closeNetPanel);
2034
+ statePillEl.addEventListener("click", function () {
2035
+ if (document.body.classList.contains("net-open")) closeNetPanel();
2036
+ else openNetPanel();
2037
+ });
2038
+
2039
+ shareBtn.addEventListener("click", async function () {
2040
+ shareBtn.disabled = true;
2041
+ try {
2042
+ const active = await ensureRoom();
2043
+ const minted = await active.startSharing();
2044
+ shareLinkEl.value = inviteLinkFor(window.location.href, { blob: minted.blob, world: worldId, worldName: worldName });
2045
+ replyProblemEl.hidden = true;
2046
+ renderWire();
2047
+ openNetPanel();
2048
+ await copyText(shareLinkEl.value);
2049
+ flashTip(shareBtn, "link copied — send it to one person");
2050
+ } catch (err) {
2051
+ addSystemLine("couldn't create an invite (" + (err && err.message ? err.message : err) + ").");
2052
+ } finally {
2053
+ shareBtn.disabled = false;
2054
+ }
2055
+ });
2056
+
2057
+ // The inviter's page has exactly one paste target, so there is no wrong box
2058
+ // to choose. A rejected paste keeps its text so the copy can be fixed.
2059
+ el("replyBtn").addEventListener("click", async function () {
2060
+ let active = room;
2061
+ if (!active) {
2062
+ try { active = await ensureRoom(); } catch { return; }
2063
+ }
2064
+ const outcome = await active.completeInvite(replyBoxEl.value);
2065
+ if (outcome && outcome.error) {
2066
+ replyProblemEl.textContent = outcome.message;
2067
+ replyProblemEl.hidden = false;
2068
+ noteTape("note", "fault", "reply rejected", outcome.error);
2069
+ return;
2070
+ }
2071
+ replyProblemEl.hidden = true;
2072
+ replyBoxEl.value = "";
2073
+ // The box stays open on purpose. If two people opened the same link, the
2074
+ // second reply still arrives, and it needs somewhere to land so the page
2075
+ // can say the invite has already been used rather than swallowing it.
2076
+ renderWire();
2077
+ });
2078
+
2079
+ copyReplyBtn.addEventListener("click", async function () {
2080
+ await copyText(replyOutEl.value);
2081
+ flashTip(copyReplyBtn, "reply copied");
2082
+ });
2083
+
2084
+ function commitNodeName() {
2085
+ const name = nodeNameInputEl.value.trim();
2086
+ if (!name || name === myDisplayName) {
2087
+ nodeNameInputEl.value = myDisplayName;
2088
+ return;
2089
+ }
2090
+ myDisplayName = name;
2091
+ writeStoredNodeName(name);
2092
+ if (!room) { renderNodes(); return; }
2093
+ room.setMyDisplayName(name)
2094
+ .then(function () { noteTape("note", "facts", "renamed", name); renderNodes(); })
2095
+ .catch(function () { /* the name still holds locally; the next broadcast carries it */ });
2096
+ }
2097
+ nodeNameInputEl.addEventListener("change", commitNodeName);
2098
+ worldNameInputEl.addEventListener("change", function () {
2099
+ if (worldNameInputEl.readOnly) return;
2100
+ const name = worldNameInputEl.value.trim();
2101
+ if (name) worldName = name;
2102
+ worldNameInputEl.value = worldName;
2103
+ });
2104
+
2105
+ waveBtn.addEventListener("click", waveNow);
2106
+
2107
+ // ---- joining: a card, one button, nothing running until it is pressed ---
2108
+ async function prepareJoinCard() {
2109
+ joinCardEl.hidden = false;
2110
+ joinWorldEl.textContent = invite.worldName || "a shared graph";
2111
+ joinBtn.focus();
2112
+ const mod = await loadP2p();
2113
+ const decoded = mod.decodeInviteBlob(invite.offer);
2114
+ if (decoded.error || decoded.value.kind !== "offer") {
2115
+ joinBtn.hidden = true;
2116
+ joinEyebrowEl.textContent = "this link didn't arrive in one piece";
2117
+ joinWorldEl.textContent = invite.worldName || "an invitation";
2118
+ joinBodyEl.textContent = decoded.error
2119
+ ? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
2120
+ : "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
2121
+ joinDismissBtn.textContent = "start talking on your own";
2122
+ return;
2123
+ }
2124
+ if (decoded.value.world) invite.world = decoded.value.world;
2125
+ if (decoded.value.worldName) {
2126
+ invite.worldName = decoded.value.worldName;
2127
+ joinWorldEl.textContent = decoded.value.worldName;
2128
+ }
2129
+ }
2130
+
2131
+ joinBtn.addEventListener("click", async function () {
2132
+ joinBtn.disabled = true;
2133
+ try {
2134
+ const active = await ensureRoom();
2135
+ const outcome = await active.acceptInvite(invite.offer);
2136
+ if (outcome && outcome.error) {
2137
+ joinProblemEl.textContent = outcome.message;
2138
+ joinProblemEl.hidden = false;
2139
+ noteTape("note", "fault", "invite rejected", outcome.error);
2140
+ joinBtn.disabled = false;
2141
+ joinBtn.textContent = "try again";
2142
+ return;
2143
+ }
2144
+ joinProblemEl.hidden = true;
2145
+ joinReplyEl.value = outcome.blob;
2146
+ replyOutEl.value = outcome.blob;
2147
+ joinReplyWrapEl.hidden = false;
2148
+ joinBtn.textContent = "reply created";
2149
+ joinDismissBtn.textContent = "close this and start talking";
2150
+ renderWire();
2151
+ await copyText(outcome.blob);
2152
+ flashTip(joinCopyBtn, "reply copied — send it back the same way");
2153
+ } catch (err) {
2154
+ joinProblemEl.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2155
+ joinProblemEl.hidden = false;
2156
+ joinBtn.disabled = false;
2157
+ }
2158
+ });
2159
+
2160
+ joinCopyBtn.addEventListener("click", async function () {
2161
+ await copyText(joinReplyEl.value);
2162
+ flashTip(joinCopyBtn, "reply copied");
2163
+ });
2164
+ joinDismissBtn.addEventListener("click", function () {
2165
+ joinCardEl.hidden = true;
2166
+ if (replyOutEl.value) openNetPanel();
2167
+ });
2168
+
2169
+ renderWire();
2170
+ renderTapeMeter();
2171
+ if (invite) {
2172
+ prepareJoinCard().catch(function (err) {
2173
+ joinProblemEl.textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
2174
+ joinProblemEl.hidden = false;
2175
+ joinBtn.hidden = true;
2176
+ });
2177
+ }
2178
+
1130
2179
  async function boot() {
1131
2180
  if (!window.tmctChat) {
1132
2181
  statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
@@ -1182,6 +2231,10 @@ ${THEME_TOKENS_CSS}
1182
2231
  renderStatus();
1183
2232
  setBusy(false);
1184
2233
  inputEl.focus();
2234
+ // Off the boot path on purpose: this fetches the shared P2P asset so the
2235
+ // rail can show a real node name and world name before anyone clicks
2236
+ // anything. A failure here costs sharing, never the chat.
2237
+ ensureIdentity().then(renderNodes).catch(function () { /* the tape already carries the reason */ });
1185
2238
  }
1186
2239
 
1187
2240
  window.tmctChatReady = boot().catch((err) => {