@marver-design/marver 0.11.0 → 0.12.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +83 -5
  2. package/README.md +17 -4
  3. package/dist/auth-DdUKeVKt.mjs +1150 -0
  4. package/dist/{build-Cr_ZLMdq.mjs → build-DmfxJc6L.mjs} +188 -33
  5. package/dist/cli.mjs +16 -7
  6. package/dist/collab-BmaOjwP1.mjs +781 -0
  7. package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
  8. package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
  9. package/dist/{dev-CLBjRs0J.mjs → dev-BXq7yE1p.mjs} +5 -5
  10. package/dist/events-C9k-ceOj.mjs +101 -0
  11. package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
  12. package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
  13. package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
  14. package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-DOjnZakC.mjs} +94 -6
  15. package/dist/{plugin-vE7sxEeT.mjs → plugin-Boy7I_JH.mjs} +82 -9
  16. package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
  17. package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
  18. package/dist/{serve-CZ1KEQxd.mjs → serve-BeLLizNg.mjs} +72 -18
  19. package/dist/share-C64b1tdN.mjs +159 -0
  20. package/dist/summary-Bf9tiGrE.mjs +211 -0
  21. package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
  22. package/package.json +1 -1
  23. package/src/client/shell/App.tsx +43 -8
  24. package/src/client/shell/Comments.tsx +9 -5
  25. package/src/client/shell/LockedApp.tsx +113 -0
  26. package/src/client/shell/Play.tsx +97 -18
  27. package/src/client/shell/Toolbar.tsx +26 -2
  28. package/src/client/shell/canvas/Canvas.tsx +5 -15
  29. package/src/client/shell/canvas/ctl.ts +26 -0
  30. package/src/client/shell/comments-store.ts +39 -9
  31. package/src/client/shell/hash.ts +19 -0
  32. package/src/client/shell/icons.tsx +1 -0
  33. package/src/client/shell/store.ts +54 -3
  34. package/src/client/shell/styles.css +27 -0
  35. package/src/shared/events.ts +8 -3
  36. package/templates/instructions/publish.md +15 -1
  37. package/dist/auth-B5yuwnOq.mjs +0 -494
  38. package/dist/collab-C-n-uQdm.mjs +0 -364
  39. package/dist/manifest-C2tzkNaC.mjs +0 -233
  40. package/dist/update-DuWDj5nR.mjs +0 -77
  41. package/dist/utm-CxC3QN5X.mjs +0 -20
