@polycode-projects/the-mechanical-code-talker 4.1.1 → 4.1.3

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 (55) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/services/adventure-viz.mjs +5 -1
  27. package/src/services/adventure.mjs +70 -44
  28. package/src/services/chat-page-viz.mjs +381 -310
  29. package/src/services/chat.mjs +273 -155
  30. package/src/services/code-explorer-viz.mjs +141 -54
  31. package/src/services/index.mjs +1 -1
  32. package/src/services/ingest-viz.mjs +134 -9
  33. package/src/services/ledger-viz.mjs +7 -4
  34. package/src/services/memory-panel-viz.mjs +8 -3
  35. package/src/services/mud-turn.mjs +11 -8
  36. package/src/services/mud-viz.mjs +441 -206
  37. package/src/services/p2p-room.mjs +110 -23
  38. package/src/services/plan-viz.mjs +63 -4
  39. package/src/services/research-viz.mjs +18 -7
  40. package/src/services/share-overlay-viz.mjs +623 -0
  41. package/src/services/spider-fly-viz.mjs +2 -2
  42. package/src/services/sprite-catalog-viz.mjs +303 -78
  43. package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
  44. package/src/surfaces/web/chat-browser-entry.mjs +37 -10
  45. package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
  46. package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
  47. package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  49. package/src/surfaces/web/mud-browser-entry.mjs +38 -7
  50. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  51. package/src/surfaces/web/plan-browser-entry.mjs +33 -2
  52. package/src/surfaces/web/research-browser-entry.mjs +11 -19
  53. package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
  54. package/src/surfaces/web/tmct-surface.mjs +12 -0
  55. package/src/surfaces/web/turn-session.mjs +10 -3
@@ -29,6 +29,10 @@
29
29
  // to public/chat.html, after chat-browser.bundle.js/chat-seed.json already
30
30
  // exist (both built earlier in that same script, for the embedded widget).
31
31
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
32
+ import {
33
+ SHARE_OVERLAY_CSS, shareOverlayHtml, shareStepStates, activeWaves, offerBlobIn, peerTerm,
34
+ shareMessageFor, replyMessageFor, whatsAppShareUrl, isProbablyMobile, copyTextToClipboard, flashCopyTip,
35
+ } from "./share-overlay-viz.mjs";
32
36
  import { provBucketFor } from "./ledger-viz.mjs";
33
37
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
34
38
  import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
@@ -139,7 +143,7 @@ export function wireStateLabel(state) {
139
143
  tone: "failed",
140
144
  pill: "can't connect",
141
145
  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.",
146
+ note: "your two machines couldn't find a path to each other. a public STUN server helps with most networks, but some firewalls or strict NATs still block it.",
143
147
  };
144
148
  default:
145
149
  return {
@@ -199,14 +203,17 @@ export function tapeClock() {
199
203
 
200
204
  /**
201
205
  * The node list: every peer this graph knows about, each with the node name it
202
- * chose and the timestamp of the most recent fact it contributed, most
206
+ * chose, the timestamp of the most recent fact it contributed, and that fact
207
+ * itself (`lastFact` — subject/predicate/object, null before any lands), most
203
208
  * recently active first.
204
209
  *
205
210
  * Activity is read off the provenance the wire already carries. A fact a peer
206
- * broadcast arrives tagged `teach:peer:<their node name>@<ts>`, so the tag
207
- * names both who contributed it and when — no separate activity ledger to
208
- * keep. This node's own row reads its local `teach:`/`ace:` tags instead,
209
- * because a fact never leaves here relabelled in its own store.
211
+ * broadcast arrives tagged `teach:peer:<their node name>#node:<their id>@<ts>`,
212
+ * so the tag names both who contributed it and when — no separate activity
213
+ * ledger to keep. The `#node:` segment is identity and never reaches the
214
+ * screen; a tag from a peer old enough not to carry one is just a name. This
215
+ * node's own row reads its local `teach:`/`ace:` tags instead, because a fact
216
+ * never leaves here relabelled in its own store.
210
217
  *
211
218
  * `nameFor` and `latestTimestampOf` are injected (the room's own
212
219
  * `displayNameFor` and the P2P layer's `latestProvenanceTimestamp`) rather
@@ -215,20 +222,48 @@ export function tapeClock() {
215
222
  */
216
223
  export function nodeRowsFor({ peers, factRows, myPeerId, myDisplayName, nameFor, latestTimestampOf }) {
217
224
  const activeByName = new Map();
225
+ const factByName = new Map();
226
+ const factAtByName = new Map();
218
227
  let mineLastActive = null;
228
+ let mineLastFact = null;
229
+ let mineLastFactAt = null;
230
+ // The P2P layer's own bookkeeping rows (names, claims, waves, who invited
231
+ // whom) key on raw node ids, and the roster's rule is that no id ever
232
+ // reaches the screen — they still count as activity, they just never show
233
+ // as "last shared". The fact keeps its own newest-timestamp race, apart
234
+ // from activity, so a fresh wave can never mask an older real fact.
235
+ const BOOKKEEPING = ["mgx:worldName", "mgx:nodeName", "mgx:playedBy", "mgx:waved", "mgx:invitedBy"];
236
+ const factOf = (row) => (BOOKKEEPING.indexOf(row.predicate) >= 0
237
+ ? null
238
+ : { subject: row.subject, predicate: row.predicate, object: row.object });
219
239
  for (const row of factRows || []) {
220
240
  for (const segment of String(row.provenance || "").split(" | ")) {
221
241
  if (!segment) continue;
222
242
  const at = latestTimestampOf(segment);
223
243
  if (at === null) continue;
244
+ const fact = factOf(row);
224
245
  if (segment.indexOf("teach:peer:") === 0) {
225
246
  const marker = segment.lastIndexOf("@");
226
247
  if (marker < 0) continue;
227
- const name = segment.slice("teach:peer:".length, marker);
248
+ const label = segment.slice("teach:peer:".length, marker);
249
+ // Everything from the `#` on is the origin's stable node id, which
250
+ // keys the fact but is never shown: the roster matches on the name a
251
+ // peer chose, and that is the part before it.
252
+ const node = label.indexOf("#node:");
253
+ const name = node < 0 ? label : label.slice(0, node);
228
254
  const prior = activeByName.get(name);
229
255
  if (prior === undefined || at > prior) activeByName.set(name, at);
256
+ const factPrior = factAtByName.get(name);
257
+ if (fact && (factPrior === undefined || at > factPrior)) {
258
+ factAtByName.set(name, at);
259
+ factByName.set(name, fact);
260
+ }
230
261
  } else if (segment.indexOf("teach:") === 0 || segment.indexOf("ace:") === 0) {
231
262
  if (mineLastActive === null || at > mineLastActive) mineLastActive = at;
263
+ if (fact && (mineLastFactAt === null || at > mineLastFactAt)) {
264
+ mineLastFactAt = at;
265
+ mineLastFact = fact;
266
+ }
232
267
  }
233
268
  }
234
269
  }
@@ -238,6 +273,7 @@ export function nodeRowsFor({ peers, factRows, myPeerId, myDisplayName, nameFor,
238
273
  connected: true,
239
274
  isSelf: true,
240
275
  lastActiveAt: mineLastActive,
276
+ lastFact: mineLastFact,
241
277
  }];
242
278
  for (const peer of peers || []) {
243
279
  const name = nameFor(peer.peerId);
@@ -247,6 +283,7 @@ export function nodeRowsFor({ peers, factRows, myPeerId, myDisplayName, nameFor,
247
283
  connected: Boolean(peer.connected),
248
284
  isSelf: false,
249
285
  lastActiveAt: activeByName.has(name) ? activeByName.get(name) : null,
286
+ lastFact: factByName.has(name) ? factByName.get(name) : null,
250
287
  });
251
288
  }
252
289
  rows.sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0));
@@ -586,7 +623,7 @@ ${THEME_TOKENS_CSS}
586
623
  .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; }
587
624
  .fact-pill .fact-pill-value { font-size: .96rem; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 600; color: var(--ink); }
588
625
 
589
- .chrome { display: flex; align-items: center; gap: .4rem; }
626
+ .chrome { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
590
627
  .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; }
591
628
  .chrome-btn:hover { color: var(--ink); border-color: var(--ink); }
592
629
  .chrome-btn.share { color: var(--ink); }
@@ -602,111 +639,9 @@ ${THEME_TOKENS_CSS}
602
639
  .state-pill[data-tone="failed"] { color: var(--alert); border-color: var(--alert); }
603
640
  .state-pill[data-tone="failed"] .pill-dot { background: var(--alert); }
604
641
 
