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