@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.
- package/CHANGELOG.md +83 -5
- package/README.md +17 -4
- package/dist/auth-DdUKeVKt.mjs +1150 -0
- package/dist/{build-Cr_ZLMdq.mjs → build-DmfxJc6L.mjs} +188 -33
- package/dist/cli.mjs +16 -7
- package/dist/collab-BmaOjwP1.mjs +781 -0
- package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
- package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
- package/dist/{dev-CLBjRs0J.mjs → dev-BXq7yE1p.mjs} +5 -5
- package/dist/events-C9k-ceOj.mjs +101 -0
- package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
- package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
- package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
- package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-DOjnZakC.mjs} +94 -6
- package/dist/{plugin-vE7sxEeT.mjs → plugin-Boy7I_JH.mjs} +82 -9
- package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
- package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
- package/dist/{serve-CZ1KEQxd.mjs → serve-BeLLizNg.mjs} +72 -18
- package/dist/share-C64b1tdN.mjs +159 -0
- package/dist/summary-Bf9tiGrE.mjs +211 -0
- package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
- package/package.json +1 -1
- package/src/client/shell/App.tsx +43 -8
- package/src/client/shell/Comments.tsx +9 -5
- package/src/client/shell/LockedApp.tsx +113 -0
- package/src/client/shell/Play.tsx +97 -18
- package/src/client/shell/Toolbar.tsx +26 -2
- package/src/client/shell/canvas/Canvas.tsx +5 -15
- package/src/client/shell/canvas/ctl.ts +26 -0
- package/src/client/shell/comments-store.ts +39 -9
- package/src/client/shell/hash.ts +19 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +54 -3
- package/src/client/shell/styles.css +27 -0
- package/src/shared/events.ts +8 -3
- package/templates/instructions/publish.md +15 -1
- package/dist/auth-B5yuwnOq.mjs +0 -494
- package/dist/collab-C-n-uQdm.mjs +0 -364
- package/dist/manifest-C2tzkNaC.mjs +0 -233
- package/dist/update-DuWDj5nR.mjs +0 -77
- 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-
|
|
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,
|
|
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.
|
|
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,
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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
|
-
|
|
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
|
|
469
|
-
|
|
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
|
+
}
|