605
- /* ---- the network rail --------------------------------------------------
606
- The docks encode the two halves of a shared graph: the room on the left
607
- (who else holds this graph, and what is crossing the wire between you),
608
- the mind on the right (what it knows), the conversation between them.
609
- Mono throughout — the conversation is prose, the network is telemetry,
610
- and the register shift is the point. */
611
- .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; }
612
- .net-block { display: flex; flex-direction: column; gap: .45rem; }
613
- .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; }
614
- .netPanel h2 .h2-count { letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; }
615
- .net-field { display: flex; flex-direction: column; gap: .2rem; }
616
- .net-label { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--muted); }
617
- .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; }
618
- .net-name-input:read-only { background: none; border-color: transparent; padding-left: 0; color: var(--muted); }
619
- .net-note { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--muted); margin: 0; }
620
- .net-help { font-family: ${SERIF_STACK}; font-size: .74rem; color: var(--corpus); }
621
- .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; }
622
- .net-btn:hover { border-color: var(--ink); }
623
- .net-btn:disabled { opacity: .5; cursor: default; }
624
- .net-btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
625
- .net-btn.ghost { border-color: transparent; color: var(--muted); padding-left: 0; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; }
626
- .net-btn.ghost:hover { color: var(--ink); text-decoration-color: var(--ink); }
627
- /* the blobs stay mono: they are machine text a person only ever copies, and
628
- a serif face on base64 is a lie about what it is. */
629
- .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; }
630
- .net-blob:focus { color: var(--ink); }
631
- .net-problem { margin: 0; font-family: ${SERIF_STACK}; font-size: .78rem; color: var(--alert); border-left: 2px solid var(--alert); padding-left: .5rem; }
632
- .net-invite { display: flex; flex-direction: column; gap: .4rem; padding-top: .2rem; }
633
-
634
- /* connection state, as a real visual state at every point: a calm slow
635
- breath while nothing is in flight, a quicker one while ICE runs, a solid
636
- green edge when the channel is open, and a static dashed red when it
637
- failed — a fault should not twitch. */
638
- .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}; }
639
- .wire-state-word { display: block; font-size: .95rem; color: var(--ink); }
640
- .wire-state-note { display: block; margin-top: .25rem; font-size: .74rem; line-height: 1.4; color: var(--muted); }
641
- .wire-state[data-tone="waiting"], .wire-state[data-tone="working"] { border-left-color: var(--corpus); }
642
- .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; }
643
- .wire-state[data-tone="working"]::before { animation-duration: .9s; }
644
- .wire-state[data-tone="live"] { border-left-color: var(--taught); background: var(--taught-soft); }
645
- .wire-state[data-tone="live"] .wire-state-word { color: var(--taught); }
646
- .wire-state[data-tone="failed"] { border-style: dashed; border-left-style: solid; border-left-color: var(--alert); background: var(--alert-soft); }
647
- .wire-state[data-tone="failed"] .wire-state-word { color: var(--alert); }
648
642
  @keyframes wire-breathe { 0%, 100% { opacity: .2; } 50% { opacity: 1; } }
649
-
650
- /* a member list, the way every chat app already draws one: a monogram, a
651
- presence badge on it, the name, and when they were last heard from. The
652
- monogram is neutral on purpose — on this page colour means provenance,
653
- and an avatar palette would spend that meaning on decoration. */
654
- .node-list { list-style: none; margin: 0; padding: 0; }
655
- .node-row { display: flex; align-items: center; gap: .55rem; padding: .3rem 0; }
656
- .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; }
657
- .node-row[data-self="true"] .node-avatar { background: var(--ink); color: var(--bg); }
658
- .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); }
659
- .node-row[data-away="true"] .node-dot { background: var(--muted); }
660
- .node-name { font-family: ${SERIF_STACK}; color: var(--ink); font-size: .86rem; flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
661
- .node-row[data-self="true"] .node-name::after { content: " (you)"; color: var(--muted); font-size: .74rem; }
662
- .node-row[data-away="true"] .node-name { color: var(--muted); }
663
- .node-when { color: var(--muted); font-family: ${MONO_STACK}; font-size: .62rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
664
- .node-hand { display: inline-block; font-size: .88rem; opacity: 0; transform-origin: 70% 80%; }
665
- .node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
666
- .node-empty { font-family: ${SERIF_STACK}; color: var(--muted); font-size: .76rem; line-height: 1.4; margin: 0; }
667
643
  @keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
668
644
 
669
- /* the wire tape — this page's own instrument. Every message in and out, in
670
- arrival order, newest first so the latest is readable without scrolling.
671
- The 3px bar carries the message family in the page's own provenance
672
- colours; the type is spelled out beside it, so colour is never the only
673
- thing distinguishing one row from another. */
674
- .net-tape-block { flex: 1 1 auto; min-height: 0; }
675
- .tape-meter { display: flex; flex-wrap: wrap; gap: .15rem .7rem; margin: 0 0 .3rem; }
676
- .meter-item { display: inline-flex; align-items: center; gap: .3rem; font-size: .6rem; color: var(--muted); }
677
- .meter-bar { width: 5px; height: 5px; border-radius: 1px; flex: 0 0 auto; }
678
- .meter-n { color: var(--ink); font-variant-numeric: tabular-nums; }
679
- .tape { list-style: none; margin: 0; padding: 0; max-height: 20rem; overflow-y: auto; border-top: 1px solid var(--line); }
680
- .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; }
681
- .tape-row:first-child { animation: tape-arrive .6s ease-out; }
682
- .tape-bar { align-self: stretch; border-radius: 1px; background: var(--muted); }
683
- .tape-clock { color: var(--muted); }
684
- .tape-type { color: var(--ink); overflow-wrap: anywhere; }
685
- .tape-detail { color: var(--muted); text-align: right; white-space: nowrap; }
686
- .tape-row[data-dir="out"] .tape-type::before { content: "\\2192 "; color: var(--muted); }
687
- .tape-row[data-dir="in"] .tape-type::before { content: "\\2190 "; color: var(--muted); }
688
- .tape-row[data-dir="note"] .tape-type::before { content: "\\00b7 "; color: var(--muted); }
689
- .tape-row[data-family="facts"] .tape-bar { background: var(--taught); }
690
- .tape-row[data-family="state"] .tape-bar { background: var(--corpus); }
691
- .tape-row[data-family="greeting"] .tape-bar { background: var(--entail); }
692
- .tape-row[data-family="signal"] .tape-bar { background: var(--entail-t1); }
693
- .tape-row[data-family="fault"] .tape-bar { background: var(--alert); }
694
- .tape-row[data-family="fault"] .tape-type { color: var(--alert); }
695
- .tape-empty { font-family: ${SERIF_STACK}; color: var(--muted); font-size: .76rem; line-height: 1.4; padding: .45rem 0 0; margin: 0; }
696
- @keyframes tape-arrive { from { background: var(--corpus-soft); } to { background: transparent; } }
697
-
698
- .netPanel-close { display: none; align-self: flex-end; font-family: ${MONO_STACK}; font-size: .8rem; color: var(--muted); padding: 0 .2rem; }
699
-
700
- /* the join card: the only thing a joiner sees until they act. The world's
701
- generated two-word name is the one place it gets to be a headline. */
702
- .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; }
703
- .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; }
704
- .joinCard-eyebrow { font-size: .82rem; color: var(--muted); margin: 0; }
705
- .joinCard-world { font-size: 1.75rem; line-height: 1.1; margin: -.45rem 0 0; color: var(--ink); font-weight: 600; overflow-wrap: anywhere; }
706
- .joinCard-body { font-size: .9rem; color: var(--muted); margin: 0; max-width: 36ch; }
707
- .joinCard .net-btn.primary { font-size: .95rem; padding: .5rem 1.15rem; }
708
- .joinCard-reply { display: flex; flex-direction: column; gap: .45rem; }
709
-
710
645
  /* a wave, on every page it reaches: the waver's node name, over the
711
646
  conversation, for as long as the wave is recent. Anchored under the
712
647
  topbar, where a chat app puts a presence toast — the foot of the column
@@ -714,17 +649,10 @@ ${THEME_TOKENS_CSS}
714
649
  .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; }
715
650
  .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; }
716
651
  .wave-pill .hand { display: inline-block; font-size: 1rem; transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; }
652
+ /* a wave aimed at you, specifically: the same pill, insistently louder. */
653
+ .wave-pill--you { border-color: var(--taught); box-shadow: 0 4px 14px rgba(0, 0, 0, .16), 0 0 0 3px var(--taught-soft); font-weight: 600; }
717
654
  @keyframes wave-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
718
655
 