@@ -0,0 +1,211 @@
1
+ import { n as replay } from "./events-C9k-ceOj.mjs";
2
+ import { x as withLock } from "./auth-DdUKeVKt.mjs";
3
+ import { closeSync, fsyncSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createHash, createPrivateKey, createPublicKey, createVerify, generateKeyPairSync, randomBytes, sign } from "node:crypto";
6
+ //#region src/server/summary.ts
7
+ /**
8
+ * The front door's canvas half (02-home §3, 04-solution §9.1-9.2).
9
+ *
10
+ * Three pieces live here: the canvas identity keypair (generated once onto the
11
+ * volume - the private half never leaves it), the JWS the summary endpoint
12
+ * answers with (origin alone is not a durable identity; possession of the key
13
+ * is what makes a front-door row light up), and the per-user seen marks that
14
+ * make `unread` a real number instead of a bookmark.
15
+ */
16
+ const idCache = /* @__PURE__ */ new Map();
17
+ /** RFC 7638 thumbprint input: the required EC members, lexicographic, no space. */
18
+ const thumbprint = (jwk) => createHash("sha256").update(JSON.stringify({
19
+ crv: jwk.crv,
20
+ kty: jwk.kty,
21
+ x: jwk.x,
22
+ y: jwk.y
23
+ })).digest("hex");
24
+ /**
25
+ * The keypair, generated on first boot with a data dir. ES256 (P-256), the
26
+ * same suite the identity service already signs with. Stored as one JSON file
27
+ * at 0600; a half-written file from a crashed boot is regenerated - nothing
28
+ * has pinned a kid the canvas never served.
29
+ */
30
+ function canvasIdentity(dir) {
31
+ const hit = idCache.get(dir);
32
+ if (hit) return hit;
33
+ const file = join(dir, "identity.json");
34
+ const read = () => {
35
+ try {
36
+ const p = JSON.parse(readFileSync(file, "utf8"));
37
+ if (typeof p?.privatePem !== "string" || typeof p?.publicJwk?.x !== "string") return null;
38
+ return {
39
+ kid: thumbprint(p.publicJwk),
40
+ publicJwk: p.publicJwk,
41
+ privatePem: p.privatePem
42
+ };
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+ const identity = read() ?? withLock(dir, () => {
48
+ const again = read();
49
+ if (again) return again;
50
+ const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
51
+ const publicJwk = publicKey.export({ format: "jwk" });
52
+ const privatePem = privateKey.export({
53
+ format: "pem",
54
+ type: "pkcs8"
55
+ }).toString();
56
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
57
+ const fd = openSync(tmp, "wx", 384);
58
+ try {
59
+ writeSync(fd, JSON.stringify({
60
+ publicJwk,
61
+ privatePem
62
+ }));
63
+ fsyncSync(fd);
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ renameSync(tmp, file);
68
+ return {
69
+ kid: thumbprint(publicJwk),
70
+ publicJwk,
71
+ privatePem
72
+ };
73
+ }, ".identity.lock");
74
+ idCache.set(dir, identity);
75
+ return identity;
76
+ }
77
+ const b64u = (b) => Buffer.from(b).toString("base64url");
78
+ /** A compact JWS over `payload`, signed by the canvas identity key. The header
79
+ * carries the kid, never the key - discovery is `GET /__mv/api/identity`. */
80
+ function signCanvasJws(dir, payload, typ = "marver-summary+jwt") {
81
+ const id = canvasIdentity(dir);
82
+ const head = b64u(JSON.stringify({
83
+ alg: "ES256",
84
+ typ,
85
+ kid: id.kid
86
+ }));
87
+ const body = b64u(JSON.stringify(payload));
88
+ const sig = sign("sha256", Buffer.from(`${head}.${body}`), {
89
+ key: createPrivateKey(id.privatePem),
90
+ dsaEncoding: "ieee-p1363"
91
+ });
92
+ return `${head}.${body}.${b64u(sig)}`;
93
+ }
94
+ /** The public half, exported for tests that verify the JWS end to end. */
95
+ function canvasPublicKey(dir) {
96
+ return createPublicKey({
97
+ key: canvasIdentity(dir).publicJwk,
98
+ format: "jwk"
99
+ });
100
+ }
101
+ function loadSeen(dir) {
102
+ try {
103
+ const p = JSON.parse(readFileSync(join(dir, "seen.json"), "utf8"));
104
+ return typeof p === "object" && p !== null ? p : {};
105
+ } catch {
106
+ return {};
107
+ }
108
+ }
109
+ function markSeen(dir, emailNorm, board, eventId) {
110
+ withLock(dir, () => {
111
+ const store = loadSeen(dir);
112
+ (store[emailNorm] ??= {})[board] = eventId;
113
+ const file = join(dir, "seen.json");
114
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
115
+ const fd = openSync(tmp, "wx", 384);
116
+ try {
117
+ writeSync(fd, JSON.stringify(store));
118
+ fsyncSync(fd);
119
+ } finally {
120
+ closeSync(fd);
121
+ }
122
+ renameSync(tmp, file);
123
+ }, ".seen.lock");
124
+ }
125
+ function deriveThreads(events) {
126
+ return {
127
+ events,
128
+ open: new Set(replay(events).filter((t) => !t.resolved).map((t) => t.id))
129
+ };
130
+ }
131
+ /** Unread for one board = open-thread events after the caller's mark; no mark
132
+ * (or a mark the log no longer holds) = every open-thread event counts. */
133
+ function unreadCount(state, markId) {
134
+ const from = markId ? state.events.findIndex((e) => e.id === markId) : -1;
135
+ let n = 0;
136
+ for (let i = from + 1; i < state.events.length; i++) {
137
+ const e = state.events[i];
138
+ const root = e.type === "reply" ? e.parentId : e.commentId;
139
+ if (root && state.open.has(root)) n++;
140
+ }
141
+ return n;
142
+ }
143
+ const spentJti = /* @__PURE__ */ new Map();
144
+ let jtiLoaded = false;
145
+ const jtiFile = (dir) => join(dir, "spent-jti.json");
146
+ function loadSpentJti(dir) {
147
+ if (jtiLoaded) return;
148
+ jtiLoaded = true;
149
+ try {
150
+ const p = JSON.parse(readFileSync(jtiFile(dir), "utf8"));
151
+ const now = Math.floor(Date.now() / 1e3);
152
+ if (typeof p === "object" && p !== null) {
153
+ for (const [k, e] of Object.entries(p)) if (typeof e === "number" && e > now) spentJti.set(k, e);
154
+ }
155
+ } catch {}
156
+ }
157
+ function persistSpentJti(dir) {
158
+ const now = Math.floor(Date.now() / 1e3);
159
+ for (const [k, e] of spentJti) if (e < now) spentJti.delete(k);
160
+ const file = jtiFile(dir);
161
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
162
+ const fd = openSync(tmp, "wx", 384);
163
+ try {
164
+ writeSync(fd, JSON.stringify(Object.fromEntries(spentJti)));
165
+ fsyncSync(fd);
166
+ } finally {
167
+ closeSync(fd);
168
+ }
169
+ renameSync(tmp, file);
170
+ }
171
+ function verifyCanvasJws(dir, token, typ, aud) {
172
+ const parts = token.split(".");
173
+ if (parts.length !== 3 || token.length > 8192) return { ok: false };
174
+ let header, claims;
175
+ try {
176
+ header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
177
+ claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
178
+ } catch {
179
+ return { ok: false };
180
+ }
181
+ if (typeof header !== "object" || header === null || typeof claims !== "object" || claims === null) return { ok: false };
182
+ if (header.alg !== "ES256" || header.typ !== typ) return { ok: false };
183
+ const id = canvasIdentity(dir);
184
+ if (header.kid !== id.kid) return { ok: false };
185
+ if (claims.aud !== aud) return { ok: false };
186
+ const now = Math.floor(Date.now() / 1e3);
187
+ if (typeof claims.exp !== "number" || claims.exp < now) return { ok: false };
188
+ const sig = Buffer.from(parts[2], "base64url");
189
+ if (sig.length !== 64) return { ok: false };
190
+ const v = createVerify("SHA256");
191
+ v.update(`${parts[0]}.${parts[1]}`);
192
+ v.end();
193
+ if (!v.verify({
194
+ key: canvasPublicKey(dir),
195
+ dsaEncoding: "ieee-p1363"
196
+ }, sig)) return { ok: false };
197
+ if (typeof claims.jti === "string") {
198
+ loadSpentJti(dir);
199
+ if (spentJti.has(claims.jti)) return { ok: false };
200
+ spentJti.set(claims.jti, claims.exp);
201
+ try {
202
+ persistSpentJti(dir);
203
+ } catch {}
204
+ }
205
+ return {
206
+ ok: true,
207
+ claims
208
+ };
209
+ }
210
+ //#endregion
211
+ export { canvasIdentity, deriveThreads, loadSeen, markSeen, signCanvasJws, unreadCount, verifyCanvasJws };
@@ -1,110 +1,11 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { t as diffEvents } from "./events-C9k-ceOj.mjs";
2
3
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
3
- import { t as CLI_TOKEN_CHARS } from "./auth-B5yuwnOq.mjs";
4
+ import { t as CLI_TOKEN_CHARS } from "./auth-DdUKeVKt.mjs";
4
5
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeSync } from "node:fs";
5
6
  import { join } from "node:path";
6
7
  import { createHash, randomBytes } from "node:crypto";
7
8
  import { homedir } from "node:os";
8
- //#region src/shared/events.ts
9
- /** Events the other side lacks, by id - the sync payload in either direction. */
10
- function diffEvents(mine, theirIds) {
11
- const have = new Set(theirIds);
12
- return mine.filter((e) => !have.has(e.id));
13
- }
14
- /** Replay a board's events into current thread state. Deterministic: replay order is
15
- * (ts, id) so two stores holding the same event SET always derive the same state.
16
- * TWO passes - creates first, then everything else - so a reply whose author's clock
17
- * ran ahead of the creator's still lands instead of being dropped forever. */
18
- function replay(events) {
19
- const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
20
- const threads = /* @__PURE__ */ new Map();
21
- for (const ev of ordered) {
22
- if (ev.type !== "create") continue;
23
- if (!ev.commentId || threads.has(ev.commentId)) continue;
24
- threads.set(ev.commentId, {
25
- id: ev.commentId,
26
- board: ev.board,
27
- nodeKey: ev.nodeKey,
28
- frame: ev.frame,
29
- anchor: ev.anchor,
30
- author: ev.author,
31
- body: ev.body,
32
- ts: ev.ts,
33
- resolved: false,
34
- agent: ev.agent,
35
- agentMeta: ev.agentMeta,
36
- replies: [],
37
- reactions: {}
38
- });
39
- }
40
- for (const ev of ordered) switch (ev.type) {
41
- case "reply": {
42
- const t = ev.parentId ? threads.get(ev.parentId) : void 0;
43
- if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
44
- t.replies.push({
45
- id: ev.commentId,
46
- author: ev.author,
47
- body: ev.body,
48
- ts: ev.ts,
49
- agent: ev.agent,
50
- agentMeta: ev.agentMeta
51
- });
52
- break;
53
- }
54
- case "edit": {
55
- if (!ev.commentId || ev.body === void 0) break;
56
- const t = threads.get(ev.commentId);
57
- if (t) {
58
- t.body = ev.body;
59
- break;
60
- }
61
- for (const th of threads.values()) {
62
- const r = th.replies.find((r) => r.id === ev.commentId);
63
- if (r) {
64
- r.body = ev.body;
65
- break;
66
- }
67
- }
68
- break;
69
- }
70
- case "resolve": {
71
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
72
- if (t) {
73
- t.resolved = true;
74
- if (ev.addressedIn) t.addressedIn = ev.addressedIn;
75
- }
76
- break;
77
- }
78
- case "reopen": {
79
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
80
- if (t) {
81
- t.resolved = false;
82
- t.addressedIn = void 0;
83
- }
84
- break;
85
- }
86
- case "reanchor": {
87
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
88
- if (t && ev.anchor != null) t.anchor = ev.anchor;
89
- break;
90
- }
91
- case "react": {
92
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
93
- const who = ev.author?.email;
94
- if (!t || !who || !ev.emoji) break;
95
- const users = t.reactions[ev.emoji] ??= [];
96
- const at = users.indexOf(who);
97
- if (at === -1) users.push(who);
98
- else {
99
- users.splice(at, 1);
100
- if (!users.length) delete t.reactions[ev.emoji];
101
- }
102
- break;
103
- }
104
- }
105
- return [...threads.values()];
106
- }
107
- //#endregion
108
9
  //#region src/server/sync.ts
109
10
  /**
110
11
  * Dev ↔ published comment sync - one merge rule, run from the dev side.
@@ -394,4 +295,4 @@ async function connectToken(root, url, token) {
394
295
  });
395
296
  }
396
297
  //#endregion
397
- export { syncOnce as a, loadCollab as i, connectClaim as n, sync_exports as o, connectToken as r, replay as s, connect as t };
298
+ export { syncOnce as a, loadCollab as i, connectClaim as n, sync_exports as o, connectToken as r, connect as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,11 +1,11 @@
1
1
  import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { useStore, CONFIG, PUBLISHED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
3
+ import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, landingMode, type FrameEntry } from './store.ts'
4
4
  import { Tip } from './Tip.tsx'
5
5
  import { PKG, ROUTE } from '../const.ts'
6
6
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
7
  import { frameByWindow } from './canvas/frame-registry.ts'
8
- import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
+ import { enterFocus, enterPlay, playCtl, PlayOverlay } from './Play.tsx'
9
9
  import { bootHash, parseHash, writeHash } from './hash.ts'
10
10
  import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
11
  import { CommentsController, revealThread } from './Comments.tsx'
@@ -26,7 +26,10 @@ function copyToClipboard(text: string, okMsg: string) {
26
26
 
27
27
  /** The address a frame copies - the same string from the sidebar right-click, the floating
28
28
  * toolbar, and ⇧P: the board it sits on, the frame id, and its file. */
29
- const framePath = (board: string, f: { id: string; file: string }) => `board: ${board} · frame: ${f.id} (${f.file})`
29
+ // stripped build: the file column would only show the opaque token, so the
30
+ // copy keeps what is still honest - the board and the frame id
31
+ const framePath = (board: string, f: { id: string; file: string }) =>
32
+ SOURCE_REVEALED ? `board: ${board} · frame: ${f.id} (${f.file})` : `board: ${board} · frame: ${f.id}`
30
33
 
31
34
  type MenuItem = { label: string; icon: ReactNode; onClick: () => void }
32
35
  type MenuState = { x: number; y: number; items: MenuItem[] }
@@ -561,7 +564,20 @@ export function App() {
561
564
  }
562
565
  const ok = await boot()
563
566
  urlReady.current = true
564
- if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
567
+ if (ok && bootHash.focus) {
568
+ // a frame deep link forces focus for that visit, over every other rule
569
+ // (01-sharing §6.1 step 3) - the stage mounts any manifest frame, so no
570
+ // board lookup is needed
571
+ enterFocus(bootHash.focus.at, { ...bootHash.focus, deep: true })
572
+ } else if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
573
+ else if (ok && PUBLISHED) {
574
+ // published landing: explicit `open` beats the type default; canvas and
575
+ // board land on the canvas surface (board-scoped already); a lock will
576
+ // then hold the mode by hiding every door (04-solution §2.35)
577
+ const mode = landingMode(useStore.getState().board)
578
+ if (mode === 'present') enterPlay()
579
+ else if (mode === 'focus') enterFocus()
580
+ }
565
581
  }
566
582
  void start()
567
583
  }, [])
