@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
@@ -86,8 +86,13 @@ import {
86
86
  } from "./adventure-viz.mjs";
87
87
  import { renderMudEditorText } from "./mud-editor.mjs";
88
88
  import {
89
- wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom,
89
+ wireStateLabel, nodeRowsFor, nodeInitials, inviteLinkFor, inviteParamsFrom, tapeRowFor, tapeClock,
90
90
  } from "./chat-page-viz.mjs";
91
+ import {
92
+ SHARE_OVERLAY_CSS, shareOverlayHtml, shareStepStates, activeWaves, offerBlobIn, peerTerm,
93
+ shareMessageFor, replyMessageFor, whatsAppShareUrl, isProbablyMobile, copyTextToClipboard, flashCopyTip,
94
+ } from "./share-overlay-viz.mjs";
95
+ export { offerBlobIn, peerTerm };
91
96
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
92
97
  import { fnv1a32 } from "../domain/hash.mjs";
93
98
  import { predatorSubjects } from "../domain/mud-facts.mjs";
@@ -123,6 +128,22 @@ const MUD_NOTE_LINES = [
123
128
  "Share the burrow and a second digger joins from another machine, browser to browser, with no server in the middle. Each animal is claimed by one node, one hand on one lemming, and everything either of you digs, eats or learns lands in the same graph.",
124
129
  ];
125
130
 
131
+ // The shared sharing overlay, in this page's own words — same markup, same
132
+ // ids, same ladder as chat.html's; only the vocabulary is a burrow's.
133
+ const MUD_SHARE_COPY = {
134
+ thing: "burrow",
135
+ lede: "This burrow lives in your browser and nowhere else. Sharing it opens a direct line (WebRTC) to one other browser: you send an invite, they send a reply, you paste it in. No account, no server, no copy anywhere in between.",
136
+ worldLabel: "the burrow you are sharing",
137
+ nodeLabel: "your node name",
138
+ namesNote: "both names are facts in the burrow, not settings.",
139
+ invitedEyebrow: "you have been invited to dig in",
140
+ joinBody: "Nothing runs until you press the button. It makes one reply for you to send back the way the invite reached you.",
141
+ dismiss: "dig on your own instead",
142
+ rosterTitle: "who digs this burrow",
143
+ nodeEmpty: "nobody else is digging here yet.",
144
+ idleNote: "this browser holds the only copy of this burrow.",
145
+ };
146
+
126
147
  /** A character id's species — "mole-1" -> "mole", "groundhog-1" ->
127
148
  * "groundhog" — mirroring spider-fly-viz.mjs's own classOfAgentId. Self-
128
149
  * contained, `.toString()`-splice safe. */
@@ -242,15 +263,6 @@ export function provenanceStamps(provenance) {
242
263
  return stamps;
243
264
  }
244
265
 