719
- .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; }
720
-
721
- @media (max-width: 1080px) {
722
- /* the rail becomes a drawer rather than disappearing: the invite flow has
723
- to stay reachable on a laptop and a phone alike. */
724
- .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; }
725
- body.net-open .netPanel { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, .3); }
726
- .netPanel-close { display: block; }
727
- }
728
656
  @media (max-width: 1360px) {
729
657
  /* the legend is decorative and the controls are not, so the legend goes
730
658
  first rather than folding the controls onto a second row. */
@@ -738,9 +666,8 @@ ${THEME_TOKENS_CSS}
738
666
  }
739
667
  @media (prefers-reduced-motion: reduce) {
740
668
  * { scroll-behavior: auto !important; }
741
- .wire-state[data-tone="waiting"]::before, .wire-state[data-tone="working"]::before,
742
- .state-pill .pill-dot, .tape-row:first-child, .netPanel { animation: none; transition: none; }
743
- .wave-pill, .wave-pill .hand, .node-row[data-waving="true"] .node-hand, .copyTip { animation: none; }
669
+ .state-pill .pill-dot { animation: none; transition: none; }
670
+ .wave-pill, .wave-pill .hand { animation: none; }
744
671
  }
745
672
 
746
673
  /* print: the WHOLE transcript, not the scrolled-into-view slice — the
@@ -756,70 +683,13 @@ ${THEME_TOKENS_CSS}
756
683
  main.chatMain { overflow: visible; height: auto; }
757
684
  .messages { min-height: 0; }
758
685
  form.composer, .statusline, .statsPanel, .legend { display: none; }
759
- .netPanel, .joinCard, .waveBurst, .chrome, .copyTip { display: none; }
686
+ .shareOverlay, .waveBurst, .chrome, .copyTip { display: none; }
760
687
  }
688
+ ${SHARE_OVERLAY_CSS}
761
689
  </style>
762
690
  </head>
763
691
  <body>
764
- <aside class="netPanel" id="netPanel" aria-label="the shared world: this node, its connection, the other nodes, and the wire">
765
- <button type="button" class="netPanel-close" id="netPanelClose" aria-label="close the network panel">&times;</button>
766
- <section class="net-block">
767
- <h2>this node</h2>
768
- <div class="net-field">
769
- <label class="net-label" for="nodeNameInput">your node name</label>
770
- <input class="net-name-input" id="nodeNameInput" type="text" autocomplete="off" spellcheck="false"
771
- placeholder="two words from the graph's own vocabulary">
772
- </div>
773
- <div class="net-field">
774
- <label class="net-label" for="worldNameInput">the graph you are sharing</label>
775
- <input class="net-name-input" id="worldNameInput" type="text" autocomplete="off" spellcheck="false"
776
- placeholder="named when you first invite someone">
777
- </div>
778
- <p class="net-note">both names are facts in the graph, not settings. change yours whenever you like.</p>
779
- </section>
780
-
781
- <section class="net-block">
782
- <h2>connection</h2>
783
- <div class="wire-state" id="wireState" data-tone="idle" role="status">
784
- <span class="wire-state-word" id="wireStateWord">not shared</span>
785
- <span class="wire-state-note" id="wireStateNote">this browser holds the only copy of what you teach it.</span>
786
- </div>
787
- <div class="net-invite" id="sharePanel" hidden>
788
- <div class="net-field">
789
- <label class="net-label" for="shareLink">the link, in case the copy didn&#8217;t take</label>
790
- <textarea class="net-blob" id="shareLink" rows="2" readonly></textarea>
791
- </div>
792
- <p class="net-note">each link invites one person. invite again for the next.</p>
793
- <div class="net-field">
794
- <label class="net-label" for="replyBox">paste their reply here</label>
795
- <textarea class="net-blob" id="replyBox" rows="3" placeholder="the reply they send back"></textarea>
796
- </div>
797
- <button type="button" class="net-btn" id="replyBtn">connect</button>
798
- <p class="net-problem" id="replyProblem" role="alert" hidden></p>
799
- </div>
800
- <div class="net-invite" id="answerPanel" hidden>
801
- <div class="net-field">
802
- <label class="net-label" for="replyOut">your reply &#8212; send it back the same way the invite reached you</label>
803
- <textarea class="net-blob" id="replyOut" rows="3" readonly></textarea>
804
- </div>
805
- <button type="button" class="net-btn" id="copyReplyBtn">copy it again</button>
806
- </div>
807
- <a class="net-help" href="./help.html#sharing" target="_blank" rel="noopener">how sharing works &#8599;</a>
808
- </section>
809
-
810
- <section class="net-block">
811
- <h2>nodes <span class="h2-count" id="nodeCount"></span></h2>
812
- <ul class="node-list" id="nodeList"></ul>
813
- <p class="node-empty" id="nodeEmpty">nobody else yet. invite someone and their node appears here.</p>
814
- </section>
815
-
816
- <section class="net-block net-tape-block">
817
- <h2>wire <span class="h2-count" id="tapeTotal"></span></h2>
818
- <div class="tape-meter" id="tapeMeter"></div>
819
- <ol class="tape" id="tape"></ol>
820
- <p class="tape-empty" id="tapeEmpty">every message this browser sends or receives lands here, as it happens.</p>
821
- </section>
822
- </aside>
692
+ ${shareOverlayHtml({ withTape: true })}
823
693
  <div class="chatCol">
824
694
  <header class="topbar">
825
695
  <div class="brand">
@@ -891,22 +761,6 @@ ${THEME_TOKENS_CSS}
891
761
  <div id="statsPanelStats"><p class="empty">loading memory stats&hellip;</p></div>
892
762
  <div id="researchedPanel"></div>
893
763
  </aside>
894
- <div class="joinCard" id="joinCard" role="dialog" aria-labelledby="joinWorld" hidden>
895
- <div class="joinCard-inner">
896
- <p class="joinCard-eyebrow" id="joinEyebrow">you&#8217;ve been invited to</p>
897
- <h1 class="joinCard-world" id="joinWorld"></h1>
898
- <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>
899
- <button type="button" class="net-btn primary" id="joinBtn">create my reply</button>
900
- <p class="net-problem" id="joinProblem" role="alert" hidden></p>
901
- <div class="joinCard-reply" id="joinReplyWrap" hidden>
902
- <label class="net-label" for="joinReply">your reply, copied &#8212; send it back now</label>
903
- <textarea class="net-blob" id="joinReply" rows="3" readonly></textarea>
904
- <button type="button" class="net-btn" id="joinCopyBtn">copy it again</button>
905
- </div>
906
- <button type="button" class="net-btn ghost" id="joinDismiss">start talking on your own instead</button>
907
- <a class="net-help" href="./help.html#sharing" target="_blank" rel="noopener">how sharing works &#8599;</a>
908
- </div>
909
- </div>
910
764
  <script src="./chat-browser.bundle.js"></script>
911
765
  <script>
912
766
  (function () {
@@ -934,6 +788,16 @@ ${THEME_TOKENS_CSS}
934
788
  const inviteParamsFrom = ${inviteParamsFrom.toString()};
935
789
  const tapeClock = ${tapeClock.toString()};
936
790
  const relativeWhen = ${relativeWhen.toString()};
791
+ const shareStepStates = ${shareStepStates.toString()};
792
+ const activeWaves = ${activeWaves.toString()};
793
+ const offerBlobIn = ${offerBlobIn.toString()};
794
+ const peerTerm = ${peerTerm.toString()};
795
+ const shareMessageFor = ${shareMessageFor.toString()};
796
+ const replyMessageFor = ${replyMessageFor.toString()};
797
+ const whatsAppShareUrl = ${whatsAppShareUrl.toString()};
798
+ const isProbablyMobile = ${isProbablyMobile.toString()};
799
+ const copyText = ${copyTextToClipboard.toString()};
800
+ const flashTip = ${flashCopyTip.toString()};
937
801
  const DIGEST_STRUCTURES = ${digestStructuresJson};
938
802
  const el = (id) => document.getElementById(id);
939
803
 
@@ -1156,14 +1020,25 @@ ${THEME_TOKENS_CSS}
1156
1020
 
1157
1021
  let seedPayload = null;
1158
1022
  let seedFacts = 0;
1023
+ // One retry with a cache-busting query param: a CDN edge can serve a
1024
+ // corrupted or truncated precompressed response (a transient bad cache
1025
+ // entry, not a code defect — real bytes decompress fine, and the same URL
1026
+ // fetched moments later is clean), and JSON.parse throwing is the only
1027
+ // signal of that. The bust param forces a fresh fetch past that one entry.
1159
1028
  async function fetchSeed() {
1160
- try {
1161
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
1162
- seedPayload = JSON.parse(await blob.text());
1163
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
1164
- } catch (err) {
1165
- seedPayload = null;
1166
- console.warn("tmct chat: chat-seed.json unavailable — starting unseeded", err);
1029
+ for (let attempt = 1; attempt <= 2; attempt++) {
1030
+ try {
1031
+ const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
1032
+ const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
1033
+ seedPayload = JSON.parse(await blob.text());
1034
+ seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
1035
+ return;
1036
+ } catch (err) {
1037
+ if (attempt === 2) {
1038
+ seedPayload = null;
1039
+ console.warn("tmct chat: chat-seed.json unavailable — starting unseeded", err);
1040
+ }
1041
+ }
1167
1042
  }
1168
1043
  }
1169
1044
  const cloneSeed = () => {
@@ -1215,14 +1090,16 @@ ${THEME_TOKENS_CSS}
1215
1090
  let saveTimer = null;
1216
1091
  let restoredCount = 0;
1217
1092
  let siteVersion = "dev";
1093
+ let lastStatsTotal = null;
1218
1094
  window.tmctChatLastSave = null;
1095
+ window.tmct.lastSave = null;
1219
1096
 
1220
1097
  function scheduleSave() {
1221
1098
  if (!persist) return;
1222
1099
  clearTimeout(saveTimer);
1223
1100
  saveTimer = setTimeout(() => {
1224
1101
  saveTimer = null;
1225
- const session = window.tmctChatSession;
1102
+ const session = window.tmct.session;
1226
1103
  if (!session) return;
1227
1104
  const started = performance.now();
1228
1105
  let snapshot;
@@ -1232,7 +1109,10 @@ ${THEME_TOKENS_CSS}
1232
1109
  try { snapshot = JSON.parse(JSON.stringify(session.memoryDir.payload)); } catch { return; }
1233
1110
  }
1234
1111
  persist.save(snapshot).then((saved) => {
1235
- if (saved) window.tmctChatLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
1112
+ if (saved) {
1113
+ window.tmctChatLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
1114
+ window.tmct.lastSave = window.tmctChatLastSave;
1115
+ }
1236
1116
  });
1237
1117
  }, 500);
1238
1118
  }
@@ -1246,8 +1126,8 @@ ${THEME_TOKENS_CSS}
1246
1126
  // it would keep merging peers' facts into a store nothing reads any more.
1247
1127
  // Rejoining is a fresh invite, which is what a dropped node needs anyway.
1248
1128
  dropRoom();
1249
- window.tmctChatSession = await newSession();
1250
- const stats = await window.tmct.page.memoryStats(window.tmctChatSession.memoryDir);
1129
+ await newSession();
1130
+ const stats = await window.tmct.page.memoryStats(window.tmct.session.memoryDir);
1251
1131
  addSystemLine("forgot everything taught on this device \\u2014 back to the fresh seed (" + statsSummaryLine(stats, bandLabelFor) + ").");
1252
1132
  await renderStatsPanel(stats);
1253
1133
  }
@@ -1266,11 +1146,12 @@ ${THEME_TOKENS_CSS}
1266
1146
  * rather than blanking it. */