@@ -574,11 +590,15 @@ export function App() {
574
590
  useEffect(() => {
575
591
  if (!urlReady.current) return
576
592
  const s = useStore.getState()
577
- if (playState) {
593
+ if (playState?.focus && playState.deep) {
594
+ // a deep-link focus visit keeps its frame URL - the shareable form
595
+ writeHash({ focus: { at: playState.at, device: playState.device, theme: playState.theme } })
596
+ } else if (playState && !playState.focus) {
578
597
  const push = !prevPlay.current || prevPlay.current.at !== playState.at
579
598
  writeHash({ board: s.board, play: playState }, push)
580
599
  } else {
581
- writeHash({ board: s.board, n: s.selection })
600
+ // type-default focus keeps the board URL the visitor arrived on
601
+ writeHash({ board: s.board, n: playState ? [] : s.selection })
582
602
  }
583
603
  prevPlay.current = playState
584
604
  })
@@ -596,7 +616,9 @@ export function App() {
596
616
  s = useStore.getState()
597
617
  if (s.board !== h.board) return // switch failed; the projection effect will re-sync the URL
598
618
  }
599
- if (h.play) {
619
+ if (h.focus) {
620
+ enterFocus(h.focus.at, { ...h.focus, deep: true })
621
+ } else if (h.play) {
600
622
  if (s.play) playCtl.sync(h.play)
601
623
  else enterPlay(h.play)
602
624
  } else {
@@ -820,7 +842,7 @@ export function App() {
820
842
  if (e.key === 't') { animateLayout(); runTidy() }
821
843
  // laser and comment mode are one-at-a-time: comment mode already highlights
822
844
  // what you'd click, so stacking the full rainbow on top only adds noise
823
- if (e.key === 'l') {
845
+ if (e.key === 'l' && SOURCE_REVEALED) {
824
846
  if (!s.laser) commentsStore().setMode(false)
825
847
  s.setLaser(!s.laser)
826
848
  }
@@ -919,6 +941,19 @@ export function App() {
919
941
  if (!interacting) appRef.current?.focus({ preventScroll: true })
920
942
  }, [interacting])
921
943
 
944
+ // Every published board is locked to a stage mode: the canvas shell is never
945
+ // offered - no sidebar, no canvas surface, no toolbar. The bundle IS the
946
+ // locked view (the Publish-to-web shape). Boot still ran above; the landing
947
+ // effect entered the mode, and PlayOverlay's noDoor keeps it there.
948
+ if (LOCKED_SHELL) {
949
+ return (
950
+ <div ref={appRef} tabIndex={-1} className={`sh-app${dark ? ' dark' : ''}`}>
951
+ <PlayOverlay />
952
+ <div className="sh-toasts">{toasts.map((t) => <div className="sh-toast" key={t.id}>{t.text}</div>)}</div>
953
+ </div>
954
+ )
955
+ }
956
+
922
957
  return (
923
958
  <div ref={appRef} tabIndex={-1} className={`sh-app${dark ? ' dark' : ''}${interacting ? ' interacting' : ''}`}>
924
959
  <Canvas />
@@ -9,7 +9,7 @@ import { createPortal } from 'react-dom'
9
9
  import { avatarFallback, useComments } from './comments-store.ts'
10
10
  import { useStore, type Node } from './store.ts'
11
11
  import { threadHostKey } from './keys.ts'
12
- import { canvasCtl } from './canvas/Canvas.tsx'
12
+ import { canvasCtl } from './canvas/ctl.ts'
13
13
  import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
14
14
  import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
15
15
  import { Tip } from './Tip.tsx'
@@ -74,7 +74,7 @@ export function Avatar({ author, size = 22 }: { author?: { email?: string; name?
74
74
  )
75
75
  }
76
76
 
77
- type Face = { email?: string; name?: string; avatar?: string; agent?: boolean }
77
+ type Face = { email?: string; id?: string; name?: string; avatar?: string; agent?: boolean }
78
78
 
79
79
  /** The signal a marker (pin or stack) carries: WHO took part and HOW MANY comments
80
80
  * in total - root + every reply, across all the given threads. Unique participants
@@ -89,7 +89,9 @@ function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
89
89
  // Avatar renders them all as the live identity - two keys would show identical twin faces.
90
90
  const add = (a: Face | undefined, agent?: boolean) => {
91
91
  const face: Face | undefined = agent ? { agent: true } : a
92
- const k = agent ? 'marver' : a?.email ? `e:${a.email.toLowerCase()}` : 'local'
92
+ // published transport carries the opaque id instead of an email - same
93
+ // namespacing rule, different stable key
94
+ const k = agent ? 'marver' : a?.email ? `e:${a.email.toLowerCase()}` : a?.id ? `i:${a.id}` : 'local'
93
95
  if (face && !seen.has(k)) seen.set(k, face)
94
96
  }
95
97
  for (const t of threads) {
@@ -465,8 +467,10 @@ export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, sta
465
467
  const local = useComments((s) => s.local)
466
468
  const canComment = local || !!me // dev is always "me"; published needs a session
467
469
  // Owner-authored @marver is a live trigger (bold accent); anyone else's is context (plain + tooltip).
468
- // Match by email when we know the owner's; in dev without a profile, local writes are the owner.
469
- const isOwner = (a?: { email?: string }) => (me?.email ? a?.email === me.email : local)
470
+ // Match by opaque id on the projected transport, by email locally; in dev
471
+ // without a profile, local writes are the owner.
472
+ const isOwner = (a?: { email?: string; id?: string }) =>
473
+ me?.id && a?.id ? a.id === me.id : me?.email ? a?.email === me.email : local
470
474
  const [text, setText] = useState('')
471
475
  const [copied, setCopied] = useState(false)
472
476
  const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The locked-shell entry (01-sharing §5.1's all-boards rule) - what a bundle
3
+ * whose every published board is locked to a stage mode boots into INSTEAD of
4
+ * the canvas shell. The build swaps this module in for App.tsx, so the canvas
5
+ * surface (sidebar, board grid, toolbar) is not hidden - its code never enters
6
+ * the bundle. Publish-to-web, as an artifact rather than a flag.
7
+ *
8
+ * What remains is exactly the locked surface: boot, the landing rule, the play
9
+ * or focus overlay (whose lock already refuses every exit), and deep links
10
+ * within what the lock allows.
11
+ */
12
+ import { Component, useEffect, useRef, type ReactNode } from 'react'
13
+ import { useStore, boardLabel, landingMode } from './store.ts'
14
+ import { enterFocus, enterPlay, PlayOverlay, playCtl } from './Play.tsx'
15
+ import { bootHash, parseHash, writeHash } from './hash.ts'
16
+ import { useComments } from './comments-store.ts'
17
+
18
+ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
19
+ state = { err: null as Error | null }
20
+ static getDerivedStateFromError(err: Error) { return { err } }
21
+ render() {
22
+ if (!this.state.err) return this.props.children
23
+ return (
24
+ <div style={{ fontFamily: 'ui-monospace, monospace', padding: 32 }}>
25
+ <b style={{ color: '#a81f16' }}>shell crashed</b>
26
+ <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}>{this.state.err.message}</pre>
27
+ <button onClick={() => location.reload()}>reload</button>
28
+ </div>
29
+ )
30
+ }
31
+ }
32
+
33
+ let booted = false
34
+
35
+ export function App() {
36
+ const toasts = useStore((s) => s.toasts)
37
+ const board = useStore((s) => s.board)
38
+
39
+ /** The landing rule, enforced: the mode the lock pinned, entered on boot and
40
+ * re-entered if anything ever drops the overlay - there is no other surface. */
41
+ const land = () => {
42
+ const s = useStore.getState()
43
+ if (s.play) return
44
+ const mode = landingMode(s.board)
45
+ if (mode === 'focus') enterFocus()
46
+ else enterPlay()
47
+ }
48
+
49
+ useEffect(() => {
50
+ if (booted) return
51
+ booted = true
52
+ const start = async () => {
53
+ if (bootHash.board && bootHash.board !== useStore.getState().board)
54
+ useStore.setState({ board: bootHash.board, boardAuto: false })
55
+ const ok = await boot()
56
+ urlReady.current = true
57
+ if (!ok) return
58
+ if (bootHash.focus) enterFocus(bootHash.focus.at, { ...bootHash.focus, deep: true })
59
+ else if (bootHash.play) enterPlay(bootHash.play)
60
+ else land()
61
+ }
62
+ const boot = useStore.getState().boot
63
+ void start()
64
+ }, [])
65
+
66
+ // the overlay IS the surface: if it ever closes, land again
67
+ const play = useStore((s) => s.play)
68
+ useEffect(() => { if (!play) setTimeout(land, 30) }, [play])
69
+
70
+ // the URL projection, minimal: stage state only (there is no selection here)
71
+ const urlReady = useRef(false)
72
+ useEffect(() => {
73
+ if (!urlReady.current || !play) return
74
+ if (play.focus && play.deep) writeHash({ focus: { at: play.at, device: play.device, theme: play.theme } })
75
+ else if (!play.focus) writeHash({ board: useStore.getState().board, play })
76
+ })
77
+
78
+ // back/forward + pasted links, within what the lock allows
79
+ useEffect(() => {
80
+ const onPop = async () => {
81
+ const h = parseHash()
82
+ const s = useStore.getState()
83
+ if (h.board && h.board !== s.board) {
84
+ await s.switchBoard(h.board)
85
+ land()
86
+ return
87
+ }
88
+ if (h.focus) enterFocus(h.focus.at, { ...h.focus, deep: true })
89
+ else if (h.play && s.play) playCtl.sync(h.play)
90
+ else if (h.play) enterPlay(h.play)
91
+ }
92
+ window.addEventListener('popstate', onPop)
93
+ window.addEventListener('hashchange', onPop)
94
+ return () => { window.removeEventListener('popstate', onPop); window.removeEventListener('hashchange', onPop) }
95
+ }, [])
96
+
97
+ // comment liveness for the stage overlay - same wiring the canvas shell does
98
+ useEffect(() => {
99
+ if (!board) return
100
+ const c = useComments.getState()
101
+ void c.load(board)
102
+ return c.live(board)
103
+ }, [board])
104
+
105
+ useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board])
106
+
107
+ return (
108
+ <div tabIndex={-1} className="sh-app dark">
109
+ <PlayOverlay />
110
+ <div className="sh-toasts">{toasts.map((t) => <div className="sh-toast" key={t.id}>{t.text}</div>)}</div>
111
+ </div>
112
+ )
113
+ }