@marver-design/marver 0.11.1 → 0.13.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 +111 -0
- package/README.md +1 -1
- package/dist/auth-DdUKeVKt.mjs +1150 -0
- package/dist/{build-DI31MFTB.mjs → build-BxGrHFT2.mjs} +188 -33
- package/dist/cli.mjs +16 -7
- package/dist/collab-mAiNZlDj.mjs +970 -0
- package/dist/{comments-CRqaO2MH.mjs → comments-DHB_8BRa.mjs} +3 -2
- package/dist/{daemon-DSmaS453.mjs → daemon-BChkzDqQ.mjs} +3 -3
- package/dist/{dev-BWIL2ewj.mjs → dev-DLwt3Brb.mjs} +5 -5
- package/dist/events-B3LBn74P.mjs +103 -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-B2uraTHS.mjs} +100 -9
- package/dist/{plugin-DjBFxEQ7.mjs → plugin-DNc4Jpae.mjs} +82 -9
- package/dist/{profile-DcsJyppw.mjs → profile-BjAPAJSb.mjs} +1 -1
- package/dist/{serve-3APqaNSB.mjs → serve-EjEqsiYa.mjs} +70 -17
- package/dist/share-Gqo_Ygqw.mjs +158 -0
- package/dist/summary-C7CAypbB.mjs +211 -0
- package/dist/{sync-DELGomPk.mjs → sync-BZaCWqK-.mjs} +3 -102
- package/package.json +1 -1
- package/src/client/shell/App.tsx +62 -11
- package/src/client/shell/Comments.tsx +103 -22
- 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 +96 -11
- package/src/client/shell/hash.ts +19 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/mentions.ts +108 -1
- package/src/client/shell/sound.ts +32 -0
- package/src/client/shell/store.ts +66 -7
- package/src/client/shell/styles.css +68 -0
- package/src/shared/events.ts +20 -6
- package/dist/auth-B5yuwnOq.mjs +0 -494
- package/dist/collab-CWaG3Q4w.mjs +0 -340
- 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-B3LBn74P.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-B3LBn74P.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.13.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,16 +1,16 @@
|
|
|
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'
|
|
12
12
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
13
|
-
import { useComments } from './comments-store.ts'
|
|
13
|
+
import { avatarFallback, useComments } from './comments-store.ts'
|
|
14
14
|
import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
|
|
15
15
|
|
|
16
16
|
const commentsStore = () => useComments.getState()
|
|
@@ -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 />
|
|
@@ -1134,6 +1169,15 @@ function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
|
|
|
1134
1169
|
)
|
|
1135
1170
|
}
|
|
1136
1171
|
|
|
1172
|
+
/** The pill's face for a human notification - the author's picture, or the
|
|
1173
|
+
* house initials fallback (same ladder the thread avatars use). */
|
|
1174
|
+
function JamAvatar({ author }: { author: { name?: string; avatar?: string; id?: string; email?: string } }) {
|
|
1175
|
+
const { initials, hue } = avatarFallback(author)
|
|
1176
|
+
return author.avatar
|
|
1177
|
+
? <img className="sh-jam-ava" src={author.avatar} alt="" referrerPolicy="no-referrer" />
|
|
1178
|
+
: <span className="sh-jam-ava init" style={{ background: `hsl(${hue} 55% 45%)` }}>{initials}</span>
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1137
1181
|
/** A data-goto whose target frame is not on the current board follows the frame HOME:
|
|
1138
1182
|
* the first curated board (switcher rank) that pins it is switched to and the frame
|
|
1139
1183
|
* focused there - a link is navigation, and navigation never edits a board. Only a
|
|
@@ -1204,11 +1248,18 @@ async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void)
|
|
|
1204
1248
|
* `badge` shows the +N deck count (replacing View); `inert` disables inner clicks (the deck handles it). */
|
|
1205
1249
|
function JamToast({ id, note, badge, inert }: { id: number; note: import('./store.ts').JamNote; badge?: number; inert?: boolean }) {
|
|
1206
1250
|
const dismiss = () => useStore.getState().dismissToast(id)
|
|
1251
|
+
// human pills are person-first: their avatar, "name mentioned you"; the
|
|
1252
|
+
// agent pill keeps its frame-first shape with the mark
|
|
1253
|
+
const human = note.kind && note.author
|
|
1207
1254
|
return (
|
|
1208
1255
|
<div className="sh-toast jam">
|
|
1209
|
-
|
|
1256
|
+
{human
|
|
1257
|
+
? <JamAvatar author={note.author!} />
|
|
1258
|
+
: <span className="sh-jam-mark"><ParallelogramFillIcon size={17} /></span>}
|
|
1210
1259
|
<div className="sh-jam-txt">
|
|
1211
|
-
|
|
1260
|
+
{human
|
|
1261
|
+
? <b className="sh-jam-frame"><span className="t">{note.author!.name ?? 'Someone'}</span> <span className="v">{note.kind === 'mention' ? 'mentioned you' : 'replied'}</span></b>
|
|
1262
|
+
: <b className="sh-jam-frame"><span className="t">{note.frameTitle ?? note.board}</span></b>}
|
|
1212
1263
|
<span className="sh-jam-prev">{note.preview}</span>
|
|
1213
1264
|
</div>
|
|
1214
1265
|
{badge != null
|