1267
1147
  async function renderStatsPanel(stats) {
1268
1148
  if (!stats) {
1269
- if (!window.tmctChatSession || !window.tmct.page.memoryStats) return;
1270
- try { stats = await window.tmct.page.memoryStats(window.tmctChatSession.memoryDir); }
1149
+ if (!window.tmct.session || !window.tmct.page.memoryStats) return;
1150
+ try { stats = await window.tmct.page.memoryStats(window.tmct.session.memoryDir); }
1271
1151
  catch { return; }
1272
1152
  }
1273
- factPillValueEl.textContent = Number(stats.total || 0).toLocaleString();
1153
+ lastStatsTotal = Number(stats.total || 0);
1154
+ factPillValueEl.textContent = lastStatsTotal.toLocaleString();
1274
1155
  renderStatsPanelInto(statsPanelEl, stats, {
1275
1156
  bandLabel: bandLabelFor,
1276
1157
  onForget: persist ? forgetEverything : null,
@@ -1348,9 +1229,9 @@ ${THEME_TOKENS_CSS}
1348
1229
  * own entry — the passage was still read, even where nothing new stuck. */
1349
1230
  async function noteResearchLearned(result) {
1350
1231
  if (result.research === undefined || !result.record || result.record.miss) return;
1351
- if (!window.tmct.page.researchedFactRows || !window.tmctChatSession) return;
1232
+ if (!window.tmct.page.researchedFactRows || !window.tmct.session) return;
1352
1233
  let rows;
1353
- try { rows = await window.tmct.page.researchedFactRows(window.tmctChatSession.memoryDir); }
1234
+ try { rows = await window.tmct.page.researchedFactRows(window.tmct.session.memoryDir); }
1354
1235
  catch { return; }
1355
1236
  const newFacts = [];
1356
1237
  for (const row of rows) {
@@ -1386,7 +1267,7 @@ ${THEME_TOKENS_CSS}
1386
1267
  : winkStatus === "unavailable"
1387
1268
  ? "wink-nlp unavailable — curated + fuzzy tiers only (still zero guesses, zero LLM)"
1388
1269
  : "wink-nlp: loading\\u2026";
1389
- const liveReference = window.tmctChatSession ? window.tmctChatSession.liveReference : liveReferenceForMode(checkedWikiMode());
1270
+ const liveReference = window.tmct.session ? window.tmct.session.liveReference : liveReferenceForMode(checkedWikiMode());
1390
1271
  const livePart = "live wikipedia: " + liveStatusWord(liveReference);
1391
1272
  const netPart = room
1392
1273
  ? " \\u00b7 world \\u201c" + worldName + "\\u201d: " + wireStateLabel(room.state).word
@@ -1399,7 +1280,7 @@ ${THEME_TOKENS_CSS}
1399
1280
  // "supplement" clears every radio (it has none of its own) rather than
1400
1281
  // leaving a stale mode checked.
1401
1282
  function mirrorWikiModeFromSession() {
1402
- const session = window.tmctChatSession;
1283
+ const session = window.tmct.session;
1403
1284
  if (!session || typeof session.liveReference === "undefined") return;
1404
1285
  const liveReference = session.liveReference;
1405
1286
  const mode = liveReference === "always" ? "always" : liveReference === true ? "miss" : liveReference === false ? "off" : null;
@@ -1410,8 +1291,8 @@ ${THEME_TOKENS_CSS}
1410
1291
  wikiModeFieldset.addEventListener("change", function () {
1411
1292
  const mode = checkedWikiMode();
1412
1293
  writeWikiMode(mode);
1413
- if (window.tmctChatSession && window.tmctChatSession.setLiveReference) {
1414
- window.tmctChatSession.setLiveReference(liveReferenceForMode(mode));
1294
+ if (window.tmct.session && window.tmct.session.setLiveReference) {
1295
+ window.tmct.session.setLiveReference(liveReferenceForMode(mode));
1415
1296
  }
1416
1297
  renderStatus();
1417
1298
  });
@@ -1420,15 +1301,15 @@ ${THEME_TOKENS_CSS}
1420
1301
  const n = Number(synthSliderEl.value);
1421
1302
  synthValueEl.textContent = String(n);
1422
1303
  writeSynthBudget(n);
1423
- if (window.tmctChatSession && window.tmctChatSession.setSynthesisBudget) {
1424
- window.tmctChatSession.setSynthesisBudget(n);
1304
+ if (window.tmct.session && window.tmct.session.setSynthesisBudget) {
1305
+ window.tmct.session.setSynthesisBudget(n);
1425
1306
  }
1426
1307
  });
1427
1308
 
1428
1309
  let busy = true;
1429
1310
  function setBusy(v) {
1430
1311
  busy = v;
1431
- const ready = Boolean(window.tmctChatSession);
1312
+ const ready = Boolean(window.tmct.session);
1432
1313
  inputEl.disabled = v || !ready;
1433
1314
  sendBtn.disabled = v || !ready;
1434
1315
  }
@@ -1439,7 +1320,7 @@ ${THEME_TOKENS_CSS}
1439
1320
  // bubble, settle, persist, stats. Resolves once the turn has settled (the
1440
1321
  // ticker awaits it before pacing the next step).
1441
1322
  async function submitLine(q) {
1442
- if (!q || busy || !window.tmctChatSession) return null;
1323
+ if (!q || busy || !window.tmct.session) return null;
1443
1324
  addUserBubble(q);
1444
1325
  transcript.push({ role: "you", text: q, chipTier: null, ts: Date.now() });
1445
1326
  const pendingRow = addPendingAssistantBubble();
@@ -1482,7 +1363,7 @@ ${THEME_TOKENS_CSS}
1482
1363
  composerForm.addEventListener("submit", (e) => {
1483
1364
  e.preventDefault();
1484
1365
  const q = inputEl.value.trim();
1485
- if (!q || busy || !window.tmctChatSession) return;
1366
+ if (!q || busy || !window.tmct.session) return;
1486
1367
  inputEl.value = "";
1487
1368
  const lowered = q.toLowerCase();
1488
1369
  if (lowered === "wave" || lowered === "/wave") {
@@ -1546,7 +1427,7 @@ ${THEME_TOKENS_CSS}
1546
1427
 
1547
1428
  researchGoBtn.addEventListener("click", () => {
1548
1429
  const topic = researchTopicEl.value.trim();
1549
- if (!topic || busy || !window.tmctChatSession) return;
1430
+ if (!topic || busy || !window.tmct.session) return;
1550
1431
  researchTopicEl.value = "";
1551
1432
  submitLine("research " + topic);
1552
1433
  });
@@ -1567,7 +1448,7 @@ ${THEME_TOKENS_CSS}
1567
1448
  // @media print stylesheet above to un-pin the message column so every
1568
1449
  // turn reaches paper.
1569
1450
  el("exportMd").addEventListener("click", () => {
1570
- const sessionId = (window.tmctChatSession && window.tmctChatSession.sessionId) || "";
1451
+ const sessionId = (window.tmct.session && window.tmct.session.sessionId) || "";
1571
1452
  const md = transcriptMarkdown(transcript, { version: siteVersion, sessionId: sessionId }, sessionLogHeaderMarkdown, sessionLogTurnMarkdown);
1572
1453
  const blob = new Blob([md], { type: "text/markdown" });
1573
1454
  const url = URL.createObjectURL(blob);
@@ -1586,7 +1467,7 @@ ${THEME_TOKENS_CSS}
1586
1467
  // subject/predicate/object/provenance shape the extract and memory-export
1587
1468
  // CLI paths emit), so what you taught leaves in the standard shape.
1588
1469
  el("exportFacts").addEventListener("click", async () => {
1589
- const session = window.tmctChatSession;
1470
+ const session = window.tmct.session;
1590
1471
  if (!session || !window.tmct.page.exportFactsJsonl) return;
1591
1472
  let jsonl;
1592
1473
  try {
@@ -1615,7 +1496,7 @@ ${THEME_TOKENS_CSS}
1615
1496
  el("ingestInput").addEventListener("change", async (e) => {
1616
1497
  const file = e.target.files && e.target.files[0];
1617
1498
  e.target.value = "";
1618
- const session = window.tmctChatSession;
1499
+ const session = window.tmct.session;
1619
1500
  if (!file || busy || !session || !window.tmct.page.splitSentences) return;
1620
1501
  let text;
1621
1502
  try {
@@ -1683,11 +1564,16 @@ ${THEME_TOKENS_CSS}
1683
1564
  const wireStateNoteEl = el("wireStateNote");
1684
1565
  const statePillEl = el("statePill");
1685
1566
  const statePillWordEl = el("statePillWord");
1686
- const sharePanelEl = el("sharePanel");
1687
1567
  const shareLinkEl = el("shareLink");
1568
+ const inviteSummaryEl = el("inviteSummary");
1569
+ const webShareBtnEl = el("webShareBtn");
1570
+ const waShareBtnEl = el("waShareBtn");
1571
+ const replyShareBtnEl = el("replyShareBtn");
1572
+ const replyWaBtnEl = el("replyWaBtn");
1573
+ const joinShareBtnEl = el("joinShareBtn");
1574
+ const joinWaBtnEl = el("joinWaBtn");
1688
1575
  const replyBoxEl = el("replyBox");
1689
1576
  const replyProblemEl = el("replyProblem");
1690
- const answerPanelEl = el("answerPanel");
1691
1577
  const replyOutEl = el("replyOut");
1692
1578
  const nodeListEl = el("nodeList");
1693
1579
  const nodeEmptyEl = el("nodeEmpty");
@@ -1713,18 +1599,36 @@ ${THEME_TOKENS_CSS}
1713
1599
 
1714
1600
  const P2P_ASSET = "./vendor/p2p.js";
1715
1601
  const NODE_NAME_KEY = "tmct.chat.nodeName";
1602
+ // Not the IndexedDB store beside it: that record is stamped with the site
1603
+ // version and the seed, and is dropped whenever either moves. A node id has
1604
+ // to outlive a deploy, because peers have already keyed this node's facts
1605
+ // on it.
1606
+ const NODE_ID_KEY = "tmct.chat.nodeId";
1716
1607
  const invite = inviteParamsFrom(window.location.search);
1717
1608
 
1718
1609
  let p2p = null;
1719
1610
  let p2pLoad = null;
1720
1611
  let room = null;
1721
1612
  let myPeerId = null;
1613
+ let myNodeId = "";
1722
1614
  let myDisplayName = "";
1723
1615
  let worldId = "";
1724
1616
  let worldName = "";
1725
1617
  let waveTimer = null;
1726
1618
  let nodeClockTimer = null;
1727
1619
  let channelCount = 0;
1620
+ let mintedBlob = "";
1621
+
1622
+ // Which seat this page occupies in the handshake — "idle" until something
1623
+ // happens, "sponsor" once an invite is minted here, "joiner" once an invite
1624
+ // is opened here. The overlay's CSS reads it to decide which calls to
1625
+ // action stand at each step; entry distinguishes an invite that arrived
1626
+ // as a link (the hero card owns the reply) from one pasted in as text
1627
+ // (step 4's own slot owns it).
1628
+ function setOverlayRole(role, entry) {
1629
+ netPanelEl.dataset.role = role;
1630
+ netPanelEl.dataset.entry = entry || "";
1631
+ }
1728
1632
 
1729
1633
  function loadP2p() {
1730
1634
  if (!p2pLoad) {
@@ -1747,6 +1651,13 @@ ${THEME_TOKENS_CSS}
1747
1651
  async function ensureIdentity() {
1748
1652
  const mod = await loadP2p();
1749
1653
  if (!myPeerId) myPeerId = mod.generatePeerId();
1654
+ if (!myNodeId) {
1655
+ try { myNodeId = localStorage.getItem(NODE_ID_KEY) || ""; } catch { myNodeId = ""; }
1656
+ if (!myNodeId) {
1657
+ myNodeId = mod.generateNodeId();
1658
+ try { localStorage.setItem(NODE_ID_KEY, myNodeId); } catch { /* private mode — this visit still has an id, it just won't outlive the tab */ }
1659
+ }
1660
+ }
1750
1661
  if (!myDisplayName) {
1751
1662
  myDisplayName = readStoredNodeName() || mod.generateDisplayName();
1752
1663
  nodeNameInputEl.value = myDisplayName;
@@ -1762,7 +1673,7 @@ ${THEME_TOKENS_CSS}
1762
1673
  // message crossing one can be seen. The room asks for transports through
1763
1674
  // this factory and never learns it is being watched.
1764
1675
  function instrumentedTransport() {
1765
- const transport = p2p.createTransport({ iceServers: [] });
1676
+ const transport = p2p.createTransport();
1766
1677
  const channel = "ch" + (++channelCount);
1767
1678
  transport.onMessage(function (message) { noteWire("in", message, channel); });
1768
1679
  transport.onOpen(function () { noteTape("note", "link", "channel open", channel); });
@@ -1791,12 +1702,13 @@ ${THEME_TOKENS_CSS}
1791
1702
  if (room) return room;
1792
1703
  const mod = await loadP2p();
1793
1704
  await ensureIdentity();
1794
- if (!window.tmctChatSession) await window.tmctChatReady;
1795
- if (!window.tmctChatSession) throw new Error("the chat engine didn't finish booting");
1705
+ if (!window.tmct.session) await window.tmctChatReady;
1706
+ if (!window.tmct.session) throw new Error("the chat engine didn't finish booting");
1796
1707
  room = mod.createP2pRoom({
1797
- memoryDir: window.tmctChatSession.memoryDir,
1708
+ memoryDir: window.tmct.session.memoryDir,
1798
1709
  myPeerId: myPeerId,
1799
1710
  myDisplayName: myDisplayName,
1711
+ myNodeId: myNodeId,
1800
1712
  worldId: worldId,
1801
1713
  worldName: worldName,
1802
1714
  transportFactory: instrumentedTransport,
@@ -1804,10 +1716,14 @@ ${THEME_TOKENS_CSS}
1804
1716
  });
1805
1717
  room.onStateChanged(function (state) {
1806
1718
  noteTape("note", state === "failed" ? "fault" : "state", "state " + state, "");
1807
- // The join card exists to produce one reply. Once the channel is open
1808
- // the reply has done its job, and a full-screen card over a live
1809
- // conversation is just something in the way.
1810
- if (state === "connected") joinCardEl.hidden = true;
1719
+ // The overlay exists to make one connection. The moment the channel is
1720
+ // open its work is done the lights come back up, and the pill in the
1721
+ // chrome is the way back in.
1722
+ if (state === "connected") {
1723
+ joinCardEl.hidden = true;
1724
+ closeNetPanel();
1725
+ flashTip(statePillEl, "connected \\u2014 you're sharing live");
1726
+ }
1811
1727
  renderWire();
1812
1728
  renderStatus();
1813
1729
  });
@@ -1841,6 +1757,8 @@ ${THEME_TOKENS_CSS}
1841
1757
  nodeClockTimer = null;
1842
1758
  shareLinkEl.value = "";
1843
1759
  replyOutEl.value = "";
1760
+ mintedBlob = "";
1761
+ setOverlayRole("idle", "");
1844
1762
  noteTape("note", "link", "world closed", worldName);
1845
1763
  renderWire();
1846
1764
  renderNodes();
@@ -1924,7 +1842,7 @@ ${THEME_TOKENS_CSS}
1924
1842
  }
1925
1843
  }
1926
1844
 
1927
- // ---- what a person sees: state, nodes, waves ----------------------------
1845
+ // ---- what a person sees: state, the ladder, nodes, waves ----------------
1928
1846
  function renderWire() {
1929
1847
  const label = wireStateLabel(room ? room.state : "idle");
1930
1848
  wireStateEl.dataset.tone = label.tone;
@@ -1933,10 +1851,29 @@ ${THEME_TOKENS_CSS}
1933
1851
  statePillEl.dataset.tone = label.tone;
1934
1852
  statePillWordEl.textContent = label.pill;
1935
1853
  statePillEl.title = label.note;
1936
- sharePanelEl.hidden = !shareLinkEl.value;
1937
- // Once the channel is open the reply has been used; leaving "send this
1938
- // back" on screen would be asking for something already done.
1939
- answerPanelEl.hidden = !replyOutEl.value || (room && room.state === "connected");
1854
+ netPanelEl.dataset.tone = label.tone;
1855
+ renderSteps();
1856
+ }
1857
+
1858
+ function renderSteps() {
1859
+ const states = shareStepStates({
1860
+ role: netPanelEl.dataset.role,
1861
+ state: room ? room.state : "idle",
1862
+ hasReply: Boolean(replyOutEl.value),
1863
+ });
1864
+ for (const step of states) {
1865
+ const item = el("step-" + step.key);
1866
+ if (item) item.dataset.status = step.status;
1867
+ }
1868
+ }
1869
+
1870
+ // The scene's two browser cards: this one wears the node's own name, the
1871
+ // far one wears the first peer's the moment there is one to name.
1872
+ function renderScene() {
1873
+ el("sceneYouName").textContent = myDisplayName || "you";
1874
+ const peers = room ? room.peers() : [];
1875
+ const first = peers.find(function (p) { return p.connected; }) || peers[0];
1876
+ el("sceneThemName").textContent = first ? room.displayNameFor(first.peerId) : "your friend";
1940
1877
  }
1941
1878
 
1942
1879
  function renderNodes() {
@@ -1944,6 +1881,7 @@ ${THEME_TOKENS_CSS}
1944
1881
  nodeCountEl.textContent = "";
1945
1882
  nodeListEl.textContent = "";
1946
1883
  nodeEmptyEl.hidden = false;
1884
+ renderScene();
1947
1885
  return;
1948
1886
  }
1949
1887
  const rows = nodeRowsFor({
@@ -1955,6 +1893,7 @@ ${THEME_TOKENS_CSS}
1955
1893
  latestTimestampOf: p2p.latestProvenanceTimestamp,
1956
1894
  });
1957
1895
  const nowMs = Date.now();
1896
+ const wavers = new Set(activeWaves(room.factRows(), nowMs).map(function (w) { return w.waver; }));
1958
1897
  nodeCountEl.textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
1959
1898
  nodeListEl.textContent = "";
1960
1899
  for (const entry of rows) {
@@ -1963,7 +1902,7 @@ ${THEME_TOKENS_CSS}
1963
1902
  item.dataset.self = String(entry.isSelf);
1964
1903
  item.dataset.away = String(!entry.connected);
1965
1904
  item.dataset.peer = entry.peerId;
1966
- item.dataset.waving = String(room.isWaving("peer:" + entry.peerId, nowMs));
1905
+ item.dataset.waving = String(wavers.has(peerTerm(entry.peerId)));
1967
1906
  const avatar = document.createElement("span");
1968
1907
  avatar.className = "node-avatar";
1969
1908
  avatar.textContent = nodeInitials(entry.name);
@@ -1971,132 +1910,262 @@ ${THEME_TOKENS_CSS}
1971
1910
  const dot = document.createElement("i");
1972
1911
  dot.className = "node-dot";
1973
1912
  avatar.appendChild(dot);
1974
- const name = document.createElement("span");
1975
- name.className = "node-name";
1976
- name.textContent = entry.name;
1977
1913
  const hand = document.createElement("span");
1978
1914
  hand.className = "node-hand";
1979
1915
  hand.textContent = "👋";
1916
+ avatar.appendChild(hand);
1917
+ const name = document.createElement("span");
1918
+ name.className = "node-name";
1919
+ name.textContent = entry.name;
1980
1920
  const when = document.createElement("span");
1981
1921
  when.className = "node-when";
1982
1922
  when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
1983
1923
  when.title = entry.lastActiveAt
1984
1924
  ? "last contributed a fact at " + new Date(entry.lastActiveAt).toLocaleTimeString()
1985
1925
  : "has contributed no fact yet";
1926
+ const fact = document.createElement("span");
1927
+ fact.className = "node-fact";
1928
+ if (entry.lastFact) {
1929
+ const line = entry.lastFact.subject + " " + String(entry.lastFact.predicate || "").replace(/^[a-z0-9_-]+:/i, "") + " " + entry.lastFact.object;
1930
+ fact.textContent = line;
1931
+ fact.title = "last shared: " + line;
1932
+ } else {
1933
+ fact.textContent = "nothing shared yet";
1934
+ }
1935
+ const waveOne = document.createElement("button");
1936
+ waveOne.type = "button";
1937
+ waveOne.className = "node-wave-btn";
1938
+ waveOne.textContent = "👋";
1939
+ waveOne.title = entry.isSelf ? "wave at everyone" : "wave at " + entry.name + " — they see it as a fact arriving";
1940
+ waveOne.setAttribute("aria-label", waveOne.title);
1941
+ waveOne.addEventListener("click", function () { waveAt(entry.isSelf ? null : entry.peerId, waveOne); });
1986
1942
  item.appendChild(avatar);
1987
1943
  item.appendChild(name);
1988
- item.appendChild(hand);
1989
1944
  item.appendChild(when);
1945
+ item.appendChild(waveOne);
1946
+ item.appendChild(fact);
1990
1947
  nodeListEl.appendChild(item);
1991
1948
  }
1992
1949
  nodeEmptyEl.hidden = rows.length > 1;
1950
+ renderScene();
1993
1951
  }
1994
1952
 
1995
1953
  // A wave is a fact like any other, so it reaches every page through the
1996
1954
  // same merge every other fact does; "currently waving" is a recency read
1997
1955
  // over that fact, never stored state. Nothing here is a second live-update
1998
1956
  // path — onFactsChanged is what wakes it, and the timer below only lets a
1999
- // wave stop rendering once its window has passed.
1957
+ // wave stop rendering once its window has passed. The fact's object names
1958
+ // the audience: the presence scope is everyone, a peer term is one node —
1959
+ // and a wave aimed at this node says so to its face.
2000
1960
  function renderWaves() {
2001
1961
  if (!room) return;
2002
1962
  const nowMs = Date.now();
2003
- const known = [{ peerId: myPeerId, name: myDisplayName }];
2004
- for (const peer of room.peers()) known.push({ peerId: peer.peerId, name: room.displayNameFor(peer.peerId) });
2005
- const waving = known.filter(function (entry) { return room.isWaving("peer:" + entry.peerId, nowMs); });
1963
+ const myTerm = peerTerm(myPeerId);
1964
+ const nameByTerm = new Map([[myTerm, myDisplayName]]);
1965
+ for (const peer of room.peers()) nameByTerm.set(peerTerm(peer.peerId), room.displayNameFor(peer.peerId));
1966
+ const waves = activeWaves(room.factRows(), nowMs).filter(function (w) { return nameByTerm.has(w.waver); });
2006
1967
  waveBurstEl.textContent = "";
2007
- for (const entry of waving) {
1968
+ for (const wave of waves) {
2008
1969
  const pill = document.createElement("div");
2009
- pill.className = "wave-pill";
1970
+ pill.className = "wave-pill" + (wave.target === myTerm ? " wave-pill--you" : "");
2010
1971
  const hand = document.createElement("span");
2011
1972
  hand.className = "hand";
2012
1973
  hand.textContent = "👋";
2013
1974
  const label = document.createElement("span");
2014
- label.textContent = entry.name + " waved";
1975
+ const who = nameByTerm.get(wave.waver);
1976
+ label.textContent = !wave.target ? who + " waved at everyone"
1977
+ : wave.target === myTerm ? who + " is waving at you"
1978
+ : nameByTerm.has(wave.target) ? who + " is waving at " + nameByTerm.get(wave.target)
1979
+ : who + " waved";
2015
1980
  pill.appendChild(hand);
2016
1981
  pill.appendChild(label);
2017
1982
  waveBurstEl.appendChild(pill);
2018
1983
  }
1984
+ const wavers = new Set(waves.map(function (w) { return w.waver; }));
2019
1985
  for (const item of nodeListEl.children) {
2020
- item.dataset.waving = String(waving.some(function (entry) { return entry.peerId === item.dataset.peer; }));
1986
+ item.dataset.waving = String(wavers.has(peerTerm(item.dataset.peer)));
2021
1987
  }
2022
1988
  clearTimeout(waveTimer);
2023
- if (waving.length) waveTimer = setTimeout(renderWaves, 1000);
1989
+ if (waves.length) waveTimer = setTimeout(renderWaves, 1000);
2024
1990
  }
2025
1991
 
2026
- async function waveNow() {
1992
+ async function waveAt(targetPeerId, anchor) {
2027
1993
  try {
2028
1994
  const active = await ensureRoom();
2029
- await active.wave("peer:" + myPeerId, null);
2030
- noteTape("note", "facts", "waved", myDisplayName);
1995
+ await active.wave("peer:" + myPeerId, targetPeerId ? "peer:" + targetPeerId : null);
1996
+ const audience = targetPeerId ? (active.displayNameFor(targetPeerId) || "one node") : "everyone";
1997
+ noteTape("note", "facts", "waved", audience);
2031
1998
  renderNodes();
2032
1999
  renderWaves();
2000
+ if (anchor) flashTip(anchor, "you waved at " + audience);
2033
2001
  } catch (err) {
2034
2002
  addSystemLine("couldn't wave (" + (err && err.message ? err.message : err) + ").");
2035
2003
  }
2036
2004
  }
2005
+ function waveNow() { return waveAt(null, null); }
2037
2006
 
2038
- // ---- copying: one tap, no menu, a tooltip that says it happened ---------
2039
- async function copyText(text) {
2040
- try {
2041
- if (navigator.clipboard && navigator.clipboard.writeText) {
2042
- await navigator.clipboard.writeText(text);
2043
- return true;
2044
- }
2045
- } catch { /* fall through — the box on the page still holds the text */ }
2046
- try {
2047
- const holder = document.createElement("textarea");
2048
- holder.value = text;
2049
- holder.setAttribute("readonly", "");
2050
- holder.style.position = "fixed";
2051
- holder.style.opacity = "0";
2052
- document.body.appendChild(holder);
2053
- holder.select();
2054
- const copied = document.execCommand("copy");
2055
- holder.remove();
2056
- return copied;
2057
- } catch {
2058
- return false;
2059
- }
2007
+ // ---- the overlay itself: the lights go down, the handshake takes the room
2008
+ function openNetPanel() {
2009
+ netPanelEl.hidden = false;
2010
+ const card = netPanelEl.querySelector(".so-card");
2011
+ if (card) card.focus({ preventScroll: true });
2060
2012
  }
2061
-
2062
- function flashTip(anchor, text) {
2063
- const tip = document.createElement("div");
2064
- tip.className = "copyTip";
2065
- tip.setAttribute("role", "status");
2066
- tip.textContent = text;
2067
- document.body.appendChild(tip);
2068
- const box = anchor.getBoundingClientRect();
2069
- const width = tip.offsetWidth;
2070
- tip.style.top = (box.bottom + 6) + "px";
2071
- tip.style.left = Math.max(6, Math.min(window.innerWidth - width - 6, box.left + box.width / 2 - width / 2)) + "px";
2072
- setTimeout(function () { tip.remove(); }, 2600);
2013
+ function closeNetPanel() {
2014
+ netPanelEl.hidden = true;
2073
2015
  }
2074
-
2075
- const openNetPanel = () => document.body.classList.add("net-open");
2076
- const closeNetPanel = () => document.body.classList.remove("net-open");
2077
2016
  el("netPanelClose").addEventListener("click", closeNetPanel);
2017
+ netPanelEl.addEventListener("click", function (e) {
2018
+ if (e.target === netPanelEl) closeNetPanel();
2019
+ });
2020
+ document.addEventListener("keydown", function (e) {
2021
+ if (e.key === "Escape" && !netPanelEl.hidden) closeNetPanel();
2022
+ });
2078
2023
  statePillEl.addEventListener("click", function () {
2079
- if (document.body.classList.contains("net-open")) closeNetPanel();
2080
- else openNetPanel();
2024
+ if (netPanelEl.hidden) openNetPanel();
2025
+ else closeNetPanel();
2081
2026
  });
2027
+ if (isProbablyMobile()) netPanelEl.classList.add("so-mobile");
2028
+ // The blobs are made to be copied whole — a click selects everything, so a
2029
+ // manual copy can never take half a code.
2030
+ for (const boxId of ["shareLink", "replyOut", "joinReply"]) {
2031
+ el(boxId).addEventListener("focus", function (e) { e.target.select(); });
2032
+ }
2082
2033
 
2083
- shareBtn.addEventListener("click", async function () {
2084
- shareBtn.disabled = true;
2034
+ function renderInviteShare() {
2035
+ const factsPart = lastStatsTotal === null ? "" : " \\u00b7 " + lastStatsTotal.toLocaleString() + " facts travel when they join";
2036
+ inviteSummaryEl.textContent = "invites one person into \\u201c" + worldName + "\\u201d" + factsPart;
2037
+ const message = shareMessageFor({ worldName: worldName, link: shareLinkEl.value, thing: "graph" });
2038
+ waShareBtnEl.href = whatsAppShareUrl(message);
2039
+ webShareBtnEl.hidden = !navigator.share;
2040
+ }
2041
+
2042
+ function renderReplyShare() {
2043
+ const message = replyMessageFor({ worldName: worldName, blob: replyOutEl.value });
2044
+ replyWaBtnEl.href = whatsAppShareUrl(message);
2045
+ joinWaBtnEl.href = whatsAppShareUrl(message);
2046
+ replyShareBtnEl.hidden = !navigator.share;
2047
+ joinShareBtnEl.hidden = !navigator.share;
2048
+ }
2049
+
2050
+ async function mintInvite(anchor) {
2085
2051
  try {
2086
2052
  const active = await ensureRoom();
2087
2053
  const minted = await active.startSharing();
2054
+ mintedBlob = minted.blob;
2088
2055
  shareLinkEl.value = inviteLinkFor(window.location.href, { blob: minted.blob, world: worldId, worldName: worldName });
2056
+ setOverlayRole("sponsor", "");
2057
+ renderInviteShare();
2089
2058
  replyProblemEl.hidden = true;
2090
2059
  renderWire();
2091
2060
  openNetPanel();
2092
2061
  await copyText(shareLinkEl.value);
2093
- flashTip(shareBtn, "link copied — send it to one person");
2062
+ flashTip(anchor, "link copied — send it to one person");
2094
2063
  } catch (err) {
2095
2064
  addSystemLine("couldn't create an invite (" + (err && err.message ? err.message : err) + ").");
2065
+ }
2066
+ }
2067
+
2068
+ shareBtn.addEventListener("click", async function () {
2069
+ shareBtn.disabled = true;
2070
+ try {
2071
+ await mintInvite(shareBtn);
2096
2072
  } finally {
2097
2073
  shareBtn.disabled = false;
2098
2074
  }
2099
2075
  });
2076
+ for (const mintId of ["mintInviteBtn", "remintBtn"]) {
2077
+ el(mintId).addEventListener("click", async function () {
2078
+ const btn = el(mintId);
2079
+ btn.disabled = true;
2080
+ try {
2081
+ await mintInvite(btn);
2082
+ } finally {
2083
+ btn.disabled = false;
2084
+ }
2085
+ });
2086
+ }
2087
+
2088
+ el("copyLinkBtn").addEventListener("click", async function () {
2089
+ await copyText(shareLinkEl.value);
2090
+ flashTip(el("copyLinkBtn"), "link copied — send it to one person");
2091
+ });
2092
+ el("copyCodeBtn").addEventListener("click", async function () {
2093
+ await copyText(mintedBlob);
2094
+ flashTip(el("copyCodeBtn"), "code copied — they paste it under step 3 on their page");
2095
+ });
2096
+ webShareBtnEl.addEventListener("click", function () {
2097
+ navigator.share({
2098
+ title: "join \\u201c" + worldName + "\\u201d",
2099
+ text: shareMessageFor({ worldName: worldName, link: shareLinkEl.value, thing: "graph" }),
2100
+ }).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
2101
+ });
2102
+ function shareReplyViaSheet() {
2103
+ navigator.share({
2104
+ title: "my reply to \\u201c" + worldName + "\\u201d",
2105
+ text: replyMessageFor({ worldName: worldName, blob: replyOutEl.value }),
2106
+ }).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
2107
+ }
2108
+ replyShareBtnEl.addEventListener("click", shareReplyViaSheet);
2109
+ joinShareBtnEl.addEventListener("click", shareReplyViaSheet);
2110
+
2111
+ // The other way in: an invite that arrived as text rather than as a link.
2112
+ // The same paste box reads a whole link or a bare code — telling them
2113
+ // apart by looking is cheaper than asking anyone to.
2114
+ el("inviteBtn").addEventListener("click", async function () {
2115
+ const problem = el("inviteProblem");
2116
+ const pasted = el("inviteBox").value.trim();
2117
+ if (!pasted) {
2118
+ problem.textContent = "paste the invite you were sent, then try again";
2119
+ problem.hidden = false;
2120
+ return;
2121
+ }
2122
+ if (room) {
2123
+ problem.textContent = "this graph is already shared. reload the page first, then open the invite.";
2124
+ problem.hidden = false;
2125
+ return;
2126
+ }
2127
+ const blob = offerBlobIn(pasted);
2128
+ const mod = await loadP2p();
2129
+ const decoded = mod.decodeInviteBlob(blob);
2130
+ if (decoded.error || decoded.value.kind !== "offer") {
2131
+ problem.textContent = decoded.error
2132
+ ? "this invite looks cut short — ask for it to be sent again"
2133
+ : "that's a reply, not an invite — it belongs under step 5, on the page that sent the invite";
2134
+ problem.hidden = false;
2135
+ return;
2136
+ }
2137
+ // The world id has to be the invite's before the room is opened: a room
2138
+ // that minted its own would refuse the invite as belonging elsewhere.
2139
+ await ensureIdentity();
2140
+ worldId = decoded.value.world || worldId;
2141
+ if (decoded.value.worldName) {
2142
+ worldName = decoded.value.worldName;
2143
+ worldNameInputEl.value = worldName;
2144
+ }
2145
+ try {
2146
+ const active = await ensureRoom();
2147
+ const outcome = await active.acceptInvite(blob);
2148
+ if (outcome && outcome.error) {
2149
+ problem.textContent = outcome.message;
2150
+ problem.hidden = false;
2151
+ noteTape("note", "fault", "invite rejected", outcome.error);
2152
+ return;
2153
+ }
2154
+ problem.hidden = true;
2155
+ el("inviteBox").value = "";
2156
+ replyOutEl.value = outcome.blob;
2157
+ setOverlayRole("joiner", "paste");
2158
+ renderReplyShare();
2159
+ renderWire();
2160
+ await copyText(outcome.blob);
2161
+ flashTip(copyReplyBtn, "reply copied — send it back the same way");
2162
+ } catch (err) {
2163
+ problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2164
+ problem.hidden = false;
2165
+ }
2166
+ });
2167
+
2168
+ el("waveAllBtn").addEventListener("click", function () { waveAt(null, el("waveAllBtn")); });
2100
2169
 
2101
2170
  // The inviter's page has exactly one paste target, so there is no wrong box
2102
2171
  // to choose. A rejected paste keeps its text so the copy can be fixed.
@@ -2148,10 +2217,13 @@ ${THEME_TOKENS_CSS}
2148
2217
 
2149
2218
  waveBtn.addEventListener("click", waveNow);
2150
2219
 
2151
- // ---- joining: a card, one button, nothing running until it is pressed ---
2220
+ // ---- joining: the hero card, one button, nothing running until pressed --
2152
2221
  async function prepareJoinCard() {
2222
+ setOverlayRole("joiner", "link");
2153
2223
  joinCardEl.hidden = false;
2154
2224
  joinWorldEl.textContent = invite.worldName || "a shared graph";
2225
+ renderSteps();
2226
+ openNetPanel();
2155
2227
  joinBtn.focus();
2156
2228
  const mod = await loadP2p();
2157
2229
  const decoded = mod.decodeInviteBlob(invite.offer);
@@ -2191,6 +2263,7 @@ ${THEME_TOKENS_CSS}
2191
2263
  joinReplyWrapEl.hidden = false;
2192
2264
  joinBtn.textContent = "reply created";
2193
2265
  joinDismissBtn.textContent = "close this and start talking";
2266
+ renderReplyShare();
2194
2267
  renderWire();
2195
2268
  await copyText(outcome.blob);
2196
2269
  flashTip(joinCopyBtn, "reply copied — send it back the same way");
@@ -2205,10 +2278,7 @@ ${THEME_TOKENS_CSS}
2205
2278
  await copyText(joinReplyEl.value);
2206
2279
  flashTip(joinCopyBtn, "reply copied");
2207
2280
  });
2208
- joinDismissBtn.addEventListener("click", function () {
2209
- joinCardEl.hidden = true;
2210
- if (replyOutEl.value) openNetPanel();
2211
- });
2281
+ joinDismissBtn.addEventListener("click", closeNetPanel);
2212
2282
 
2213
2283
  renderWire();
2214
2284
  renderTapeMeter();
@@ -2239,7 +2309,7 @@ ${THEME_TOKENS_CSS}
2239
2309
  synthSliderEl.value = String(readSynthBudget());
2240
2310
  synthValueEl.textContent = synthSliderEl.value;
2241
2311
  if (savedRecord && savedRecord.payload) {
2242
- window.tmctChatSession = await window.tmct.open({
2312
+ await window.tmct.open({
2243
2313
  seedPayload: savedRecord.payload,
2244
2314
  vocabSeeded: true,
2245
2315
  liveReference: liveReferenceForMode(initialMode),
@@ -2248,9 +2318,9 @@ ${THEME_TOKENS_CSS}
2248
2318
  digestStructures: DIGEST_STRUCTURES,
2249
2319
  });
2250
2320
  } else {
2251
- window.tmctChatSession = await newSession();
2321
+ await newSession();
2252
2322
  }
2253
- const stats = await window.tmct.page.memoryStats(window.tmctChatSession.memoryDir);
2323
+ const stats = await window.tmct.page.memoryStats(window.tmct.session.memoryDir);
2254
2324
  if (savedRecord) restoredCount = stats.taught.length;
2255
2325
  const restoredNote = savedRecord
2256
2326
  ? " Restored " + restoredCount + " taught fact" + (restoredCount === 1 ? "" : "s")
@@ -2266,7 +2336,7 @@ ${THEME_TOKENS_CSS}
2266
2336
  // was never open to read.
2267
2337
  if (window.tmct.page.researchedFactRows) {
2268
2338
  try {
2269
- const existingResearch = await window.tmct.page.researchedFactRows(window.tmctChatSession.memoryDir);
2339
+ const existingResearch = await window.tmct.page.researchedFactRows(window.tmct.session.memoryDir);
2270
2340
  for (const row of existingResearch) researchedFactKeysSeen.add(row.subject + "|" + row.predicate + "|" + row.object);
2271
2341
  } catch { /* best-effort seeding only — a fresh session has none to seed */ }
2272
2342
  }
@@ -2285,6 +2355,7 @@ ${THEME_TOKENS_CSS}
2285
2355
  console.error("tmct chat failed to boot", err);
2286
2356
  statusEl.textContent = "the chat failed to start (" + (err && err.message ? err.message : err) + ")";
2287
2357
  });
2358
+ window.tmct.ready = window.tmctChatReady;
2288
2359
  })();
2289
2360
  </script>
2290
2361
  </body>