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