245
- /** A peer id as the fact store actually holds it. `appendFacts` normalizes a
246
- * term by stripping its CURIE prefix and lowercasing, so the `peer:<id>` a
247
- * claim is written with reads back as the bare id — a comparison against a
248
- * raw peer id has to normalize the same way or it never matches. Pure,
249
- * self-contained. */
250
- export function peerTerm(peerId) {
251
- return String(peerId || "").replace(/^[a-z][\w.-]*:/i, "").toLowerCase();
252
- }
253
-
254
266
  /** Which peer plays `character`, or null when nobody has claimed it. Claims are
255
267
  * add-only and never retracted, so two peers claiming the same animal both
256
268
  * write and every page settles it the same way: the OLDEST claim wins, with
@@ -290,19 +302,6 @@ export function wavingCharacters(rows, nowMs, windowMs = 8000) {
290
302
  return waving;
291
303
  }
292
304
 
293
- /** The offer blob inside whatever somebody actually pasted: the `offer`
294
- * parameter of a whole invite link, or the bare blob when that is what
295
- * arrived. Both are what people really paste, and telling them apart by
296
- * looking is cheaper than asking them to. Pure, self-contained. */
297
- export function offerBlobIn(pasted) {
298
- const text = String(pasted || "").trim();
299
- const marker = text.indexOf("offer=");
300
- if (marker === -1) return text;
301
- const rest = text.slice(marker + "offer=".length);
302
- const end = rest.search(/[&#\s]/);
303
- return decodeURIComponent(end === -1 ? rest : rest.slice(0, end));
304
- }
305
-
306
305
  /** The name a peer chose for its node, from its latest `mgx:nodeName` fact,
307
306
  * falling back to a shortened peer id. A label never waits for a name to
308
307
  * arrive. Pure, self-contained. */
@@ -386,11 +385,23 @@ ${engineBundleJs ? "" : `<link rel="icon" href="./favicon.svg" type="image/svg+x
386
385
  <style>
387
386
  ${THEME_TOKENS_CSS}
388
387
  ${MUD_STYLE}
388
+ ${SHARE_OVERLAY_CSS}
389
+ ${MUD_SHARE_SKIN}
389
390
  </style>
390
391
  </head>
391
392
  <body>
392
393
  <main>
393
- <h1 class="eyebrow">tmct &middot; mud</h1>
394
+ <header class="mud-topbar">
395
+ <h1 class="eyebrow">tmct &middot; mud</h1>
396
+ <div class="mud-topbar-actions">
397
+ <button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
398
+ title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
399
+ <button type="button" id="shareBtn">share</button>
400
+ <button type="button" id="joinOpenBtn">join</button>
401
+ <a class="mud-topbar-help" href="./help.html#sharing" target="_blank" rel="noopener"
402
+ title="how sharing works, in a new tab" aria-label="how sharing works, opens in a new tab">?</a>
403
+ </div>
404
+ </header>
394
405
  <div class="deck-row">
395
406
  <section class="deck" aria-label="simulation controls">
396
407
  <div class="deck-controls">
@@ -400,10 +411,6 @@ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" class="deck-sel
400
411
  ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
401
412
  </select>` : ""}
402
413
  <button type="button" id="editModeBtn" aria-pressed="false">edit</button>
403
- <button type="button" id="shareBtn">share</button>
404
- <button type="button" id="joinOpenBtn">join</button>
405
- <button type="button" class="state-pill" id="statePill" aria-expanded="false" aria-controls="netPanel"
406
- title="whether this burrow is shared with anyone"><i class="state-dot"></i><span id="statePillWord">not shared</span></button>
407
414
  <button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
408
415
  <span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
409
416
  </div>
@@ -448,61 +455,7 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
448
455
  <div class="world-map-key" id="worldMapKey"></div>
449
456
  </section>
450
457
  </div>
451
- <section class="net-panel" id="netPanel" aria-label="the shared burrow" hidden>
452
- <div class="net-head">
453
- <span class="net-title">the shared burrow</span>
454
- <span class="net-state" id="wireState" data-tone="idle"><b id="wireStateWord">not shared</b><span id="wireStateNote">this browser holds the only copy of this burrow.</span></span>
455
- <button type="button" class="net-close" id="netPanelClose" aria-label="close">&times;</button>
456
- </div>
457
- <div class="net-fields">
458
- <label class="net-field">this node
459
- <input type="text" id="nodeNameInput" spellcheck="false" aria-label="the name this node goes by">
460
- </label>
461
- <label class="net-field">this burrow
462
- <input type="text" id="worldNameInput" spellcheck="false" aria-label="the name of the shared burrow">
463
- </label>
464
- </div>
465
- <div class="net-block" id="sharePanel" hidden>
466
- <p class="net-hint">Each link invites one person. Share again for the next.</p>
467
- <textarea id="shareLink" readonly rows="2" aria-label="the invite link"></textarea>
468
- </div>
469
- <div class="net-block" id="invitePanel" hidden>
470
- <label class="net-label" for="inviteBox">paste the invite you were sent</label>
471
- <textarea id="inviteBox" rows="2" spellcheck="false"></textarea>
472
- <button type="button" id="inviteBtn">make my reply</button>
473
- <p class="net-problem" id="inviteProblem" role="alert" hidden></p>
474
- </div>
475
- <div class="net-block" id="replyPanel">
476
- <label class="net-label" for="replyBox">paste their reply here</label>
477
- <textarea id="replyBox" rows="2" spellcheck="false"></textarea>
478
- <button type="button" id="replyBtn">connect</button>
479
- <p class="net-problem" id="replyProblem" role="alert" hidden></p>
480
- </div>
481
- <div class="net-block" id="answerPanel" hidden>
482
- <label class="net-label" for="replyOut">send this back the same way the invite reached you</label>
483
- <textarea id="replyOut" readonly rows="2"></textarea>
484
- <button type="button" id="copyReplyBtn">copy the reply</button>
485
- </div>
486
- <div class="net-block">
487
- <div class="net-label">nodes <span class="mono" id="nodeCount"></span></div>
488
- <ul class="node-list" id="nodeList"></ul>
489
- <p class="net-hint" id="nodeEmpty">Nobody else is digging here yet.</p>
490
- </div>
491
- </section>
492
- <div class="join-card" id="joinCard" hidden>
493
- <div class="join-inner" role="dialog" aria-label="an invitation to a shared burrow">
494
- <p class="join-eyebrow" id="joinEyebrow">you have been invited to dig in</p>
495
- <h2 class="join-world" id="joinWorld">a shared burrow</h2>
496
- <p class="join-body" id="joinBody">Nothing runs until you press the button. It makes one reply for you to send back the way the invite reached you.</p>
497
- <button type="button" id="joinBtn">create my reply</button>
498
- <p class="net-problem" id="joinProblem" role="alert" hidden></p>
499
- <div class="join-reply" id="joinReplyWrap" hidden>
500
- <textarea id="joinReply" readonly rows="2" aria-label="your reply"></textarea>
501
- <button type="button" id="joinCopyBtn">copy the reply</button>
502
- </div>
503
- <button type="button" class="join-dismiss" id="joinDismiss">dig on your own instead</button>
504
- </div>
505
- </div>
458
+ ${shareOverlayHtml({ copy: MUD_SHARE_COPY, withTape: true })}
506
459
  <div class="mud-stage" id="mudStage" data-panes="${openingCount}">
507
460
  ${paneHtml}
508
461
  </div>
@@ -633,6 +586,22 @@ const MUD_STYLE = `
633
586
  .mono { font-family: ${MONO_STACK}; }
634
587
  main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
635
588
  .eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--parchment); opacity: .9; margin: 0 0 .8rem; }
589
+
590
+ /* ---- the header: brand on the left, the sharing chrome on the right ----
591
+ The same arrangement chat.html's topbar holds — sharing is page chrome,
592
+ not a simulation control, so it lives above the deck rather than in it. */
593
+ .mud-topbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: 0 0 .8rem; }
594
+ .mud-topbar .eyebrow { margin: 0; }
595
+ .mud-topbar-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
596
+ .mud-topbar-actions button:not(.state-pill), .mud-topbar-help {
597
+ background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
598
+ padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0, 0, 0, .12); text-decoration: none; font-size: .82rem;
599
+ }
600
+ .mud-topbar-actions button:not(.state-pill):hover, .mud-topbar-help:hover { background: var(--lem-face-hi); border-color: var(--burrow-glow); }
601
+ .mud-topbar-help { padding: .32rem .55rem; line-height: 1.2; }
602
+ /* the pill wears the deck's lit-readout idiom: this is the one word that
603
+ says whether the burrow is shared, and it reads like an instrument. */
604
+ .mud-topbar-actions .state-pill { background: var(--lem-readout-bg); color: var(--lem-readout); border: 1px solid #000; border-radius: 2px; padding: .3rem .6rem; cursor: pointer; }
636
605
  h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; text-transform: capitalize; }
637
606
  h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--soil-mid); }
638
607
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
@@ -1039,16 +1008,15 @@ const MUD_STYLE = `
1039
1008
  }
1040
1009
  .deck-slider, .deck h3 { color: var(--lem-chalk); }
1041
1010
  .deck-slider input[type="range"] { accent-color: var(--burrow-glow); }
1042
- .deck button, .pane-controls button, .net-panel button, .join-inner button {
1011
+ .deck button, .pane-controls button {
1043
1012
  background: var(--lem-face); color: var(--mud-ink); border: 1px solid var(--soil-mid); border-radius: 3px;
1044
1013
  padding: .32rem .7rem; box-shadow: 0 1px 0 rgba(0,0,0,.12);
1045
1014
  }
1046
- .deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled),
1047
- .net-panel button:hover:not(:disabled), .join-inner button:hover:not(:disabled) {
1015
+ .deck button:hover:not(:disabled), .pane-controls button:hover:not(:disabled) {
1048
1016
  background: var(--lem-face-hi); border-color: var(--burrow-glow);
1049
1017
  }
1050
1018
  .deck button:active:not(:disabled), .pane-controls button:active:not(:disabled),
1051
- .net-panel button:active:not(:disabled), .join-inner button:active:not(:disabled) {
1019
+ .mud-topbar-actions button:active:not(:disabled) {
1052
1020
  background: var(--lem-face-lo); box-shadow: inset 0 1px 2px rgba(0,0,0,.25);
1053
1021
  }
1054
1022
  .deck-play { background: var(--lem-go) !important; color: var(--parchment) !important; border-color: var(--lem-go) !important; }
@@ -1069,69 +1037,6 @@ const MUD_STYLE = `
1069
1037
  .state-pill[data-tone="live"] .state-dot { background: var(--lem-go); }
1070
1038
  .state-pill[data-tone="failed"] .state-dot { background: var(--lem-alert); }
1071
1039
 
1072
- .net-panel {
1073
- background: var(--soil-deep); color: var(--parchment);
1074
- border: 1px solid var(--burrow-glow); border-radius: 3px;
1075
- box-shadow: 0 2px 0 rgba(0,0,0,.3);
1076
- padding: .7rem .8rem; margin-bottom: 1rem;
1077
- display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .6rem .9rem; align-items: start;
1078
- }
1079
- .net-head { grid-column: 1 / -1; display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
1080
- .net-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
1081
- .net-state { font-size: .74rem; display: flex; gap: .4rem; flex-wrap: wrap; align-items: baseline; }
1082
- .net-state b { font-family: ${MONO_STACK}; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; }
1083
- .net-state[data-tone="live"] b { color: var(--lem-go-hi); }
1084
- .net-state[data-tone="failed"] b { color: #FF8C70; }
1085
- .net-state[data-tone="waiting"] b, .net-state[data-tone="working"] b { color: #F0C070; }
1086
- .net-state span { opacity: .8; }
1087
- .net-close { margin-left: auto; }
1088
- .net-fields, .net-block { display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
1089
- .net-fields { grid-column: 1 / -1; flex-direction: row; flex-wrap: wrap; gap: .8rem; }
1090
- .net-field, .net-label { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
1091
- .net-field { display: flex; flex-direction: column; gap: .2rem; }
1092
- .net-panel input[type="text"], .net-panel textarea {
1093
- font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout); background: var(--lem-readout-bg);
1094
- border: 1px solid #000; border-radius: 2px; padding: .26rem .4rem; width: 100%; box-sizing: border-box;
1095
- resize: vertical; overflow-wrap: anywhere;
1096
- }
1097
- .net-panel input[readonly], .net-panel textarea[readonly] { opacity: .85; }
1098
- .net-panel button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .66rem;
1099
- text-transform: uppercase; letter-spacing: .08em; }
1100
- .net-hint { margin: 0; font-size: .7rem; opacity: .75; }
1101
- .net-problem { margin: 0; font-size: .72rem; color: #FFB4A0; }
1102
- .node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .22rem; }
1103
- .node-row { display: flex; align-items: center; gap: .4rem; font-size: .74rem; }
1104
- .node-row[data-away="true"] { opacity: .55; }
1105
- .node-avatar { width: 1.25rem; height: 1.25rem; border-radius: 50%; background: var(--lem-face);
1106
- color: #16162A; font-family: ${MONO_STACK}; font-size: .52rem; display: inline-flex;
1107
- align-items: center; justify-content: center; flex: 0 0 auto; }
1108
- .node-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1109
- .node-self { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .08em; opacity: .6; }
1110
- .node-hand { opacity: 0; }
1111
- .node-row[data-waving="true"] .node-hand { opacity: 1; animation: hand-wave .8s ease-in-out infinite; }
1112
- .node-when { margin-left: auto; font-family: ${MONO_STACK}; font-size: .58rem; opacity: .6; }
1113
-
1114
- .join-card { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center;
1115
- background: rgba(8,8,42,.86); padding: 1rem; }
1116
- /* Setting display at all outranks the browser's own [hidden] rule, so every
1117
- block here that is hidden by attribute has to say so a second time. */
1118
- .join-card[hidden], .net-panel[hidden], .net-block[hidden], .join-reply[hidden] { display: none; }
1119
- .join-inner { background: var(--soil-deep);
1120
- color: var(--parchment); border: 1px solid var(--burrow-glow); border-radius: 3px;
1121
- box-shadow: 0 10px 26px rgba(0,0,0,.5);
1122
- padding: 1.1rem 1.2rem; max-width: 32rem; width: 100%; display: flex; flex-direction: column; gap: .55rem; }
1123
- .join-eyebrow { margin: 0; font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .8; }
1124
- .join-world { font-family: ${DISPLAY_STACK}; font-size: 1.4rem; margin: 0; }
1125
- .join-body { margin: 0; font-size: .8rem; opacity: .9; max-width: 52ch; }
1126
- .join-reply { display: flex; flex-direction: column; gap: .3rem; }
1127
- .join-inner textarea { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--lem-readout);
1128
- background: var(--lem-readout-bg); border: 1px solid #000; border-radius: 2px; padding: .3rem .4rem;
1129
- width: 100%; box-sizing: border-box; overflow-wrap: anywhere; }
1130
- .join-inner button { align-self: flex-start; font-family: ${MONO_STACK}; font-size: .68rem;
1131
- text-transform: uppercase; letter-spacing: .08em; }
1132
- .join-dismiss { background: transparent !important; box-shadow: none !important; color: #C6C6E8 !important;
1133
- text-decoration: underline; padding-left: 0 !important; }
1134
-
1135
1040
  /* ---- a wave, and the node a character is played from ---- */
1136
1041
  .pill-strip { display: flex; align-items: flex-start; gap: .22rem; min-width: 0; }
1137
1042
  .pill-strip .chatpills { flex: 1 1 auto; min-width: 0; }
@@ -1149,8 +1054,32 @@ const MUD_STYLE = `
1149
1054
  .pane-node { font-family: ${MONO_STACK}; font-size: .56rem; color: var(--soil-mid); opacity: .85; }
1150
1055
  .mud-window.remote .pane-controls button { display: none; }
1151
1056
  .mud-window.remote .room-view { box-shadow: inset 0 0 0 2px rgba(60,60,180,.35); }
1057
+
1152
1058
  @media (prefers-reduced-motion: reduce) {
1153
- .wave-hand, .node-row[data-waving="true"] .node-hand { animation: none; }
1059
+ .wave-hand { animation: none; }
1060
+ }
1061
+ `;
1062
+
1063
+ // The shared overlay, re-pointed at the burrow's own palette: soil-deep card,
1064
+ // parchment ink, the burrow-glow as the working accent, moss as the live one.
1065
+ // Only variables change — every rule stays the component's.
1066
+ const MUD_SHARE_SKIN = `
1067
+ .shareOverlay {
1068
+ --so-scrim: rgba(10, 6, 2, .74);
1069
+ --so-card: var(--soil-deep);
1070
+ --so-ink: var(--parchment);
1071
+ --so-muted: var(--chalk);
1072
+ --so-line: var(--soil-light);
1073
+ --so-accent: var(--burrow-glow);
1074
+ --so-good: #9DB36A;
1075
+ --so-warn: var(--burrow-glow);
1076
+ --so-alert: #FF9C82;
1077
+ --so-good-soft: rgba(157, 179, 106, .18);
1078
+ --so-accent-soft: rgba(232, 163, 61, .14);
1079
+ --so-alert-soft: rgba(255, 156, 130, .14);
1080
+ --so-display: ${DISPLAY_STACK};
1081
+ --so-body: ${SANS_STACK};
1082
+ --so-mono: ${MONO_STACK};
1154
1083
  }
1155
1084
  `;
1156
1085
 
@@ -1204,10 +1133,20 @@ function pageScript() {
1204
1133
  const nodeNameFor = ${nodeNameFor.toString()};
1205
1134
  const offerBlobIn = ${offerBlobIn.toString()};
1206
1135
  const wireStateLabel = ${wireStateLabel.toString()};
1136
+ const tapeRowFor = ${tapeRowFor.toString()};
1137
+ const tapeClock = ${tapeClock.toString()};
1207
1138
  const nodeRowsFor = ${nodeRowsFor.toString()};
1208
1139
  const nodeInitials = ${nodeInitials.toString()};
1209
1140
  const inviteLinkFor = ${inviteLinkFor.toString()};
1210
1141
  const inviteParamsFrom = ${inviteParamsFrom.toString()};
1142
+ const shareStepStates = ${shareStepStates.toString()};
1143
+ const activeWaves = ${activeWaves.toString()};
1144
+ const shareMessageFor = ${shareMessageFor.toString()};
1145
+ const replyMessageFor = ${replyMessageFor.toString()};
1146
+ const whatsAppShareUrl = ${whatsAppShareUrl.toString()};
1147
+ const isProbablyMobile = ${isProbablyMobile.toString()};
1148
+ const copyText = ${copyTextToClipboard.toString()};
1149
+ const flashTip = ${flashCopyTip.toString()};
1211
1150
 
1212
1151
  const el = (id) => document.getElementById(id);
1213
1152
  // Which of the shipped burrows is loaded. Every read of the world — the
@@ -2201,19 +2140,44 @@ function pageScript() {
2201
2140
  // block owns what a person sees and clicks, and nothing else.
2202
2141
  const P2P_ASSET = "./vendor/p2p.js";
2203
2142
  const NODE_NAME_KEY = "tmct.mud.nodeName";
2143
+ // Not the IndexedDB store beside it: that record is stamped with the site
2144
+ // version and the seed, and is dropped whenever either moves. A node id has
2145
+ // to outlive a deploy, because peers have already keyed this node's facts
2146
+ // on it.
2147
+ const NODE_ID_KEY = "tmct.mud.nodeId";
2204
2148
  const invite = inviteParamsFrom(window.location.search);
2205
2149
 
2206
2150
  let p2p = null;
2207
2151
  let p2pLoad = null;
2208
2152
  let room = null;
2209
2153
  let myPeerId = null;
2154
+ let myNodeId = "";
2210
2155
  let myDisplayName = "";
2211
2156
  let worldId = "";
2212
2157
  let worldName = "";
2213
2158
  let nodeClockTimer = null;
2159
+ let mintedBlob = "";
2160
+ let nodeWaveTimer = null;
2161
+ let channelCount = 0;
2162
+
2163
+ // Which seat this page occupies in the handshake — "idle" until something
2164
+ // happens, "sponsor" once an invite is minted here, "joiner" once an invite
2165
+ // is opened here. The overlay's CSS reads it to decide which calls to
2166
+ // action stand at each step; entry distinguishes an invite that arrived as
2167
+ // a link (the hero card owns the reply) from one pasted in as text.
2168
+ function setOverlayRole(role, entry) {
2169
+ const overlay = el("netPanel");
2170
+ overlay.dataset.role = role;
2171
+ overlay.dataset.entry = entry || "";
2172
+ }
2214
2173
 
2215
2174
  function loadP2p() {
2216
- if (!p2pLoad) p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
2175
+ if (!p2pLoad) {
2176
+ p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
2177
+ p2pLoad.catch(function (err) {
2178
+ noteTape("note", "fault", "networking unavailable", err && err.message ? err.message : String(err));
2179
+ });
2180
+ }
2217
2181
  return p2pLoad;
2218
2182
  }
2219
2183
  window.tmctP2pLoad = loadP2p;
@@ -2228,6 +2192,13 @@ function pageScript() {
2228
2192
  async function ensureIdentity() {
2229
2193
  const mod = await loadP2p();
2230
2194
  if (!myPeerId) myPeerId = mod.generatePeerId();
2195
+ if (!myNodeId) {
2196
+ try { myNodeId = localStorage.getItem(NODE_ID_KEY) || ""; } catch { myNodeId = ""; }
2197
+ if (!myNodeId) {
2198
+ myNodeId = mod.generateNodeId();
2199
+ try { localStorage.setItem(NODE_ID_KEY, myNodeId); } catch { /* private mode — this visit still has an id, it just won't outlive the tab */ }
2200
+ }
2201
+ }
2231
2202
  if (!myDisplayName) {
2232
2203
  myDisplayName = readStoredNodeName() || mod.generateDisplayName();
2233
2204
  el("nodeNameInput").value = myDisplayName;
@@ -2239,6 +2210,35 @@ function pageScript() {
2239
2210
  }
2240
2211
  }
2241
2212
 
2213
+ // The one place a transport gets made, which makes it the one place every
2214
+ // message crossing one can be seen. The room asks for transports through
2215
+ // this factory and never learns it is being watched.
2216
+ function instrumentedTransport() {
2217
+ const transport = p2p.createTransport();
2218
+ const channel = "ch" + (++channelCount);
2219
+ transport.onMessage(function (message) { noteWire("in", message, channel); });
2220
+ transport.onOpen(function () { noteTape("note", "link", "channel open", channel); });
2221
+ transport.onClose(function () { noteTape("note", "link", "channel closed", channel); });
2222
+ return {
2223
+ createOffer: function () {
2224
+ return transport.createOffer().then(function (sdp) { noteTape("note", "signal", "offer minted", channel); return sdp; });
2225
+ },
2226
+ createAnswerFor: function (offerSdp) {
2227
+ return transport.createAnswerFor(offerSdp).then(function (sdp) { noteTape("note", "signal", "answer minted", channel); return sdp; });
2228
+ },
2229
+ completeWithAnswer: function (answerSdp) {
2230
+ noteTape("note", "signal", "answer accepted", channel);
2231
+ return transport.completeWithAnswer(answerSdp);
2232
+ },
2233
+ send: function (message) { transport.send(message); noteWire("out", message, channel); },
2234
+ onMessage: function (fn) { transport.onMessage(fn); },
2235
+ onOpen: function (fn) { transport.onOpen(fn); },
2236
+ onClose: function (fn) { transport.onClose(fn); },
2237
+ close: function () { transport.close(); },
2238
+ get connectionState() { return transport.connectionState; },
2239
+ };
2240
+ }
2241
+
2242
2242
  async function ensureRoom() {
2243
2243
  if (room) return room;
2244
2244
  const mod = await loadP2p();
@@ -2255,25 +2255,36 @@ function pageScript() {
2255
2255
  memoryDir: session.memoryDir,
2256
2256
  myPeerId: myPeerId,
2257
2257
  myDisplayName: myDisplayName,
2258
+ myNodeId: myNodeId,
2258
2259
  worldId: worldId,
2259
2260
  worldName: worldName,
2260
- transportFactory: function () { return mod.createTransport({ iceServers: [] }); },
2261
+ transportFactory: instrumentedTransport,
2261
2262
  syncableFacts: function (rows) { return mod.mudSyncableFacts(rows, isState, extraPredicates); },
2262
2263
  });
2263
2264
  room.onStateChanged(function (state) {
2264
- // The join card exists to produce one reply. Once the channel is open it
2265
- // has done its job, and a full-screen card over a live burrow is just
2266
- // something in the way.
2267
- if (state === "connected") el("joinCard").hidden = true;
2265
+ noteTape("note", state === "failed" ? "fault" : "state", "state " + state, "");
2266
+ // The overlay exists to make one connection. The moment the channel is
2267
+ // open its work is done — the lights come back up, and the pill in the
2268
+ // header is the way back in.
2269
+ if (state === "connected") {
2270
+ el("joinCard").hidden = true;
2271
+ closeNetPanel();
2272
+ flashTip(el("statePill"), "connected \\u2014 you're digging together");
2273
+ }
2268
2274
  renderWire();
2269
2275
  });
2270
2276
  room.onPeersChanged(function () { renderNodes(); renderWire(); });
2271
- room.onFactsChanged(function () { renderNodes(); renderSoon(); });
2277
+ room.onFactsChanged(function (payload) {
2278
+ noteTape("note", "facts", "merged", payload.merged + (payload.merged === 1 ? " fact" : " facts"));
2279
+ renderNodes();
2280
+ renderSoon();
2281
+ });
2272
2282
  await room.start();
2273
2283
  // The burrow's name is written into the graph the moment the room starts,
2274
2284
  // and this version retracts nothing — so the field stops taking edits.
2275
2285
  el("worldNameInput").readOnly = true;
2276
2286
  window.tmctP2pRoom = room;
2287
+ noteTape("note", "link", "world " + worldName, myDisplayName);
2277
2288
  await claimMyAnimals();
2278
2289
  renderWire();
2279
2290
  renderNodes();
@@ -2312,6 +2323,9 @@ function pageScript() {
2312
2323
  el("shareLink").value = "";
2313
2324
  el("replyOut").value = "";
2314
2325
  el("worldNameInput").readOnly = false;
2326
+ mintedBlob = "";
2327
+ setOverlayRole("idle", "");
2328
+ noteTape("note", "link", "world closed", worldName);
2315
2329
  renderWire();
2316
2330
  renderNodes();
2317
2331
  // Last, not first: renderWire restates the note for the state it has just
@@ -2321,6 +2335,86 @@ function pageScript() {
2321
2335
  if (note) el("wireStateNote").textContent = note;
2322
2336
  }
2323
2337
 
2338
+ // ---- the wire tape: every message, as it happens ------------------------
2339
+ const TAPE_CAP = 240;
2340
+ const TAPE_FAMILY_COLOR = {
2341
+ facts: "var(--so-good)",
2342
+ state: "var(--so-accent)",
2343
+ greeting: "var(--so-warn)",
2344
+ signal: "var(--so-warn)",
2345
+ fault: "var(--so-alert)",
2346
+ link: "var(--so-muted)",
2347
+ };
2348
+ const tapeCounts = new Map();
2349
+ let wireMessageCount = 0;
2350
+
2351
+ function pushTape(entry) {
2352
+ // The meter counts real wire messages only; the tape below shows those
2353
+ // plus the local notes (state changes, a channel opening) that explain
2354
+ // them. Folding notes into the counts would make "12 op" mean two things.
2355
+ if (entry.dir !== "note") {
2356
+ wireMessageCount += 1;
2357
+ tapeCounts.set(entry.type, (tapeCounts.get(entry.type) || 0) + 1);
2358
+ }
2359
+ const tapeEl = el("tape");
2360
+ el("tapeEmpty").hidden = true;
2361
+ const row = document.createElement("li");
2362
+ row.className = "tape-row";
2363
+ row.dataset.dir = entry.dir;
2364
+ row.dataset.family = entry.family;
2365
+ row.dataset.type = entry.type;
2366
+ const bar = document.createElement("i");
2367
+ bar.className = "tape-bar";
2368
+ const clock = document.createElement("span");
2369
+ clock.className = "tape-clock";
2370
+ clock.textContent = tapeClock();
2371
+ const type = document.createElement("span");
2372
+ type.className = "tape-type";
2373
+ type.textContent = entry.type;
2374
+ const detail = document.createElement("span");
2375
+ detail.className = "tape-detail";
2376
+ detail.textContent = entry.detail || "";
2377
+ row.appendChild(bar);
2378
+ row.appendChild(clock);
2379
+ row.appendChild(type);
2380
+ row.appendChild(detail);
2381
+ tapeEl.insertBefore(row, tapeEl.firstChild);
2382
+ while (tapeEl.childElementCount > TAPE_CAP) tapeEl.removeChild(tapeEl.lastElementChild);
2383
+ renderTapeMeter();
2384
+ }
2385
+
2386
+ function noteWire(direction, message, channel) {
2387
+ const row = tapeRowFor(direction, message);
2388
+ pushTape({ dir: direction, family: row.family, type: row.type, detail: row.detail || channel });
2389
+ }
2390
+ function noteTape(dir, family, type, detail) {
2391
+ pushTape({ dir: dir, family: family, type: type, detail: detail });
2392
+ }
2393
+
2394
+ function renderTapeMeter() {
2395
+ const tapeMeterEl = el("tapeMeter");
2396
+ el("tapeTotal").textContent = wireMessageCount + (wireMessageCount === 1 ? " message" : " messages");
2397
+ tapeMeterEl.textContent = "";
2398
+ const counted = [...tapeCounts.entries()].sort(function (a, b) { return b[1] - a[1]; });
2399
+ for (const pair of counted) {
2400
+ const item = document.createElement("span");
2401
+ item.className = "meter-item";
2402
+ item.dataset.type = pair[0];
2403
+ const bar = document.createElement("i");
2404
+ bar.className = "meter-bar";
2405
+ bar.style.background = TAPE_FAMILY_COLOR[tapeRowFor("in", { type: pair[0] }).family] || "var(--so-muted)";
2406
+ const label = document.createElement("span");
2407
+ label.textContent = pair[0];
2408
+ const count = document.createElement("span");
2409
+ count.className = "meter-n";
2410
+ count.textContent = String(pair[1]);
2411
+ item.appendChild(bar);
2412
+ item.appendChild(label);
2413
+ item.appendChild(count);
2414
+ tapeMeterEl.appendChild(item);
2415
+ }
2416
+ }
2417
+
2324
2418
  // The state machine is shared with chat.html and the words mostly are too.
2325
2419
  // Two of them are not: nothing is taught here and nothing is a node below,
2326
2420
  // so those two notes are said in this page's own terms rather than forking
@@ -2342,10 +2436,29 @@ function pageScript() {
2342
2436
  pill.dataset.tone = label.tone;
2343
2437
  el("statePillWord").textContent = label.pill;
2344
2438
  pill.title = note;
2345
- el("sharePanel").hidden = !el("shareLink").value;
2346
- // Once the channel is open the reply has been used; leaving "send this
2347
- // back" on screen would be asking for something already done.
2348
- el("answerPanel").hidden = !el("replyOut").value || (room && room.state === "connected");
2439
+ el("netPanel").dataset.tone = label.tone;
2440
+ renderSteps();
2441
+ }
2442
+
2443
+ function renderSteps() {
2444
+ const states = shareStepStates({
2445
+ role: el("netPanel").dataset.role,
2446
+ state: room ? room.state : "idle",
2447
+ hasReply: Boolean(el("replyOut").value),
2448
+ });
2449
+ for (const step of states) {
2450
+ const item = el("step-" + step.key);
2451
+ if (item) item.dataset.status = step.status;
2452
+ }
2453
+ }
2454
+
2455
+ // The scene's two browser cards: this one wears the node's own name, the
2456
+ // far one wears the first peer's the moment there is one to name.
2457
+ function renderScene() {
2458
+ el("sceneYouName").textContent = myDisplayName || "you";
2459
+ const peers = room ? room.peers() : [];
2460
+ const first = peers.find(function (p) { return p.connected; }) || peers[0];
2461
+ el("sceneThemName").textContent = first ? room.displayNameFor(first.peerId) : "your friend";
2349
2462
  }
2350
2463
 
2351
2464
  function relativeWhen(at, nowMs) {
@@ -2358,12 +2471,27 @@ function pageScript() {
2358
2471
  return Math.round(seconds / 86400) + "d";
2359
2472
  }
2360
2473
 
2474
+ // A node is marked waving whether the wave came from one of its claimed
2475
+ // animals (a pane's own wave button) or from the node itself (the roster's
2476
+ // peer-scoped wave) — one hand, two ways to raise it.
2477
+ function wavingPeerTerms(nowMs) {
2478
+ const rows = room.factRows();
2479
+ const terms = new Set();
2480
+ for (const wave of activeWaves(rows, nowMs)) terms.add(wave.waver);
2481
+ for (const character of wavingCharacters(rows, nowMs)) {
2482
+ const owner = claimantOf(rows, character);
2483
+ if (owner !== null) terms.add(peerTerm(owner));
2484
+ }
2485
+ return terms;
2486
+ }
2487
+
2361
2488
  function renderNodes() {
2362
2489
  const listEl = el("nodeList");
2363
2490
  if (!room || !p2p) {
2364
2491
  el("nodeCount").textContent = "";
2365
2492
  listEl.textContent = "";
2366
2493
  el("nodeEmpty").hidden = false;
2494
+ renderScene();
2367
2495
  return;
2368
2496
  }
2369
2497
  const rows = nodeRowsFor({
@@ -2375,7 +2503,7 @@ function pageScript() {
2375
2503
  latestTimestampOf: p2p.latestProvenanceTimestamp,
2376
2504
  });
2377
2505
  const nowMs = Date.now();
2378
- const waving = wavingCharacters(room.factRows(), nowMs);
2506
+ const waving = wavingPeerTerms(nowMs);
2379
2507
  el("nodeCount").textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
2380
2508
  listEl.textContent = "";
2381
2509
  for (const entry of rows) {
@@ -2384,86 +2512,182 @@ function pageScript() {
2384
2512
  item.dataset.self = String(entry.isSelf);
2385
2513
  item.dataset.away = String(!entry.connected);
2386
2514
  item.dataset.peer = entry.peerId;
2387
- item.dataset.waving = String(waving.some(function (character) {
2388
- const owner = claimantOf(room.factRows(), character);
2389
- return owner !== null && peerTerm(owner) === peerTerm(entry.peerId);
2390
- }));
2515
+ item.dataset.waving = String(waving.has(peerTerm(entry.peerId)));
2391
2516
  const avatar = document.createElement("span");
2392
2517
  avatar.className = "node-avatar";
2393
2518
  avatar.textContent = nodeInitials(entry.name);
2394
2519
  avatar.title = entry.connected
2395
2520
  ? "connected"
2396
2521
  : "away \\u2014 closed the tab or dropped offline; everything it dug stays";
2397
- const name = document.createElement("span");
2398
- name.className = "node-name";
2399
- name.textContent = entry.name;
2522
+ const dot = document.createElement("i");
2523
+ dot.className = "node-dot";
2524
+ avatar.appendChild(dot);
2400
2525
  const hand = document.createElement("span");
2401
2526
  hand.className = "node-hand";
2402
2527
  hand.textContent = "\\u{1F44B}";
2528
+ avatar.appendChild(hand);
2529
+ const name = document.createElement("span");
2530
+ name.className = "node-name";
2531
+ name.textContent = entry.name;
2403
2532
  const when = document.createElement("span");
2404
2533
  when.className = "node-when";
2405
2534
  when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
2535
+ const fact = document.createElement("span");
2536
+ fact.className = "node-fact";
2537
+ if (entry.lastFact) {
2538
+ const line = entry.lastFact.subject + " " + String(entry.lastFact.predicate || "").replace(/^[a-z0-9_-]+:/i, "") + " " + entry.lastFact.object;
2539
+ fact.textContent = line;
2540
+ fact.title = "last shared: " + line;
2541
+ } else {
2542
+ fact.textContent = "nothing shared yet";
2543
+ }
2544
+ const waveOne = document.createElement("button");
2545
+ waveOne.type = "button";
2546
+ waveOne.className = "node-wave-btn";
2547
+ waveOne.textContent = "\\u{1F44B}";
2548
+ waveOne.title = entry.isSelf ? "wave at everyone" : "wave at " + entry.name + " \\u2014 they see it as a fact arriving";
2549
+ waveOne.setAttribute("aria-label", waveOne.title);
2550
+ waveOne.addEventListener("click", function () { waveAtNode(entry.isSelf ? null : entry.peerId, waveOne); });
2406
2551
  item.appendChild(avatar);
2407
2552
  item.appendChild(name);
2408
- if (entry.isSelf) {
2409
- const self = document.createElement("span");
2410
- self.className = "node-self";
2411
- self.textContent = "you";
2412
- item.appendChild(self);
2413
- }
2414
- item.appendChild(hand);
2415
2553
  item.appendChild(when);
2554
+ item.appendChild(waveOne);
2555
+ item.appendChild(fact);
2416
2556
  listEl.appendChild(item);
2417
2557
  }
2418
2558
  el("nodeEmpty").hidden = rows.length > 1;
2559
+ renderScene();
2560
+ clearTimeout(nodeWaveTimer);
2561
+ if (waving.size) nodeWaveTimer = setTimeout(renderNodes, 1000);
2419
2562
  }
2420
2563
 
2421
- async function copyText(text) {
2564
+ // A node's own wave, distinct from an animal's: the fact's subject is the
2565
+ // peer, and its object names the audience — everyone, or one node.
2566
+ async function waveAtNode(targetPeerId, anchor) {
2422
2567
  try {
2423
- if (navigator.clipboard && navigator.clipboard.writeText) {
2424
- await navigator.clipboard.writeText(text);
2425
- return true;
2426
- }
2427
- } catch { /* fall through the box on the page still holds the text */ }
2428
- return false;
2568
+ const active = await ensureRoom();
2569
+ await active.wave("peer:" + myPeerId, targetPeerId ? "peer:" + targetPeerId : null);
2570
+ const audience = targetPeerId ? (active.displayNameFor(targetPeerId) || "one node") : "everyone";
2571
+ renderNodes();
2572
+ if (anchor) flashTip(anchor, "you waved at " + audience);
2573
+ } catch (err) {
2574
+ el("wireStateNote").textContent = "couldn't wave (" + (err && err.message ? err.message : err) + ").";
2575
+ }
2429
2576
  }
2430
2577
 
2431
2578
  const openNetPanel = function () {
2432
- el("netPanel").hidden = false;
2579
+ const overlay = el("netPanel");
2580
+ overlay.hidden = false;
2433
2581
  el("statePill").setAttribute("aria-expanded", "true");
2582
+ const card = overlay.querySelector(".so-card");
2583
+ if (card) card.focus({ preventScroll: true });
2434
2584
  };
2435
2585
  const closeNetPanel = function () {
2436
2586
  el("netPanel").hidden = true;
2437
2587
  el("statePill").setAttribute("aria-expanded", "false");
2438
2588
  };
2439
2589
 
2590
+ function renderInviteShare() {
2591
+ const facts = room ? room.factRows().length : 0;
2592
+ const factsPart = facts ? " \\u00b7 " + facts + (facts === 1 ? " fact travels" : " facts travel") + " when they join" : "";
2593
+ el("inviteSummary").textContent = "invites one person into \\u201c" + worldName + "\\u201d" + factsPart;
2594
+ const message = shareMessageFor({ worldName: worldName, link: el("shareLink").value, thing: "burrow" });
2595
+ el("waShareBtn").href = whatsAppShareUrl(message);
2596
+ el("webShareBtn").hidden = !navigator.share;
2597
+ }
2598
+
2599
+ function renderReplyShare() {
2600
+ const message = replyMessageFor({ worldName: worldName, blob: el("replyOut").value });
2601
+ el("replyWaBtn").href = whatsAppShareUrl(message);
2602
+ el("joinWaBtn").href = whatsAppShareUrl(message);
2603
+ el("replyShareBtn").hidden = !navigator.share;
2604
+ el("joinShareBtn").hidden = !navigator.share;
2605
+ }
2606
+
2607
+ async function mintInvite(anchor) {
2608
+ try {
2609
+ const active = await ensureRoom();
2610
+ const minted = await active.startSharing();
2611
+ mintedBlob = minted.blob;
2612
+ el("shareLink").value = inviteLinkFor(window.location.href, {
2613
+ blob: minted.blob, world: worldId, worldName: worldName,
2614
+ });
2615
+ setOverlayRole("sponsor", "");
2616
+ renderInviteShare();
2617
+ el("replyProblem").hidden = true;
2618
+ openNetPanel();
2619
+ renderWire();
2620
+ await copyText(el("shareLink").value);
2621
+ flashTip(anchor, "link copied \\u2014 send it to one person");
2622
+ } catch (err) {
2623
+ el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
2624
+ el("replyProblem").hidden = false;
2625
+ openNetPanel();
2626
+ }
2627
+ }
2628
+
2440
2629
  function wireNetPanel() {
2441
2630
  el("netPanelClose").addEventListener("click", closeNetPanel);
2631
+ el("netPanel").addEventListener("click", function (e) {
2632
+ if (e.target === el("netPanel")) closeNetPanel();
2633
+ });
2634
+ document.addEventListener("keydown", function (e) {
2635
+ if (e.key === "Escape" && !el("netPanel").hidden) closeNetPanel();
2636
+ });
2442
2637
  el("statePill").addEventListener("click", function () {
2443
2638
  if (el("netPanel").hidden) openNetPanel(); else closeNetPanel();
2444
2639
  });
2640
+ if (isProbablyMobile()) el("netPanel").classList.add("so-mobile");
2641
+ // The blobs are made to be copied whole — a click selects everything, so
2642
+ // a manual copy can never take half a code.
2643
+ for (const boxId of ["shareLink", "replyOut", "joinReply"]) {
2644
+ el(boxId).addEventListener("focus", function (e) { e.target.select(); });
2645
+ }
2445
2646
 
2446
2647
  el("shareBtn").addEventListener("click", async function () {
2447
2648
  const shareBtn = el("shareBtn");
2448
2649
  shareBtn.disabled = true;
2449
2650
  try {
2450
- const active = await ensureRoom();
2451
- const minted = await active.startSharing();
2452
- el("shareLink").value = inviteLinkFor(window.location.href, {
2453
- blob: minted.blob, world: worldId, worldName: worldName,
2454
- });
2455
- el("replyProblem").hidden = true;
2456
- openNetPanel();
2457
- renderWire();
2458
- await copyText(el("shareLink").value);
2459
- } catch (err) {
2460
- el("replyProblem").textContent = "couldn't create an invite (" + (err && err.message ? err.message : err) + ").";
2461
- el("replyProblem").hidden = false;
2462
- openNetPanel();
2651
+ await mintInvite(shareBtn);
2463
2652
  } finally {
2464
2653
  shareBtn.disabled = false;
2465
2654
  }
2466
2655
  });
2656
+ for (const mintId of ["mintInviteBtn", "remintBtn"]) {
2657
+ el(mintId).addEventListener("click", async function () {
2658
+ const btn = el(mintId);
2659
+ btn.disabled = true;
2660
+ try {
2661
+ await mintInvite(btn);
2662
+ } finally {
2663
+ btn.disabled = false;
2664
+ }
2665
+ });
2666
+ }
2667
+
2668
+ el("copyLinkBtn").addEventListener("click", async function () {
2669
+ await copyText(el("shareLink").value);
2670
+ flashTip(el("copyLinkBtn"), "link copied \\u2014 send it to one person");
2671
+ });
2672
+ el("copyCodeBtn").addEventListener("click", async function () {
2673
+ await copyText(mintedBlob);
2674
+ flashTip(el("copyCodeBtn"), "code copied \\u2014 they paste it under step 3 on their page");
2675
+ });
2676
+ el("webShareBtn").addEventListener("click", function () {
2677
+ navigator.share({
2678
+ title: "join \\u201c" + worldName + "\\u201d",
2679
+ text: shareMessageFor({ worldName: worldName, link: el("shareLink").value, thing: "burrow" }),
2680
+ }).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
2681
+ });
2682
+ const shareReplyViaSheet = function () {
2683
+ navigator.share({
2684
+ title: "my reply to \\u201c" + worldName + "\\u201d",
2685
+ text: replyMessageFor({ worldName: worldName, blob: el("replyOut").value }),
2686
+ }).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
2687
+ };
2688
+ el("replyShareBtn").addEventListener("click", shareReplyViaSheet);
2689
+ el("joinShareBtn").addEventListener("click", shareReplyViaSheet);
2690
+ el("waveAllBtn").addEventListener("click", function () { waveAtNode(null, el("waveAllBtn")); });
2467
2691
 
2468
2692
  // The inviter's page has exactly one paste target, so there is no wrong box
2469
2693
  // to choose. A rejected paste keeps its text so the copy can be fixed.
@@ -2483,15 +2707,18 @@ function pageScript() {
2483
2707
  renderWire();
2484
2708
  });
2485
2709
 
2486
- el("copyReplyBtn").addEventListener("click", function () { copyText(el("replyOut").value); });
2710
+ el("copyReplyBtn").addEventListener("click", async function () {
2711
+ await copyText(el("replyOut").value);
2712
+ flashTip(el("copyReplyBtn"), "reply copied \\u2014 send it back the same way");
2713
+ });
2487
2714
 
2488
2715
  // The link flow lands somebody on the join card before they have dug
2489
2716
  // anything. This is the other case: a burrow already open, an invite that
2490
2717
  // arrived in a message, and no reason to throw the burrow away to take it.
2491
2718
  el("joinOpenBtn").addEventListener("click", function () {
2492
- el("invitePanel").hidden = false;
2493
2719
  openNetPanel();
2494
- el("inviteBox").focus();
2720
+ const box = el("inviteBox");
2721
+ if (box.offsetParent !== null) box.focus();
2495
2722
  });
2496
2723
 
2497
2724
  el("inviteBtn").addEventListener("click", async function () {
@@ -2538,8 +2765,11 @@ function pageScript() {
2538
2765
  problem.hidden = true;
2539
2766
  el("inviteBox").value = "";
2540
2767
  el("replyOut").value = outcome.blob;
2768
+ setOverlayRole("joiner", "paste");
2769
+ renderReplyShare();
2541
2770
  renderWire();
2542
2771
  await copyText(outcome.blob);
2772
+ flashTip(el("copyReplyBtn"), "reply copied \\u2014 send it back the same way");
2543
2773
  } catch (err) {
2544
2774
  problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2545
2775
  problem.hidden = false;
@@ -2581,26 +2811,31 @@ function pageScript() {
2581
2811
  el("joinReplyWrap").hidden = false;
2582
2812
  joinBtn.textContent = "reply created";
2583
2813
  el("joinDismiss").textContent = "close this and start digging";
2814
+ renderReplyShare();
2584
2815
  renderWire();
2585
2816
  await copyText(outcome.blob);
2817
+ flashTip(el("joinCopyBtn"), "reply copied \\u2014 send it back the same way");
2586
2818
  } catch (err) {
2587
2819
  el("joinProblem").textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
2588
2820
  el("joinProblem").hidden = false;
2589
2821
  joinBtn.disabled = false;
2590
2822
  }
2591
2823
  });
2592
- el("joinCopyBtn").addEventListener("click", function () { copyText(el("joinReply").value); });
2593
- el("joinDismiss").addEventListener("click", function () {
2594
- el("joinCard").hidden = true;
2595
- if (el("replyOut").value) openNetPanel();
2824
+ el("joinCopyBtn").addEventListener("click", async function () {
2825
+ await copyText(el("joinReply").value);
2826
+ flashTip(el("joinCopyBtn"), "reply copied \\u2014 send it back the same way");
2596
2827
  });
2828
+ el("joinDismiss").addEventListener("click", closeNetPanel);
2597
2829
  }
2598
2830
 
2599
2831
  // A link that lost part of itself on the way reads as a mangled link, never
2600
2832
  // as no invite at all.
2601
2833
  async function prepareJoinCard() {
2834
+ setOverlayRole("joiner", "link");
2602
2835
  el("joinCard").hidden = false;
2603
2836
  el("joinWorld").textContent = invite.worldName || "a shared burrow";
2837
+ renderSteps();
2838
+ openNetPanel();
2604
2839
  el("joinBtn").focus();
2605
2840
  const mod = await loadP2p();
2606
2841
  const decoded = mod.decodeInviteBlob(invite.offer);
@@ -2856,7 +3091,7 @@ function pageScript() {
2856
3091
  // Off the boot path on purpose: this fetches the shared P2P asset so the
2857
3092
  // panel can show a real node name and burrow name before anyone clicks
2858
3093
  // anything. A failure here costs sharing, never the digging.
2859
- ensureIdentity().catch(function () { /* sharing stays unavailable; the burrow does not */ });
3094
+ ensureIdentity().then(renderNodes).catch(function () { /* sharing stays unavailable; the burrow does not */ });
2860
3095
  if (invite) {
2861
3096
  prepareJoinCard().catch(function (err) {
2862
3097
  el("joinProblem").textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";