@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
@@ -0,0 +1,623 @@
1
+ // share-overlay-viz.mjs — the ONE sharing surface chat.html and mud.html both
2
+ // render: a full-page "lights down" dialog that walks the WebRTC invite/reply
3
+ // handshake as a five-step relay, with the connected-node roster, every
4
+ // copy/share affordance, and the learn-more links in one place. Both pages
5
+ // build their markup from shareOverlayHtml() and their styles from
6
+ // SHARE_OVERLAY_CSS, so the two flows cannot drift apart again the way the
7
+ // hand-copied .joinCard/.join-card pair did.
8
+ //
9
+ // The component is themed entirely through `--so-*` custom properties, which
10
+ // default to the site's shared theme tokens; mud.html re-points them at its
11
+ // own soil/parchment palette in its page style and inherits everything else.
12
+ //
13
+ // The pure helpers here (shareStepStates, activeWaves, offerBlobIn, the share
14
+ // message builders, copyTextToClipboard, flashCopyTip, isProbablyMobile) are
15
+ // self-contained and `.toString()`-splice safe — both pages splice them into
16
+ // their inline scripts, the same discipline chat-page-viz.mjs's own p2p
17
+ // helpers hold.
18
+ import { SERIF_STACK, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
19
+
20
+ /** The five relay stations of the handshake, in wire order. Each key is also
21
+ * the DOM id suffix (`step-<key>`) both pages stamp `data-status` onto. */
22
+ export const SHARE_STEP_KEYS = Object.freeze(["invite", "send", "reply", "return", "connect"]);
23
+
24
+ /**
25
+ * Where each of the five steps stands right now — "done" | "now" | "todo" |
26
+ * "fail" — for either seat at the table. The SAME ladder renders for the
27
+ * sponsor and the joiner; only the position differs:
28
+ *
29
+ * - sponsor (minted an invite): step 1 done, step 2 is theirs to act on,
30
+ * and everything after waits on the far side;
31
+ * - joiner (opened an invite): the first two legs already happened to get
32
+ * the invite here, so step 3 (or 4, once the reply exists) is live;
33
+ * - nobody yet ("idle"): step 1 is the only live step;
34
+ * - connecting/connected/failed override the seat — by then both halves have
35
+ * been exchanged and the ladder reads the wire, not the role.
36
+ *
37
+ * Self-contained (no outer refs), `.toString()`-splice safe.
38
+ */
39
+ export function shareStepStates({ role, state, hasReply }) {
40
+ const keys = ["invite", "send", "reply", "return", "connect"];
41
+ const upTo = (n) => keys.map((key, i) => ({ key: key, status: i < n ? "done" : "todo" }));
42
+ if (state === "connected") return upTo(5);
43
+ if (state === "failed") {
44
+ const rows = upTo(4);
45
+ rows[4].status = "fail";
46
+ return rows;
47
+ }
48
+ if (state === "connecting") {
49
+ const rows = upTo(4);
50
+ rows[4].status = "now";
51
+ return rows;
52
+ }
53
+ if (role === "sponsor") {
54
+ const rows = upTo(1);
55
+ rows[1].status = "now";
56
+ return rows;
57
+ }
58
+ if (role === "joiner") {
59
+ const live = hasReply ? 3 : 2;
60
+ const rows = upTo(live);
61
+ rows[live].status = "now";
62
+ return rows;
63
+ }
64
+ const rows = upTo(0);
65
+ rows[0].status = "now";
66
+ return rows;
67
+ }
68
+
69
+ /**
70
+ * Every wave currently live in `rows`: who waved, at whom, and when. A wave
71
+ * is an ordinary `mgx:waved` fact whose object is either the presence scope
72
+ * (a wave at everyone, returned as target "") or a specific term (a wave at
73
+ * one node/room). Recency is read off the NEWEST provenance stamp — a repeat
74
+ * wave re-asserts the same fact with a fresh tag — and anything older than
75
+ * the window simply stops being read as a wave, nothing retracted. Newest
76
+ * first. Self-contained, `.toString()`-splice safe.
77
+ */
78
+ export function activeWaves(rows, nowMs, windowMs = 8000) {
79
+ const found = new Map();
80
+ for (const row of rows || []) {
81
+ if (row.predicate !== "mgx:waved") continue;
82
+ const text = String(row.provenance || "");
83
+ const pattern = /@(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)/g;
84
+ let newest = null;
85
+ let m = pattern.exec(text);
86
+ while (m !== null) {
87
+ const at = Date.parse(m[1]);
88
+ if (!Number.isNaN(at) && (newest === null || at > newest)) newest = at;
89
+ m = pattern.exec(text);
90
+ }
91
+ if (newest === null) continue;
92
+ const age = nowMs - newest;
93
+ if (age < 0 || age > windowMs) continue;
94
+ const waver = String(row.subject);
95
+ const target = String(row.object) === "presence" ? "" : String(row.object);
96
+ const key = waver + "\u0000" + target;
97
+ const prior = found.get(key);
98
+ if (!prior || newest > prior.at) found.set(key, { waver: waver, target: target, at: newest });
99
+ }
100
+ return [...found.values()].sort((a, b) => b.at - a.at);
101
+ }
102
+
103
+ /** A peer id as the fact store actually holds it. `appendFacts` normalizes a
104
+ * term by stripping its CURIE prefix and lowercasing, so the `peer:<id>` a
105
+ * fact is written with reads back as the bare id — a comparison against a
106
+ * raw peer id has to normalize the same way or it never matches. Pure,
107
+ * self-contained. */
108
+ export function peerTerm(peerId) {
109
+ return String(peerId || "").replace(/^[a-z][\w.-]*:/i, "").toLowerCase();
110
+ }
111
+
112
+ /** The offer blob inside whatever somebody actually pasted: the `offer`
113
+ * parameter of a whole invite link, or the bare blob when that is what
114
+ * arrived. Both are what people really paste, and telling them apart by
115
+ * looking is cheaper than asking them to. Pure, self-contained. */
116
+ export function offerBlobIn(pasted) {
117
+ const text = String(pasted || "").trim();
118
+ const marker = text.indexOf("offer=");
119
+ if (marker === -1) return text;
120
+ const rest = text.slice(marker + "offer=".length);
121
+ const end = rest.search(/[&#\s]/);
122
+ return decodeURIComponent(end === -1 ? rest : rest.slice(0, end));
123
+ }
124
+
125
+ /** The invite as one message a share sheet or chat app can carry whole —
126
+ * what it is, what to do, and the link last so a preview never hides the
127
+ * instructions. Self-contained, `.toString()`-splice safe. */
128
+ export function shareMessageFor({ worldName, link, thing = "graph" }) {
129
+ return 'you’re invited into "' + worldName + '" — a shared ' + thing
130
+ + " that runs browser to browser, no server in the middle. open the link, press “create my reply”, and send the reply back: "
131
+ + link;
132
+ }
133
+
134
+ /** The joiner's reply as one message, with the instruction riding along so
135
+ * the sponsor knows where it goes. Self-contained, splice safe. */
136
+ export function replyMessageFor({ worldName, blob }) {
137
+ return 'my reply to your "' + worldName + '" invite — paste it into the “paste their reply here” box on your page: ' + blob;
138
+ }
139
+
140
+ /** The wa.me share URL for one message — WhatsApp's own documented deep link,
141
+ * which hands the app the whole text atomically (the fix for links that a
142
+ * chat app splits when they are pasted by hand). Works on WhatsApp mobile
143
+ * and WhatsApp Web alike. Self-contained, splice safe. */
144
+ export function whatsAppShareUrl(text) {
145
+ return "https://wa.me/?text=" + encodeURIComponent(String(text || ""));
146
+ }
147
+
148
+ /** Whether this is probably a touch-first mobile browser — used only to
149
+ * promote the OS share sheet above manual copying, never to remove a path.
150
+ * Self-contained, browser-only, splice safe. */
151
+ export function isProbablyMobile() {
152
+ if (navigator.userAgentData && typeof navigator.userAgentData.mobile === "boolean") return navigator.userAgentData.mobile;
153
+ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "");
154
+ }
155
+
156
+ /** One-tap copy with the textarea fallback for clipboards the page can't
157
+ * reach directly. Self-contained, browser-only, splice safe. */
158
+ export function copyTextToClipboard(text) {
159
+ const viaExec = function () {
160
+ try {
161
+ const holder = document.createElement("textarea");
162
+ holder.value = text;
163
+ holder.setAttribute("readonly", "");
164
+ holder.style.position = "fixed";
165
+ holder.style.opacity = "0";
166
+ document.body.appendChild(holder);
167
+ holder.select();
168
+ const copied = document.execCommand("copy");
169
+ holder.remove();
170
+ return copied;
171
+ } catch {
172
+ return false;
173
+ }
174
+ };
175
+ if (navigator.clipboard && navigator.clipboard.writeText) {
176
+ return navigator.clipboard.writeText(text).then(function () { return true; }, viaExec);
177
+ }
178
+ return Promise.resolve(viaExec());
179
+ }
180
+
181
+ /** The little "copied" toast, anchored under whatever was pressed — the
182
+ * same tip on every page, so a copy feels the same everywhere.
183
+ * Self-contained, browser-only, splice safe. */
184
+ export function flashCopyTip(anchor, text) {
185
+ const tip = document.createElement("div");
186
+ tip.className = "copyTip";
187
+ tip.setAttribute("role", "status");
188
+ tip.textContent = text;
189
+ document.body.appendChild(tip);
190
+ const box = anchor.getBoundingClientRect();
191
+ const width = tip.offsetWidth;
192
+ tip.style.top = (box.bottom + 6) + "px";
193
+ tip.style.left = Math.max(6, Math.min(window.innerWidth - width - 6, box.left + box.width / 2 - width / 2)) + "px";
194
+ setTimeout(function () { tip.remove(); }, 2600);
195
+ }
196
+
197
+ const DEFAULT_COPY = {
198
+ thing: "graph",
199
+ eyebrow: "browser-to-browser sharing",
200
+ lede: "This graph 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.",
201
+ worldLabel: "the graph you are sharing",
202
+ nodeLabel: "your node name",
203
+ namesNote: "both names are facts in the graph, not settings.",
204
+ invitedEyebrow: "you’ve been invited to",
205
+ joinBody: "Nothing has run yet. The button below makes your reply and copies it — send it back the same way this invite reached you, and leave this tab open.",
206
+ dismiss: "start talking on your own instead",
207
+ rosterTitle: "who holds this graph",
208
+ nodeEmpty: "nobody else yet. invite someone and their node appears here.",
209
+ themLabel: "your friend",
210
+ helpHref: "./help.html#sharing",
211
+ idleNote: "this browser holds the only copy of what you teach it.",
212
+ factsVerb: "teach",
213
+ };
214
+
215
+ const STEP_COPY = [
216
+ {
217
+ key: "invite",
218
+ title: "make the invite",
219
+ hint: "one invite, one person. it holds your half of the handshake — nothing is sent until you share it.",
220
+ },
221
+ {
222
+ key: "send",
223
+ title: "send it to them",
224
+ hint: "any messenger works. if links arrive mangled (some apps split long ones), send the code instead — it travels as plain text.",
225
+ },
226
+ {
227
+ key: "reply",
228
+ title: "they open it and make a reply",
229
+ hint: "their browser builds the answering half. still nothing connected — just the second half of the handshake.",
230
+ },
231
+ {
232
+ key: "return",
233
+ title: "they send the reply back",
234
+ hint: "same channel, opposite direction.",
235
+ },
236
+ {
237
+ key: "connect",
238
+ title: "paste the reply here — connected",
239
+ hint: "the moment it lands, your two browsers talk directly.",
240
+ },
241
+ ];
242
+
243
+ /**
244
+ * The overlay's markup: one dialog both pages embed verbatim, differing only
245
+ * in wording (`copy` overrides) and whether the wire tape rides along
246
+ * (`withTape` — chat.html's diagnostics instrument; mud.html has none yet).
247
+ * Every interactive element keeps a stable id — the page scripts and the
248
+ * e2e drivers address them by id, never by structure.
249
+ */
250
+ export function shareOverlayHtml({ copy = {}, withTape = false } = {}) {
251
+ const c = { ...DEFAULT_COPY, ...copy };
252
+ const esc = escapeHtml;
253
+ const stepSlots = {
254
+ invite: `
255
+ <div class="so-slot for-idle">
256
+ <button type="button" class="so-btn primary" id="mintInviteBtn">make an invite</button>
257
+ </div>
258
+ <div class="so-slot for-sponsor">
259
+ <p class="so-summary" id="inviteSummary"></p>
260
+ <textarea class="so-blob" id="shareLink" rows="3" readonly aria-label="the invite link, exactly as it will be sent"></textarea>
261
+ <div class="so-btnrow">
262
+ <button type="button" class="so-btn primary" id="copyLinkBtn">copy link</button>
263
+ <button type="button" class="so-btn" id="copyCodeBtn">copy code only</button>
264
+ <button type="button" class="so-btn" id="webShareBtn" hidden>share&#8230;</button>
265
+ <a class="so-btn" id="waShareBtn" href="https://wa.me/" target="_blank" rel="noopener">WhatsApp</a>
266
+ </div>
267
+ <p class="so-hint">the box above is everything that gets copied. “code only” is the same invite without the link around it — they paste it under step 3 on their own page.</p>
268
+ <button type="button" class="so-btn ghost" id="remintBtn">each invite admits one person — make a fresh one for the next</button>
269
+ </div>`,
270
+ send: "",
271
+ reply: `
272
+ <div class="so-slot for-idle">
273
+ <label class="so-label" for="inviteBox">got an invite? paste the link or the code here</label>
274
+ <textarea class="so-blob" id="inviteBox" rows="3" spellcheck="false" aria-label="an invite somebody sent you, as a link or a bare code"></textarea>
275
+ <div class="so-btnrow">
276
+ <button type="button" class="so-btn primary" id="inviteBtn">make my reply</button>
277
+ </div>
278
+ <p class="so-problem" id="inviteProblem" role="alert" hidden></p>
279
+ </div>`,
280
+ return: `
281
+ <div class="so-slot for-joiner for-joiner-paste">
282
+ <label class="so-label" for="replyOut">your reply — send it back the same way the invite reached you</label>
283
+ <textarea class="so-blob" id="replyOut" rows="3" readonly></textarea>
284
+ <div class="so-btnrow">
285
+ <button type="button" class="so-btn primary" id="copyReplyBtn">copy the reply</button>
286
+ <button type="button" class="so-btn" id="replyShareBtn" hidden>share&#8230;</button>
287
+ <a class="so-btn" id="replyWaBtn" href="https://wa.me/" target="_blank" rel="noopener">WhatsApp</a>
288
+ </div>
289
+ </div>`,
290
+ connect: `
291
+ <div class="so-slot for-idle for-sponsor">
292
+ <label class="so-label" for="replyBox">paste their reply here</label>
293
+ <textarea class="so-blob" id="replyBox" rows="3" spellcheck="false"></textarea>
294
+ <div class="so-btnrow">
295
+ <button type="button" class="so-btn primary" id="replyBtn">connect</button>
296
+ </div>
297
+ <p class="so-problem" id="replyProblem" role="alert" hidden></p>
298
+ </div>`,
299
+ };
300
+ const steps = STEP_COPY.map((step, i) => `
301
+ <li class="so-step" id="step-${step.key}" data-status="${i === 0 ? "now" : "todo"}">
302
+ <span class="so-step-mark" aria-hidden="true"><i class="so-step-n">${i + 1}</i><i class="so-step-check">&#10003;</i><i class="so-step-x">&#10007;</i></span>
303
+ <div class="so-step-body">
304
+ <span class="so-step-title">${esc(step.title)}</span>
305
+ <span class="so-step-hint">${esc(step.hint)}</span>${stepSlots[step.key]}
306
+ </div>
307
+ </li>`).join("");
308
+
309
+ const tape = withTape ? `
310
+ <details class="so-tape">
311
+ <summary>the wire, message by message <span class="so-count" id="tapeTotal"></span></summary>
312
+ <div class="tape-meter" id="tapeMeter"></div>
313
+ <ol class="tape" id="tape"></ol>
314
+ <p class="tape-empty" id="tapeEmpty">every message this browser sends or receives lands here, as it happens.</p>
315
+ </details>` : "";
316
+
317
+ return ` <div class="shareOverlay" id="netPanel" role="dialog" aria-modal="true" aria-labelledby="shareOverlayTitle" data-role="idle" data-tone="idle" hidden>
318
+ <section class="so-card" tabindex="-1">
319
+ <button type="button" class="so-close" id="netPanelClose" aria-label="close sharing">&times;</button>
320
+ <header class="so-head">
321
+ <p class="so-eyebrow" id="shareOverlayTitle">${esc(c.eyebrow)}</p>
322
+ <div class="so-state" id="wireState" data-tone="idle" role="status">
323
+ <span class="so-state-word" id="wireStateWord">not shared</span>
324
+ <span class="so-state-note" id="wireStateNote">${esc(c.idleNote)}</span>
325
+ </div>
326
+ </header>
327
+ <p class="so-lede">${esc(c.lede)}</p>
328
+ <section class="so-join" id="joinCard" hidden>
329
+ <p class="so-join-eyebrow" id="joinEyebrow">${esc(c.invitedEyebrow)}</p>
330
+ <h2 class="so-join-world" id="joinWorld"></h2>
331
+ <p class="so-join-body" id="joinBody">${esc(c.joinBody)}</p>
332
+ <div class="so-btnrow">
333
+ <button type="button" class="so-btn primary big" id="joinBtn">create my reply</button>
334
+ </div>
335
+ <p class="so-problem" id="joinProblem" role="alert" hidden></p>
336
+ <div class="so-join-reply" id="joinReplyWrap" hidden>
337
+ <label class="so-label" for="joinReply">your reply — send it back the same way the invite reached you</label>
338
+ <textarea class="so-blob" id="joinReply" rows="3" readonly></textarea>
339
+ <div class="so-btnrow">
340
+ <button type="button" class="so-btn primary" id="joinCopyBtn">copy the reply</button>
341
+ <button type="button" class="so-btn" id="joinShareBtn" hidden>share&#8230;</button>
342
+ <a class="so-btn" id="joinWaBtn" href="https://wa.me/" target="_blank" rel="noopener">WhatsApp</a>
343
+ </div>
344
+ </div>
345
+ <button type="button" class="so-btn ghost" id="joinDismiss">${esc(c.dismiss)}</button>
346
+ </section>
347
+ <div class="so-scene" aria-hidden="true">
348
+ <div class="so-browser">
349
+ <div class="so-browser-chrome"><i></i><i></i><i></i></div>
350
+ <div class="so-browser-body">
351
+ <span class="so-browser-who" id="sceneYouName">you</span>
352
+ </div>
353
+ </div>
354
+ <div class="so-wirebed">
355
+ <i class="so-packet so-packet-out"></i>
356
+ <i class="so-packet so-packet-back"></i>
357
+ <span class="so-wire-break">&#10007;</span>
358
+ <span class="so-wire-caption">webrtc — a direct line, no server between you</span>
359
+ </div>
360
+ <div class="so-browser">
361
+ <div class="so-browser-chrome"><i></i><i></i><i></i></div>
362
+ <div class="so-browser-body">
363
+ <span class="so-browser-who" id="sceneThemName">${esc(c.themLabel)}</span>
364
+ </div>
365
+ </div>
366
+ </div>
367
+ <div class="so-body">
368
+ <ol class="so-steps" aria-label="the handshake, step by step">${steps}
369
+ </ol>
370
+ <aside class="so-side">
371
+ <section class="so-block">
372
+ <h3 class="so-side-title">${esc(c.rosterTitle)} <span class="so-count" id="nodeCount"></span></h3>
373
+ <ul class="node-list" id="nodeList"></ul>
374
+ <p class="so-hint" id="nodeEmpty">${esc(c.nodeEmpty)}</p>
375
+ <button type="button" class="so-btn" id="waveAllBtn">&#128075; wave at everyone</button>
376
+ </section>
377
+ <section class="so-block so-names">
378
+ <label class="so-label" for="worldNameInput">${esc(c.worldLabel)}
379
+ <input class="so-name-input" id="worldNameInput" type="text" autocomplete="off" spellcheck="false"></label>
380
+ <label class="so-label" for="nodeNameInput">${esc(c.nodeLabel)}
381
+ <input class="so-name-input" id="nodeNameInput" type="text" autocomplete="off" spellcheck="false"></label>
382
+ <p class="so-hint">${esc(c.namesNote)}</p>
383
+ </section>${tape}
384
+ <section class="so-block so-links">
385
+ <h3 class="so-side-title">how this works</h3>
386
+ <p class="so-hint">the invite and the reply are the two halves of a WebRTC handshake (an offer and an answer). once both sides hold both halves, your browsers talk directly — no server ever holds this ${esc(c.thing)}.</p>
387
+ <ul class="so-linklist">
388
+ <li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API" target="_blank" rel="noopener">WebRTC, on MDN &#8599;</a></li>
389
+ <li><a href="https://webrtc.org/" target="_blank" rel="noopener">webrtc.org &#8599;</a></li>
390
+ <li><a href="${esc(c.helpHref)}" target="_blank" rel="noopener">how sharing works, on this site &#8599;</a></li>
391
+ </ul>
392
+ </section>
393
+ </aside>
394
+ </div>
395
+ </section>
396
+ </div>`;
397
+ }
398
+
399
+ /** The overlay's whole stylesheet, themed through `--so-*` custom properties
400
+ * that default to the shared theme tokens — a page with a palette of its own
401
+ * (mud.html) re-points the variables and inherits every rule. */
402
+ export const SHARE_OVERLAY_CSS = `
403
+ .shareOverlay {
404
+ --so-scrim: rgba(12, 12, 10, .62);
405
+ --so-card: var(--card, #fff);
406
+ --so-ink: var(--ink, #23272B);
407
+ --so-muted: var(--muted, #6E7168);
408
+ --so-line: var(--line, #DDD9D0);
409
+ --so-accent: var(--corpus, #5A80AC);
410
+ --so-good: var(--taught, #2E7D4F);
411
+ --so-warn: var(--entail, #B07C2E);
412
+ --so-alert: var(--alert, #B0503F);
413
+ --so-good-soft: var(--taught-soft, rgba(46, 125, 79, .12));
414
+ --so-accent-soft: var(--corpus-soft, rgba(90, 128, 172, .12));
415
+ --so-alert-soft: var(--alert-soft, rgba(176, 80, 63, .12));
416
+ --so-display: ${SERIF_STACK};
417
+ --so-body: ${SERIF_STACK};
418
+ --so-mono: ${MONO_STACK};
419
+ position: fixed; inset: 0; z-index: 40; overflow-y: auto; overscroll-behavior: contain;
420
+ background: var(--so-scrim);
421
+ }
422
+ .shareOverlay[hidden] { display: none !important; }
423
+ /* Setting display at all outranks the browser's own [hidden] rule, so
424
+ everything inside the overlay that hides by attribute needs it restated
425
+ once — scoped here so a host page never has to carry its own guard. */
426
+ .shareOverlay [hidden] { display: none !important; }
427
+ .so-card {
428
+ position: relative; box-sizing: border-box; width: min(58rem, calc(100% - 2rem));
429
+ margin: 2.5rem auto 3rem; padding: 1.5rem 1.7rem 1.4rem;
430
+ background: var(--so-card); color: var(--so-ink);
431
+ border: 1px solid var(--so-line); border-radius: 12px;
432
+ box-shadow: 0 24px 70px rgba(0, 0, 0, .38);
433
+ font-family: var(--so-body); font-size: .95rem; line-height: 1.5;
434
+ animation: so-rise .22s ease-out; outline: none;
435
+ }
436
+ .shareOverlay button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
437
+ .so-close { position: absolute; top: .55rem; right: .7rem; font-size: 1.5rem; line-height: 1; color: var(--so-muted); padding: .2rem .45rem; }
438
+ .so-close:hover { color: var(--so-ink); }
439
+
440
+ .so-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; padding-right: 2rem; }
441
+ .so-eyebrow { margin: .1rem 0 0; font-family: var(--so-mono); font-size: .66rem; letter-spacing: .12em; text-transform: uppercase; color: var(--so-muted); }
442
+ .so-lede { margin: .55rem 0 0; max-width: 62ch; font-size: .88rem; color: var(--so-muted); }
443
+
444
+ /* connection state: a calm slow breath while nothing is in flight, a
445
+ quicker one while ICE runs, solid on live, static dashed red on failed —
446
+ a fault should not twitch. */
447
+ .so-state { position: relative; overflow: hidden; flex: 0 1 24rem; min-width: 14rem; border: 1px solid var(--so-line); border-left-width: 3px; border-radius: 8px; padding: .45rem .7rem .5rem .75rem; background: var(--so-card); }
448
+ .so-state-word { display: block; font-size: .95rem; }
449
+ .so-state-note { display: block; margin-top: .2rem; font-size: .74rem; line-height: 1.4; color: var(--so-muted); }
450
+ .so-state[data-tone="waiting"], .so-state[data-tone="working"] { border-left-color: var(--so-accent); }
451
+ .so-state[data-tone="waiting"]::before, .so-state[data-tone="working"]::before { content: ""; position: absolute; left: -3px; top: 0; bottom: 0; width: 3px; background: var(--so-accent); animation: so-breathe 2.4s ease-in-out infinite; }
452
+ .so-state[data-tone="working"]::before { animation-duration: .9s; }
453
+ .so-state[data-tone="live"] { border-left-color: var(--so-good); background: var(--so-good-soft); }
454
+ .so-state[data-tone="live"] .so-state-word { color: var(--so-good); }
455
+ .so-state[data-tone="failed"] { border-style: dashed; border-left-style: solid; border-left-color: var(--so-alert); background: var(--so-alert-soft); }
456
+ .so-state[data-tone="failed"] .so-state-word { color: var(--so-alert); }
457
+ @keyframes so-breathe { 0%, 100% { opacity: .2; } 50% { opacity: 1; } }
458
+ @keyframes so-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
459
+
460
+ /* the joiner's headline: the world's generated two-word name, as big as it
461
+ ever gets — the one moment the invite is the whole page's subject. */
462
+ .so-join { margin: 1.1rem 0 .2rem; padding: 1.1rem 1.2rem 1rem; border: 1px solid var(--so-line); border-radius: 10px; background: var(--so-accent-soft); display: flex; flex-direction: column; gap: .55rem; }
463
+ .so-join-eyebrow { margin: 0; font-family: var(--so-mono); font-size: .66rem; letter-spacing: .12em; text-transform: uppercase; color: var(--so-muted); }
464
+ .so-join-world { margin: 0; font-family: var(--so-display); font-size: clamp(1.7rem, 4.5vw, 2.6rem); line-height: 1.05; overflow-wrap: anywhere; }
465
+ .so-join-body { margin: 0; max-width: 52ch; font-size: .9rem; color: var(--so-muted); }
466
+ .so-join-reply { display: flex; flex-direction: column; gap: .45rem; }
467
+
468
+ /* the scene: two browsers and the direct line between them — the diagram's
469
+ one-glance version, animated by the live connection state. */
470
+ .so-scene { display: flex; align-items: stretch; gap: .9rem; margin: 1.2rem 0 1.3rem; }
471
+ .so-browser { flex: 0 0 auto; width: 8rem; border: 1px solid var(--so-line); border-radius: 8px; background: var(--so-card); box-shadow: 0 2px 8px rgba(0, 0, 0, .08); }
472
+ .so-browser-chrome { display: flex; gap: 3px; padding: .32rem .45rem; border-bottom: 1px solid var(--so-line); }
473
+ .so-browser-chrome i { width: 6px; height: 6px; border-radius: 50%; background: var(--so-line); }
474
+ .so-browser-body { display: flex; align-items: center; justify-content: center; padding: .7rem .4rem .75rem; }
475
+ .so-browser-who { font-family: var(--so-mono); font-size: .66rem; color: var(--so-muted); overflow-wrap: anywhere; text-align: center; }
476
+ .so-wirebed { position: relative; flex: 1 1 auto; align-self: center; height: 3rem; min-width: 6rem; }
477
+ .so-wirebed::before { content: ""; position: absolute; left: 2%; right: 2%; top: 50%; border-top: 2px dashed var(--so-line); }
478
+ .shareOverlay[data-tone="live"] .so-wirebed::before { border-top-style: solid; border-top-color: var(--so-good); }
479
+ .shareOverlay[data-tone="failed"] .so-wirebed::before { border-top-color: var(--so-alert); }
480
+ .so-packet { position: absolute; top: 50%; left: 2%; width: 9px; height: 9px; margin: -4.5px 0 0 -4.5px; border-radius: 3px; opacity: 0; }
481
+ .so-packet-out { background: var(--so-accent); }
482
+ .so-packet-back { background: var(--so-warn); }
483
+ .shareOverlay[data-tone="waiting"] .so-packet-out,
484
+ .shareOverlay[data-tone="working"] .so-packet-out { animation: so-travel-out 2.1s ease-in-out infinite; }
485
+ .shareOverlay[data-tone="waiting"][data-role="joiner"] .so-packet-out { animation: none; }
486
+ .shareOverlay[data-tone="waiting"][data-role="joiner"] .so-packet-back,
487
+ .shareOverlay[data-tone="working"] .so-packet-back { animation: so-travel-back 2.1s ease-in-out infinite; animation-delay: 1s; }
488
+ .shareOverlay[data-tone="working"] .so-packet-out { animation-duration: .8s; }
489
+ .shareOverlay[data-tone="working"] .so-packet-back { animation-duration: .8s; animation-delay: .4s; }
490
+ .shareOverlay[data-tone="live"] .so-packet-out { animation: so-travel-out 3.2s ease-in-out infinite; background: var(--so-good); }
491
+ .shareOverlay[data-tone="live"] .so-packet-back { animation: so-travel-back 3.2s ease-in-out infinite; animation-delay: 1.6s; background: var(--so-good); }
492
+ @keyframes so-travel-out { 0% { left: 2%; opacity: 0; } 12% { opacity: 1; } 88% { opacity: 1; } 100% { left: 98%; opacity: 0; } }
493
+ @keyframes so-travel-back { 0% { left: 98%; opacity: 0; } 12% { opacity: 1; } 88% { opacity: 1; } 100% { left: 2%; opacity: 0; } }
494
+ .so-wire-break { display: none; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -55%); color: var(--so-alert); font-size: .9rem; background: var(--so-card); padding: 0 .3rem; }
495
+ .shareOverlay[data-tone="failed"] .so-wire-break { display: block; }
496
+ .so-wire-caption { position: absolute; left: 50%; top: calc(50% + .55rem); transform: translateX(-50%); font-family: var(--so-mono); font-size: .56rem; letter-spacing: .04em; color: var(--so-muted); white-space: nowrap; }
497
+
498
+ .so-body { display: grid; grid-template-columns: minmax(0, 1fr) 16.5rem; gap: 1.5rem; align-items: start; }
499
+
500
+ /* the ladder: five stations joined by one spine. data-status drives the
501
+ mark — number, check, cross — and the live station carries the accent. */
502
+ .so-steps { list-style: none; margin: 0; padding: 0; }
503
+ .so-step { position: relative; display: grid; grid-template-columns: 1.7rem minmax(0, 1fr); gap: 0 .75rem; padding: 0 0 1.05rem; }
504
+ .so-step:last-child { padding-bottom: 0; }
505
+ .so-step::before { content: ""; position: absolute; left: .8rem; top: 1.8rem; bottom: -.1rem; width: 2px; background: var(--so-line); }
506
+ .so-step:last-child::before { display: none; }
507
+ .so-step[data-status="done"]::before { background: var(--so-good); }
508
+ .so-step-mark { position: relative; width: 1.7rem; height: 1.7rem; border-radius: 50%; border: 2px solid var(--so-line); box-sizing: border-box; display: flex; align-items: center; justify-content: center; font-family: var(--so-mono); font-size: .72rem; color: var(--so-muted); background: var(--so-card); }
509
+ .so-step-mark i { font-style: normal; display: none; }
510
+ .so-step[data-status="todo"] .so-step-n { display: block; }
511
+ .so-step[data-status="now"] .so-step-n { display: block; }
512
+ .so-step[data-status="now"] .so-step-mark { border-color: var(--so-accent); color: var(--so-card); background: var(--so-accent); animation: so-now-pulse 2.2s ease-in-out infinite; }
513
+ .so-step[data-status="done"] .so-step-mark { border-color: var(--so-good); color: var(--so-good); background: var(--so-good-soft); }
514
+ .so-step[data-status="done"] .so-step-check { display: block; }
515
+ .so-step[data-status="fail"] .so-step-mark { border-color: var(--so-alert); color: var(--so-alert); background: var(--so-alert-soft); }
516
+ .so-step[data-status="fail"] .so-step-x { display: block; }
517
+ @keyframes so-now-pulse { 0%, 100% { box-shadow: 0 0 0 0 var(--so-accent-soft); } 50% { box-shadow: 0 0 0 6px var(--so-accent-soft); } }
518
+ .so-step-body { display: flex; flex-direction: column; gap: .28rem; min-width: 0; padding-top: .12rem; }
519
+ .so-step-title { font-size: .98rem; }
520
+ .so-step[data-status="now"] .so-step-title { font-weight: 600; }
521
+ .so-step[data-status="todo"] .so-step-title, .so-step[data-status="todo"] .so-step-hint { color: var(--so-muted); }
522
+ .so-step-hint { font-size: .78rem; color: var(--so-muted); max-width: 52ch; }
523
+
524
+ /* the CTA slots: which of them shows is the role's call, not the step's */
525
+ .so-slot { display: none; flex-direction: column; gap: .45rem; margin-top: .35rem; }
526
+ .shareOverlay[data-role="idle"] .so-slot.for-idle { display: flex; }
527
+ .shareOverlay[data-role="sponsor"] .so-slot.for-sponsor { display: flex; }
528
+ .shareOverlay[data-role="joiner"] .so-slot.for-joiner { display: flex; }
529
+ .shareOverlay[data-role="joiner"][data-entry="link"] .so-slot.for-joiner-paste { display: none; }
530
+
531
+ .so-btnrow { display: flex; flex-wrap: wrap; gap: .45rem; align-items: center; }
532
+ .so-btn { font-family: var(--so-body); font-size: .84rem; line-height: 1.35; color: var(--so-ink); border: 1px solid var(--so-line); border-radius: 99px; padding: .32rem .85rem; background: var(--so-card); text-decoration: none; display: inline-flex; align-items: center; gap: .3rem; }
533
+ .so-btn:hover { border-color: var(--so-ink); }
534
+ .so-btn:disabled { opacity: .5; cursor: default; }
535
+ .so-btn.primary { background: var(--so-ink); color: var(--so-card); border-color: var(--so-ink); }
536
+ .so-btn.big { font-size: 1rem; padding: .5rem 1.2rem; align-self: flex-start; }
537
+ .so-btn.ghost { border-color: transparent; color: var(--so-muted); padding-left: 0; align-self: flex-start; text-decoration: underline; text-underline-offset: 3px; }
538
+ .so-btn.ghost:hover { color: var(--so-ink); }
539
+ .shareOverlay.so-mobile .so-btnrow #webShareBtn:not([hidden]),
540
+ .shareOverlay.so-mobile .so-btnrow #replyShareBtn:not([hidden]),
541
+ .shareOverlay.so-mobile .so-btnrow #joinShareBtn:not([hidden]) { order: -1; }
542
+ .shareOverlay.so-mobile .so-btn { padding: .45rem 1rem; }
543
+
544
+ .so-summary { margin: 0; font-family: var(--so-mono); font-size: .68rem; color: var(--so-muted); }
545
+ /* machine text stays mono: it is something a person only ever copies, and a
546
+ prose face on base64 would be a lie about what it is. */
547
+ .so-blob { width: 100%; box-sizing: border-box; font-family: var(--so-mono); font-size: .6rem; line-height: 1.35; color: var(--so-muted); background: var(--so-accent-soft); border: 1px solid var(--so-line); border-radius: 8px; padding: .45rem .55rem; resize: vertical; word-break: break-all; }
548
+ .so-blob:focus { color: var(--so-ink); outline: 2px solid var(--so-accent); outline-offset: 1px; }
549
+ .so-label { font-size: .8rem; color: var(--so-muted); display: flex; flex-direction: column; gap: .25rem; }
550
+ .so-hint { margin: 0; font-size: .74rem; line-height: 1.45; color: var(--so-muted); }
551
+ .so-problem { margin: 0; font-size: .8rem; color: var(--so-alert); border-left: 2px solid var(--so-alert); padding-left: .5rem; }
552
+ .so-name-input { font-family: var(--so-body); font-size: .88rem; color: var(--so-ink); background: var(--so-card); border: 1px solid var(--so-line); border-radius: 8px; padding: .3rem .6rem; width: 100%; box-sizing: border-box; }
553
+ .so-name-input:read-only { background: none; border-color: transparent; padding-left: 0; color: var(--so-muted); }
554
+
555
+ .so-side { display: flex; flex-direction: column; gap: 1.25rem; min-width: 0; }
556
+ .so-block { display: flex; flex-direction: column; gap: .5rem; }
557
+ .so-side-title { margin: 0; font-family: var(--so-mono); font-size: .62rem; letter-spacing: .1em; text-transform: uppercase; color: var(--so-muted); }
558
+ .so-count { letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; }
559
+ .so-links .so-linklist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .25rem; font-size: .8rem; }
560
+ .so-links a { color: var(--so-accent); }
561
+ .so-tape summary { font-family: var(--so-mono); font-size: .62rem; letter-spacing: .1em; text-transform: uppercase; color: var(--so-muted); cursor: pointer; }
562
+
563
+ /* the wire tape: every message in and out, in arrival order, newest first
564
+ so the latest is readable without scrolling. The 3px bar carries the
565
+ message family in the host page's own accent colours; the type is
566
+ spelled out beside it, so colour is never the only thing distinguishing
567
+ one row from another. */
568
+ .tape-meter { display: flex; flex-wrap: wrap; gap: .15rem .7rem; margin: .4rem 0 .3rem; }
569
+ .meter-item { display: inline-flex; align-items: center; gap: .3rem; font-size: .6rem; color: var(--so-muted); }
570
+ .meter-bar { width: 5px; height: 5px; border-radius: 1px; flex: 0 0 auto; }
571
+ .meter-n { color: var(--so-ink); font-variant-numeric: tabular-nums; }
572
+ .tape { list-style: none; margin: 0; padding: 0; max-height: 20rem; overflow-y: auto; border-top: 1px solid var(--so-line); }
573
+ .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(--so-line); font-size: .6rem; font-variant-numeric: tabular-nums; }
574
+ .tape-row:first-child { animation: tape-arrive .6s ease-out; }
575
+ .tape-bar { align-self: stretch; border-radius: 1px; background: var(--so-muted); }
576
+ .tape-clock { color: var(--so-muted); }
577
+ .tape-type { color: var(--so-ink); overflow-wrap: anywhere; }
578
+ .tape-detail { color: var(--so-muted); text-align: right; white-space: nowrap; }
579
+ .tape-row[data-dir="out"] .tape-type::before { content: "\\2192 "; color: var(--so-muted); }
580
+ .tape-row[data-dir="in"] .tape-type::before { content: "\\2190 "; color: var(--so-muted); }
581
+ .tape-row[data-dir="note"] .tape-type::before { content: "\\00b7 "; color: var(--so-muted); }
582
+ .tape-row[data-family="facts"] .tape-bar { background: var(--so-good); }
583
+ .tape-row[data-family="state"] .tape-bar { background: var(--so-accent); }
584
+ .tape-row[data-family="greeting"] .tape-bar { background: var(--so-warn); }
585
+ .tape-row[data-family="signal"] .tape-bar { background: var(--so-warn); }
586
+ .tape-row[data-family="fault"] .tape-bar { background: var(--so-alert); }
587
+ .tape-row[data-family="fault"] .tape-type { color: var(--so-alert); }
588
+ .tape-empty { font-family: var(--so-body); color: var(--so-muted); font-size: .76rem; line-height: 1.4; padding: .45rem 0 0; margin: 0; }
589
+ @keyframes tape-arrive { from { background: var(--so-accent-soft); } to { background: transparent; } }
590
+
591
+ /* the roster: every node that holds this graph, its last shared fact, and a
592
+ hand to wave at it. Colour never carries presence alone — the dot does. */
593
+ .node-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .15rem; }
594
+ .node-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; grid-template-areas: "avatar name when wave" "avatar fact fact wave"; column-gap: .55rem; align-items: center; padding: .32rem 0; border-bottom: 1px dotted var(--so-line); }
595
+ .node-row:last-child { border-bottom: none; }
596
+ .node-avatar { grid-area: avatar; position: relative; width: 1.7rem; height: 1.7rem; border-radius: 50%; background: var(--so-line); color: var(--so-muted); display: flex; align-items: center; justify-content: center; font-family: var(--so-mono); font-size: .6rem; letter-spacing: .04em; text-transform: uppercase; }
597
+ .node-row[data-self="true"] .node-avatar { background: var(--so-ink); color: var(--so-card); }
598
+ .node-dot { position: absolute; right: -1px; bottom: -1px; width: 7px; height: 7px; border-radius: 50%; background: var(--so-good); box-shadow: 0 0 0 2px var(--so-card); }
599
+ .node-row[data-away="true"] .node-dot { background: var(--so-muted); }
600
+ .node-name { grid-area: name; font-size: .86rem; min-width: 0; overflow-wrap: anywhere; }
601
+ .node-row[data-self="true"] .node-name::after { content: " (you)"; color: var(--so-muted); font-size: .74rem; }
602
+ .node-row[data-away="true"] .node-name { color: var(--so-muted); }
603
+ .node-when { grid-area: when; color: var(--so-muted); font-family: var(--so-mono); font-size: .62rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
604
+ .node-fact { grid-area: fact; font-family: var(--so-mono); font-size: .6rem; color: var(--so-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
605
+ .node-wave-btn { grid-area: wave; font-size: .95rem; line-height: 1; padding: .25rem .3rem; border-radius: 6px; opacity: .55; }
606
+ .node-wave-btn:hover { opacity: 1; background: var(--so-accent-soft); }
607
+ .node-hand { position: absolute; top: -.55rem; right: -.45rem; font-size: .9rem; opacity: 0; transform-origin: 70% 80%; pointer-events: none; }
608
+ .node-row[data-waving="true"] .node-hand { opacity: 1; animation: so-hand-wave .8s ease-in-out infinite; }
609
+ @keyframes so-hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
610
+
611
+ .copyTip { position: fixed; z-index: 60; background: var(--so-ink, #23272B); color: var(--so-card, #fff); font-family: var(--so-body, inherit); font-size: .78rem; padding: .3rem .7rem; border-radius: 99px; pointer-events: none; box-shadow: 0 3px 10px rgba(0, 0, 0, .22); animation: so-rise .14s ease-out; }
612
+
613
+ @media (max-width: 860px) {
614
+ .so-card { width: 100%; margin: 0; border-radius: 0; border-left: none; border-right: none; min-height: 100dvh; padding: 1.1rem 1rem 2rem; }
615
+ .so-body { grid-template-columns: 1fr; }
616
+ .so-head { padding-right: 1.8rem; }
617
+ .so-wire-caption { display: none; }
618
+ }
619
+ @media (prefers-reduced-motion: reduce) {
620
+ .so-card, .so-packet, .so-state::before, .so-step[data-status="now"] .so-step-mark,
621
+ .node-row[data-waving="true"] .node-hand, .copyTip, .tape-row:first-child { animation: none !important; }
622
+ }
623
+ `;
@@ -11,8 +11,8 @@
11
11
  // index.html already uses for chat-browser.bundle.js (both are the FULL
12
12
  // turn engine, both generated fresh per build, neither meant to be
13
13
  // committed), not memory-ask-browser.bundle.js's inlined-text arrangement
14
- // (that bundle is small, committed, and inlined specifically so ledger.html
15
- // stays portable on its own — neither reason applies here).
14
+ // (that bundle is small and inlined specifically so ledger.html stays
15
+ // portable on its own — that reason doesn't apply here).
16
16
  //
17
17
  // renderSpiderFlyHtml() is pure: no I/O, deterministic output for identical
18
18
  // input. scripts/build-demo-site.mjs calls it directly and writes the result