@marver-design/marver 0.3.0 → 0.4.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 +95 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-p3xmXU3b.mjs} +52 -12
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/{dev-C2oTKuXe.mjs → dev-DZi1yRhn.mjs} +17 -1
- package/dist/{init-DolP_Ld4.mjs → init-Ck8z-HiD.mjs} +1 -0
- package/dist/{plugin-Crp4CAma.mjs → plugin-CtQqO5ZZ.mjs} +114 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +2 -1
- package/src/client/frame-host/bridge.js +231 -0
- package/src/client/shell/App.tsx +73 -7
- package/src/client/shell/Comments.tsx +463 -0
- package/src/client/shell/Play.tsx +7 -1
- package/src/client/shell/canvas/FrameNode.tsx +32 -2
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/store.ts +4 -1
- package/src/client/shell/styles.css +179 -6
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/dist/serve-OtA9Nlow.mjs +0 -207
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-Ba8mU600.mjs";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
//#region src/shared/events.ts
|
|
6
|
+
/** Events the other side lacks, by id - the sync payload in either direction. */
|
|
7
|
+
function diffEvents(mine, theirIds) {
|
|
8
|
+
const have = new Set(theirIds);
|
|
9
|
+
return mine.filter((e) => !have.has(e.id));
|
|
10
|
+
}
|
|
11
|
+
/** Replay a board's events into current thread state. Deterministic: replay order is
|
|
12
|
+
* (ts, id) so two stores holding the same event SET always derive the same state.
|
|
13
|
+
* TWO passes - creates first, then everything else - so a reply whose author's clock
|
|
14
|
+
* ran ahead of the creator's still lands instead of being dropped forever. */
|
|
15
|
+
function replay(events) {
|
|
16
|
+
const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
|
|
17
|
+
const threads = /* @__PURE__ */ new Map();
|
|
18
|
+
for (const ev of ordered) {
|
|
19
|
+
if (ev.type !== "create") continue;
|
|
20
|
+
if (!ev.commentId || threads.has(ev.commentId)) continue;
|
|
21
|
+
threads.set(ev.commentId, {
|
|
22
|
+
id: ev.commentId,
|
|
23
|
+
board: ev.board,
|
|
24
|
+
nodeKey: ev.nodeKey,
|
|
25
|
+
frame: ev.frame,
|
|
26
|
+
anchor: ev.anchor,
|
|
27
|
+
author: ev.author,
|
|
28
|
+
body: ev.body,
|
|
29
|
+
ts: ev.ts,
|
|
30
|
+
resolved: false,
|
|
31
|
+
replies: [],
|
|
32
|
+
reactions: {}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
for (const ev of ordered) switch (ev.type) {
|
|
36
|
+
case "reply": {
|
|
37
|
+
const t = ev.parentId ? threads.get(ev.parentId) : void 0;
|
|
38
|
+
if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
|
|
39
|
+
t.replies.push({
|
|
40
|
+
id: ev.commentId,
|
|
41
|
+
author: ev.author,
|
|
42
|
+
body: ev.body,
|
|
43
|
+
ts: ev.ts
|
|
44
|
+
});
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case "edit": {
|
|
48
|
+
if (!ev.commentId || ev.body === void 0) break;
|
|
49
|
+
const t = threads.get(ev.commentId);
|
|
50
|
+
if (t) {
|
|
51
|
+
t.body = ev.body;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
for (const th of threads.values()) {
|
|
55
|
+
const r = th.replies.find((r) => r.id === ev.commentId);
|
|
56
|
+
if (r) {
|
|
57
|
+
r.body = ev.body;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case "resolve": {
|
|
64
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
65
|
+
if (t) {
|
|
66
|
+
t.resolved = true;
|
|
67
|
+
if (ev.addressedIn) t.addressedIn = ev.addressedIn;
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "reopen": {
|
|
72
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
73
|
+
if (t) {
|
|
74
|
+
t.resolved = false;
|
|
75
|
+
t.addressedIn = void 0;
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "react": {
|
|
80
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
81
|
+
const who = ev.author?.email;
|
|
82
|
+
if (!t || !who || !ev.emoji) break;
|
|
83
|
+
const users = t.reactions[ev.emoji] ??= [];
|
|
84
|
+
const at = users.indexOf(who);
|
|
85
|
+
if (at === -1) users.push(who);
|
|
86
|
+
else {
|
|
87
|
+
users.splice(at, 1);
|
|
88
|
+
if (!users.length) delete t.reactions[ev.emoji];
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...threads.values()];
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/server/sync.ts
|
|
97
|
+
/**
|
|
98
|
+
* Dev ↔ published comment sync (SPEC-M3 §2) - one merge rule, run from the dev side.
|
|
99
|
+
*
|
|
100
|
+
* The published serve is the canonical home; the repo's design/comments/ is the
|
|
101
|
+
* mirror the agent reads. Each exchange is: pull remote events → union into local
|
|
102
|
+
* files; push the local events the remote lacks (chunked - the server unions too).
|
|
103
|
+
* Idempotent and retry-safe by construction, so a dropped exchange costs nothing.
|
|
104
|
+
*
|
|
105
|
+
* Credentials: design/.local/collab.json {url, token} written by `comments connect`.
|
|
106
|
+
* The token is a server session held by the DEV PROCESS only - pages never see it.
|
|
107
|
+
*/
|
|
108
|
+
var sync_exports = /* @__PURE__ */ __exportAll({
|
|
109
|
+
connect: () => connect,
|
|
110
|
+
connectClaim: () => connectClaim,
|
|
111
|
+
loadCollab: () => loadCollab,
|
|
112
|
+
saveCollab: () => saveCollab,
|
|
113
|
+
syncOnce: () => syncOnce
|
|
114
|
+
});
|
|
115
|
+
const collabFile = (root) => join(root, "design", ".local", "collab.json");
|
|
116
|
+
function loadCollab(root) {
|
|
117
|
+
try {
|
|
118
|
+
const c = JSON.parse(readFileSync(collabFile(root), "utf8"));
|
|
119
|
+
return typeof c?.url === "string" && typeof c?.token === "string" ? c : null;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function saveCollab(root, collab) {
|
|
125
|
+
mkdirSync(join(root, "design", ".local"), { recursive: true });
|
|
126
|
+
writeFileSync(collabFile(root), JSON.stringify(collab, null, 2) + "\n", { mode: 384 });
|
|
127
|
+
}
|
|
128
|
+
/** One full exchange. Returns per-board counts, or throws on auth/network failure. */
|
|
129
|
+
async function syncOnce(root, collab) {
|
|
130
|
+
const dir = join(root, "design", "comments");
|
|
131
|
+
const auth = { authorization: `Bearer ${collab.token}` };
|
|
132
|
+
const base = collab.url.replace(/\/+$/, "");
|
|
133
|
+
const bres = await fetch(`${base}/__mv/api/boards`, { headers: auth });
|
|
134
|
+
if (bres.status === 401) throw new Error("the connect token was rejected - run `comments connect` again");
|
|
135
|
+
if (!bres.ok) throw new Error(`published canvas answered ${bres.status} - is it up?`);
|
|
136
|
+
const { rights } = await bres.json();
|
|
137
|
+
const boards = [.../* @__PURE__ */ new Set([...Object.keys(rights), ...listBoards(dir)])];
|
|
138
|
+
const out = {};
|
|
139
|
+
const failures = [];
|
|
140
|
+
for (const board of boards) {
|
|
141
|
+
if (!(board in rights)) continue;
|
|
142
|
+
const res = await fetch(`${base}/__mv/api/comments/${board}`, { headers: auth });
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
failures.push(`${board}: pull ${res.status}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const { events: remote } = await res.json();
|
|
148
|
+
const pulled = appendEvents(dir, board, remote).length;
|
|
149
|
+
let pushed = 0;
|
|
150
|
+
if (rights[board] === "comment") {
|
|
151
|
+
const missing = diffEvents(readLog(dir, board), remote.map((e) => e.id));
|
|
152
|
+
for (let i = 0; i < missing.length; i += 100) {
|
|
153
|
+
const r = await fetch(`${base}/__mv/api/comments/${board}`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
...auth,
|
|
157
|
+
"content-type": "application/json"
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({ events: missing.slice(i, i + 100) })
|
|
160
|
+
});
|
|
161
|
+
if (r.ok) pushed += (await r.json()).accepted ?? 0;
|
|
162
|
+
else failures.push(`${board}: push ${r.status} ${(await r.json().catch(() => null))?.error ?? ""}`.trim());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
out[board] = {
|
|
166
|
+
pulled,
|
|
167
|
+
pushed
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (failures.length) throw new Error(`sync incomplete - ${failures.join(" · ")}`);
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** The account endpoints live BEHIND the canvas gate (the outer READ boundary), so
|
|
174
|
+
* connecting passes the gate first when a canvas password is set. */
|
|
175
|
+
async function gateCookie(base, canvasPassword) {
|
|
176
|
+
if (!canvasPassword) return "";
|
|
177
|
+
const cookie = (await fetch(`${base}/__mv/auth`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
redirect: "manual",
|
|
180
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
181
|
+
body: `password=${encodeURIComponent(canvasPassword)}&next=`
|
|
182
|
+
})).headers.getSetCookie?.().find((c) => c.startsWith("mv_a=")) ?? "";
|
|
183
|
+
const tok = /^mv_a=([^;]+)/.exec(cookie)?.[1];
|
|
184
|
+
if (!tok) throw new Error("the canvas password was not accepted");
|
|
185
|
+
return `mv_a=${tok}`;
|
|
186
|
+
}
|
|
187
|
+
async function sessionFrom(res, ctx) {
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const err = (await res.json().catch(() => null))?.error;
|
|
190
|
+
throw new Error(err ?? `${ctx} failed (${res.status})`);
|
|
191
|
+
}
|
|
192
|
+
const cookie = res.headers.getSetCookie?.().find((c) => c.startsWith("mv_s=")) ?? "";
|
|
193
|
+
const token = /^mv_s=([\w-]+)/.exec(cookie)?.[1];
|
|
194
|
+
if (!token) throw new Error("the canvas did not issue a session - is collaboration enabled on it (MARVER_DATA_DIR)?");
|
|
195
|
+
return token;
|
|
196
|
+
}
|
|
197
|
+
/** Sign in against a published canvas and persist the device credential. The account's
|
|
198
|
+
* identity rides along: locally-born events carry it as their author snapshot (the
|
|
199
|
+
* server validates the claim against the session - it never rewrites events). */
|
|
200
|
+
async function connect(root, url, email, password, canvasPassword) {
|
|
201
|
+
const base = url.replace(/\/+$/, "");
|
|
202
|
+
const gate = await gateCookie(base, canvasPassword);
|
|
203
|
+
const res = await fetch(`${base}/__mv/api/auth/signin`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: {
|
|
206
|
+
"content-type": "application/json",
|
|
207
|
+
...gate ? { cookie: gate } : {}
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
email,
|
|
211
|
+
password
|
|
212
|
+
})
|
|
213
|
+
});
|
|
214
|
+
const token = await sessionFrom(res, "sign-in");
|
|
215
|
+
const user = (await res.clone().json().catch(() => null))?.user;
|
|
216
|
+
saveCollab(root, {
|
|
217
|
+
url: base,
|
|
218
|
+
token,
|
|
219
|
+
email: user?.email ?? email,
|
|
220
|
+
name: user?.name
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** Claim an invite from the CLI (the dev-first path - no published UI needed). */
|
|
224
|
+
async function connectClaim(root, url, invite, profile, canvasPassword) {
|
|
225
|
+
const base = url.replace(/\/+$/, "");
|
|
226
|
+
const gate = await gateCookie(base, canvasPassword);
|
|
227
|
+
const res = await fetch(`${base}/__mv/api/auth/claim`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: {
|
|
230
|
+
"content-type": "application/json",
|
|
231
|
+
...gate ? { cookie: gate } : {}
|
|
232
|
+
},
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
token: invite,
|
|
235
|
+
...profile
|
|
236
|
+
})
|
|
237
|
+
});
|
|
238
|
+
const token = await sessionFrom(res, "claim");
|
|
239
|
+
const user = (await res.clone().json().catch(() => null))?.user;
|
|
240
|
+
saveCollab(root, {
|
|
241
|
+
url: base,
|
|
242
|
+
token,
|
|
243
|
+
email: user?.email,
|
|
244
|
+
name: user?.name ?? profile.name
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
export { sync_exports as a, syncOnce as i, connectClaim as n, replay as o, loadCollab 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.4.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. The tool ships no AI - your coding agent is the designer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
13
|
"src/client",
|
|
14
|
+
"src/shared",
|
|
14
15
|
"templates",
|
|
15
16
|
"README.md",
|
|
16
17
|
"LICENSE",
|
|
@@ -16,6 +16,9 @@ if (isHtmlFrame) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
document.addEventListener('click', (e) => {
|
|
19
|
+
// laser/comment mode owns every click - a goto link must not ALSO navigate
|
|
20
|
+
// (both handlers capture on document, so stopPropagation can't referee)
|
|
21
|
+
if (modeActive()) return
|
|
19
22
|
const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
|
|
20
23
|
if (!el) return
|
|
21
24
|
e.preventDefault()
|
|
@@ -41,3 +44,231 @@ if (isHtmlFrame) {
|
|
|
41
44
|
// iframe's document, so the shell's blocker cannot see them). Keyboard cmd +/- untouched.
|
|
42
45
|
window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false })
|
|
43
46
|
document.addEventListener('gesturestart', (e) => e.preventDefault())
|
|
47
|
+
|
|
48
|
+
// ---- laser mode + element picking (SPEC-M3 §5, §7) ----------------------------------
|
|
49
|
+
// Laser: one injected stylesheet, outline only (zero layout shift), depth-based hue -
|
|
50
|
+
// each nesting level steps 60° around the wheel, cycling at 6. Picking: laser plus a
|
|
51
|
+
// click interception that captures the anchor bundle and posts it to the shell.
|
|
52
|
+
|
|
53
|
+
const LASER_ID = 'mv-laser-style'
|
|
54
|
+
// comment-mode cursor: the pin's teardrop in the comment green, duotone (dark rim,
|
|
55
|
+
// lighter inner) with a white halo ring so it pops on any content; hotspot at the tail
|
|
56
|
+
const PICK_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 256 256'%3E%3Cpath d='M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Z' fill='none' stroke='%23fff' stroke-width='40'/%3E%3Cpath d='M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Z' fill='%2334c759' stroke='%231f8a3d' stroke-width='12'/%3E%3Ccircle cx='138' cy='118' r='46' fill='%23fff' opacity='.32'/%3E%3C/svg%3E") 4 21, crosshair`
|
|
57
|
+
// laser-mode cursor: the crosshair reticle in accent blue with a white halo ring,
|
|
58
|
+
// hotspot dead center
|
|
59
|
+
const LASER_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 256 256'%3E%3Cg stroke='%23fff' stroke-width='46' fill='none'%3E%3Ccircle cx='128' cy='128' r='56'/%3E%3Cpath d='M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232' stroke-linecap='round'/%3E%3C/g%3E%3Cg stroke='%230088ff' stroke-width='20' fill='none'%3E%3Ccircle cx='128' cy='128' r='56'/%3E%3Cpath d='M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232' stroke-linecap='round'/%3E%3C/g%3E%3Ccircle cx='128' cy='128' r='16' fill='%230088ff' stroke='%23fff' stroke-width='8'/%3E%3C/svg%3E") 12 12, crosshair`
|
|
60
|
+
const laserCss = () => {
|
|
61
|
+
// depth via unrolled descendant combinators - CSS custom properties cannot cycle
|
|
62
|
+
let rules = 'body { --mv-hue: 0 }\n'
|
|
63
|
+
for (let d = 1; d <= 12; d++)
|
|
64
|
+
rules += `body ${'> * '.repeat(d)}{ --mv-hue: ${(d % 6) * 60} }\n`
|
|
65
|
+
// full rainbow only in laser mode; comment mode keeps just the hover highlight
|
|
66
|
+
// (understand what you'd click without the whole board shouting). The label and
|
|
67
|
+
// its children are chrome, never subjects - no outlines on them.
|
|
68
|
+
if (laserOn)
|
|
69
|
+
rules += 'body *:not(script):not(style):not(#mv-laser-label):not(#mv-laser-label *) { outline: 1px solid hsl(var(--mv-hue) 85% 55% / .75); outline-offset: -1px }\n'
|
|
70
|
+
// comment cursor wins when both modes are on (comment owns the click)
|
|
71
|
+
if (pickOn || laserOn)
|
|
72
|
+
rules += `body, body * { cursor: ${pickOn ? PICK_CURSOR : LASER_CURSOR} !important }\n`
|
|
73
|
+
return rules + `
|
|
74
|
+
body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-offset: -2px;
|
|
75
|
+
background-image: linear-gradient(hsl(var(--mv-hue) 95% 50% / .08), hsl(var(--mv-hue) 95% 50% / .08)) }
|
|
76
|
+
#mv-laser-label { position: fixed; z-index: 2147483647; pointer-events: none; outline: none !important;
|
|
77
|
+
display: flex; align-items: center; gap: 5px; width: max-content;
|
|
78
|
+
font: 600 10px -apple-system, system-ui, sans-serif; color: #fff; background: rgba(20, 20, 24, .92);
|
|
79
|
+
padding: 3px 7px; border-radius: 5px; max-width: 340px; white-space: nowrap; overflow: hidden;
|
|
80
|
+
text-overflow: ellipsis; letter-spacing: .01em }
|
|
81
|
+
#mv-laser-label svg { flex: none; display: block; outline: none !important }
|
|
82
|
+
#mv-laser-label.mv-copied { animation: mv-pop .22s cubic-bezier(.32, .72, .35, 1) }
|
|
83
|
+
@keyframes mv-pop { from { transform: scale(.85); opacity: .5 } }
|
|
84
|
+
`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// filled clipboard with a check inside (Phosphor clipboard-fill + check): the label
|
|
88
|
+
// wears this while confirming a copy
|
|
89
|
+
const COPIED_HTML = `<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor"><path d="M200,32H163.74a47.92,47.92,0,0,0-71.48,0H56A16,16,0,0,0,40,48V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Zm-72,0a32,32,0,0,1,32,32H96A32,32,0,0,1,128,32Z"/><path d="M92 150l26 26 48-54" fill="none" stroke="rgba(20,20,24,.92)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Element path copied</span>`
|
|
90
|
+
|
|
91
|
+
let laserOn = false, pickOn = false, hoverEl = null, labelEl = null
|
|
92
|
+
const modeActive = () => laserOn || pickOn
|
|
93
|
+
|
|
94
|
+
// laser and pick are independent looks over shared hover machinery: the stylesheet
|
|
95
|
+
// is regenerated on every flip so each mode contributes exactly its own rules
|
|
96
|
+
const applyModes = () => {
|
|
97
|
+
const cur = document.getElementById(LASER_ID)
|
|
98
|
+
if (!modeActive()) {
|
|
99
|
+
if (cur) cur.remove()
|
|
100
|
+
clearHover()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const s = cur ?? document.createElement('style')
|
|
104
|
+
s.id = LASER_ID
|
|
105
|
+
s.textContent = laserCss()
|
|
106
|
+
if (!cur) document.head.appendChild(s)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let copiedTimer = null
|
|
110
|
+
|
|
111
|
+
const clearHover = () => {
|
|
112
|
+
if (hoverEl) { delete hoverEl.dataset.mvHover; hoverEl = null }
|
|
113
|
+
if (labelEl) { labelEl.remove(); labelEl = null }
|
|
114
|
+
clearTimeout(copiedTimer)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// the shell confirmed the clipboard write - the hover label ITSELF says so (a
|
|
118
|
+
// corner toast is too far from where the eyes are), then reverts after 2s.
|
|
119
|
+
// The seq + element check keeps a slow ack from decorating a DIFFERENT label
|
|
120
|
+
// (pointer moved on, or laser was retoggled, before writeText resolved).
|
|
121
|
+
const showCopied = (seq) => {
|
|
122
|
+
if (seq !== copySeq || hoverEl !== copyEl) return
|
|
123
|
+
if (!labelEl) return
|
|
124
|
+
labelEl.classList.add('mv-copied')
|
|
125
|
+
labelEl.innerHTML = COPIED_HTML
|
|
126
|
+
clearTimeout(copiedTimer)
|
|
127
|
+
copiedTimer = setTimeout(() => {
|
|
128
|
+
if (!labelEl) return
|
|
129
|
+
labelEl.classList.remove('mv-copied')
|
|
130
|
+
if (hoverEl) labelEl.textContent = describe(hoverEl)
|
|
131
|
+
else { labelEl.remove(); labelEl = null }
|
|
132
|
+
}, 2000)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const describe = (el) => {
|
|
136
|
+
const tag = el.tagName.toLowerCase()
|
|
137
|
+
const loc = el.dataset.mvLoc
|
|
138
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
|
|
139
|
+
return loc ? `${tag} · ${loc}` : text ? `${tag} · “${text}”` : tag
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
document.addEventListener('mousemove', (e) => {
|
|
143
|
+
if (!modeActive()) return
|
|
144
|
+
const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
|
|
145
|
+
if (!el || el === hoverEl) { if (labelEl && hoverEl) place(labelEl, e); return }
|
|
146
|
+
clearHover()
|
|
147
|
+
hoverEl = el
|
|
148
|
+
el.dataset.mvHover = '1'
|
|
149
|
+
labelEl = document.createElement('div')
|
|
150
|
+
labelEl.id = 'mv-laser-label'
|
|
151
|
+
labelEl.textContent = describe(el)
|
|
152
|
+
document.body.appendChild(labelEl)
|
|
153
|
+
place(labelEl, e)
|
|
154
|
+
}, true)
|
|
155
|
+
// label rides to the RIGHT of the cursor, vertically centered on it - below-right
|
|
156
|
+
// hid the pointer's target; flips to the left when the right edge runs out
|
|
157
|
+
const place = (label, e) => {
|
|
158
|
+
const pad = 18
|
|
159
|
+
const w = label.offsetWidth, h = label.offsetHeight
|
|
160
|
+
let x = e.clientX + pad
|
|
161
|
+
if (x + w > innerWidth - 4) x = Math.max(4, e.clientX - pad - w)
|
|
162
|
+
label.style.left = x + 'px'
|
|
163
|
+
label.style.top = Math.min(Math.max(4, e.clientY - h / 2), innerHeight - h - 4) + 'px'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// the anchor bundle (SPEC-M3 §5): every rung captured at pick time
|
|
167
|
+
const cssPath = (el) => {
|
|
168
|
+
const seg = []
|
|
169
|
+
for (let cur = el; cur && cur !== document.body; cur = cur.parentElement) {
|
|
170
|
+
if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
|
|
171
|
+
const tag = cur.tagName.toLowerCase()
|
|
172
|
+
let n = 1
|
|
173
|
+
for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
|
|
174
|
+
if (sib.tagName === cur.tagName) n++
|
|
175
|
+
seg.unshift(n > 1 ? `${tag}:nth-of-type(${n})` : tag)
|
|
176
|
+
}
|
|
177
|
+
return seg.join(' > ')
|
|
178
|
+
}
|
|
179
|
+
const anchorBundle = (el, e) => {
|
|
180
|
+
const r = el.getBoundingClientRect()
|
|
181
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
182
|
+
return {
|
|
183
|
+
el: {
|
|
184
|
+
semantics: {
|
|
185
|
+
tag: el.tagName.toLowerCase(),
|
|
186
|
+
role: el.getAttribute('role') ?? undefined,
|
|
187
|
+
ariaLabel: el.getAttribute('aria-label') ?? undefined,
|
|
188
|
+
testId: el.getAttribute('data-testid') ?? undefined,
|
|
189
|
+
quote: text.slice(0, 200) || undefined,
|
|
190
|
+
},
|
|
191
|
+
cssPath: cssPath(el),
|
|
192
|
+
source: el.dataset.mvLoc ?? undefined,
|
|
193
|
+
},
|
|
194
|
+
pos: {
|
|
195
|
+
fx: r.width ? Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) : .5,
|
|
196
|
+
fy: r.height ? Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)) : .5,
|
|
197
|
+
},
|
|
198
|
+
rect: { x: r.left, y: r.top, w: r.width, h: r.height }, // frame-viewport coords for the shell's pin math
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// every press is reported (tiny message, capture, nothing prevented): the shell
|
|
203
|
+
// uses it to close an open thread card when the click lands INSIDE a frame -
|
|
204
|
+
// shell-document listeners can never see those
|
|
205
|
+
document.addEventListener('pointerdown', () => post({ type: 'sh:frame-down', id }), true)
|
|
206
|
+
|
|
207
|
+
let copySeq = 0, copyEl = null
|
|
208
|
+
document.addEventListener('click', (e) => {
|
|
209
|
+
if (!modeActive()) return
|
|
210
|
+
e.preventDefault()
|
|
211
|
+
e.stopPropagation()
|
|
212
|
+
const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
|
|
213
|
+
if (!el) return
|
|
214
|
+
// comment mode wins when both are on; plain laser click hands the agent an
|
|
215
|
+
// exact address - frame file + css path (+ source loc when the build stamps one)
|
|
216
|
+
if (pickOn) post({ type: 'sh:picked', id, anchor: anchorBundle(el, e) })
|
|
217
|
+
else {
|
|
218
|
+
copyEl = el
|
|
219
|
+
post({ type: 'sh:laser-copy', id, seq: ++copySeq, path: cssPath(el), source: el.dataset.mvLoc ?? null })
|
|
220
|
+
}
|
|
221
|
+
}, true)
|
|
222
|
+
|
|
223
|
+
/** Resolve a stored anchor back to a rect (the ladder, §5): semantics-verified CSS
|
|
224
|
+
* path first, then testId, then a quote scan; null = orphan. Every check compares
|
|
225
|
+
* the FULL captured semantics - two textless buttons must not swap silently. */
|
|
226
|
+
const resolveAnchor = (anchor) => {
|
|
227
|
+
const want = anchor?.el?.semantics ?? {}
|
|
228
|
+
const match = (el) => {
|
|
229
|
+
if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
|
|
230
|
+
if (want.role && el.getAttribute('role') !== want.role) return false
|
|
231
|
+
if (want.ariaLabel && el.getAttribute('aria-label') !== want.ariaLabel) return false
|
|
232
|
+
if (want.testId && el.getAttribute('data-testid') !== want.testId) return false
|
|
233
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
234
|
+
if (want.quote && !(text.startsWith(want.quote.slice(0, 60)) || text.includes(want.quote.slice(0, 40)))) return false
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const byPath = anchor?.el?.cssPath && document.querySelector(anchor.el.cssPath)
|
|
239
|
+
if (byPath && match(byPath)) return byPath
|
|
240
|
+
} catch { /* stale selector */ }
|
|
241
|
+
try {
|
|
242
|
+
if (want.testId) {
|
|
243
|
+
const el = document.querySelector(`[data-testid="${CSS.escape(want.testId)}"]`)
|
|
244
|
+
if (el && match(el)) return el
|
|
245
|
+
}
|
|
246
|
+
if (want.quote && typeof want.tag === 'string' && /^[a-z][a-z0-9-]*$/.test(want.tag)) {
|
|
247
|
+
for (const el of document.querySelectorAll(want.tag))
|
|
248
|
+
if (match(el)) return el
|
|
249
|
+
}
|
|
250
|
+
} catch { /* malformed semantics must not sink the whole batch */ }
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
window.addEventListener('message', (e) => {
|
|
255
|
+
// commands come from the SHELL only - the parent window. Anything else (nested
|
|
256
|
+
// third-party iframes, a hijacked opener) is ignored.
|
|
257
|
+
if (e.source !== window.parent || window.parent === window) return
|
|
258
|
+
const m = e?.data
|
|
259
|
+
if (!m || typeof m !== 'object') return
|
|
260
|
+
// independent toggles - each mode contributes its own rules, applyModes composes
|
|
261
|
+
if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes() }
|
|
262
|
+
if (m.type === 'sh:pick') { pickOn = !!m.on; applyModes() }
|
|
263
|
+
if (m.type === 'sh:copy-ok') showCopied(m.seq)
|
|
264
|
+
if (m.type === 'sh:resolve-anchors' && Array.isArray(m.anchors)) {
|
|
265
|
+
const rects = m.anchors.slice(0, 200).map((a) => {
|
|
266
|
+
let el = null
|
|
267
|
+
try { el = resolveAnchor(a.anchor) } catch { /* one bad anchor, not the batch */ }
|
|
268
|
+
if (!el) return { key: a.key, orphan: true }
|
|
269
|
+
const r = el.getBoundingClientRect()
|
|
270
|
+
return { key: a.key, rect: { x: r.left, y: r.top, w: r.width, h: r.height } }
|
|
271
|
+
})
|
|
272
|
+
post({ type: 'sh:anchor-rects', id, rects })
|
|
273
|
+
}
|
|
274
|
+
})
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -6,7 +6,11 @@ import { PKG, ROUTE } from '../const.ts'
|
|
|
6
6
|
import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
7
7
|
import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
8
8
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
9
|
-
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, FrameRectIcon,
|
|
9
|
+
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, CommentIcon, DevicesIcon, FrameRectIcon, IntentGlyph, LaserIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
10
|
+
import { CommentsController } from './Comments.tsx'
|
|
11
|
+
import { useComments } from './comments-store.ts'
|
|
12
|
+
|
|
13
|
+
const commentsStore = () => useComments.getState()
|
|
10
14
|
|
|
11
15
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
12
16
|
|
|
@@ -375,7 +379,8 @@ function ThemeMenu() {
|
|
|
375
379
|
}
|
|
376
380
|
|
|
377
381
|
export function App() {
|
|
378
|
-
const { manifest, nodes, panelOpen, toasts, selection } = useStore()
|
|
382
|
+
const { manifest, nodes, panelOpen, toasts, selection, laser } = useStore()
|
|
383
|
+
const commentMode = useComments((s) => s.commentMode)
|
|
379
384
|
const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
|
|
380
385
|
const [pillOpen, setPillOpen] = useState(true)
|
|
381
386
|
|
|
@@ -539,6 +544,31 @@ export function App() {
|
|
|
539
544
|
// measureNode does the rest of the admission (content frames only,
|
|
540
545
|
// frame-id match, finite positive, clamped)
|
|
541
546
|
s.measureNode(nodeKey, String(data.frame ?? ''), Number(data.ownWidth), Number(data.measuredWidth), Number(data.height))
|
|
547
|
+
} else if (data.type === 'sh:laser-copy') {
|
|
548
|
+
// laser click = copy the element's full address for the agent:
|
|
549
|
+
// frame source file + css path inside it (+ jsx source loc when stamped)
|
|
550
|
+
const n = s.nodes.find((x) => x.key === nodeKey)
|
|
551
|
+
const f = n && s.frameFor(n)
|
|
552
|
+
if (f) {
|
|
553
|
+
const addr = `${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
|
|
554
|
+
// success confirms IN the frame's hover label (right where the eyes are);
|
|
555
|
+
// only failure needs the toast
|
|
556
|
+
navigator.clipboard.writeText(addr).then(
|
|
557
|
+
() => el.contentWindow?.postMessage({ type: 'sh:copy-ok', seq: data.seq }, location.origin),
|
|
558
|
+
() => toast('copy blocked - click the canvas first'))
|
|
559
|
+
}
|
|
560
|
+
} else if (data.type === 'sh:frame-down') {
|
|
561
|
+
// clicks INSIDE a frame never reach the shell document - the frame reports
|
|
562
|
+
// them so an open thread card dismisses modal-style from anywhere
|
|
563
|
+
const c = commentsStore()
|
|
564
|
+
if (c.active) c.setActive(null)
|
|
565
|
+
} else if (data.type === 'sh:picked') {
|
|
566
|
+
// comment mode: the frame reports the picked element - stage the draft on
|
|
567
|
+
// that node (picking while a thread is open replaces it, modal-style);
|
|
568
|
+
// the CommentLayer opens the composer at the pin
|
|
569
|
+
const c = commentsStore()
|
|
570
|
+
if (c.active) c.setActive(null)
|
|
571
|
+
if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
|
|
542
572
|
} else if (data.type === 'sh:go') {
|
|
543
573
|
const target = String(data.target ?? '')
|
|
544
574
|
const existing = s.nodes.find((n) => n.frame === target && !n.missing)
|
|
@@ -565,10 +595,29 @@ export function App() {
|
|
|
565
595
|
if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); setPillOpen((o) => !o); return }
|
|
566
596
|
if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); s.selectAll(); return }
|
|
567
597
|
if (e.metaKey || e.ctrlKey) return
|
|
568
|
-
if (e.key === 'Escape')
|
|
598
|
+
if (e.key === 'Escape') {
|
|
599
|
+
const c = commentsStore()
|
|
600
|
+
if (c.commentMode || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null) }
|
|
601
|
+
else s.interact ? setInteract(null) : select(null)
|
|
602
|
+
}
|
|
569
603
|
if (e.key === 'p') enterPlay()
|
|
570
604
|
if (e.key === 't') { animateLayout(); runTidy() }
|
|
571
|
-
|
|
605
|
+
// laser and comment mode are one-at-a-time: comment mode already highlights
|
|
606
|
+
// what you'd click, so stacking the full rainbow on top only adds noise
|
|
607
|
+
if (e.key === 'l') {
|
|
608
|
+
if (!s.laser) commentsStore().setMode(false)
|
|
609
|
+
s.setLaser(!s.laser)
|
|
610
|
+
}
|
|
611
|
+
// C = comment mode (SPEC-M3 §6, the Figma/Miro convention) · Shift+C = hide/show
|
|
612
|
+
// pins · copy-file-paths moved to Y (changelog 0.4.0)
|
|
613
|
+
if (e.key === 'c' && !e.shiftKey) {
|
|
614
|
+
const c = commentsStore()
|
|
615
|
+
if (!c.commentMode) s.setLaser(false)
|
|
616
|
+
c.setMode(!c.commentMode)
|
|
617
|
+
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
618
|
+
}
|
|
619
|
+
if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
|
|
620
|
+
if (e.key === 'y' && s.selection.length) {
|
|
572
621
|
const files = s.selection
|
|
573
622
|
.map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
|
|
574
623
|
.filter((f): f is string => !!f)
|
|
@@ -763,13 +812,29 @@ export function App() {
|
|
|
763
812
|
|
|
764
813
|
{/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
|
|
765
814
|
<nav className={`sh-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
|
|
815
|
+
{/* far-left section: single actions - comment mode, laser, tidy */}
|
|
816
|
+
<Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
|
|
817
|
+
<button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
|
|
818
|
+
const c = commentsStore()
|
|
819
|
+
if (!c.commentMode) useStore.getState().setLaser(false)
|
|
820
|
+
c.setMode(!c.commentMode)
|
|
821
|
+
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
822
|
+
}}><CommentIcon size={16} /></button>
|
|
823
|
+
</Tip>
|
|
824
|
+
<Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
|
|
825
|
+
<button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
|
|
826
|
+
if (!laser) commentsStore().setMode(false)
|
|
827
|
+
useStore.getState().setLaser(!laser)
|
|
828
|
+
}}><LaserIcon size={16} /></button>
|
|
829
|
+
</Tip>
|
|
830
|
+
<Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
|
|
831
|
+
<button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><ColumnsIcon size={16} /></button>
|
|
832
|
+
</Tip>
|
|
833
|
+
<i className="sep" />
|
|
766
834
|
<DeviceMenu />
|
|
767
835
|
<ThemeMenu />
|
|
768
836
|
<i className="sep" />
|
|
769
837
|
<ZoomMenu />
|
|
770
|
-
<Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
|
|
771
|
-
<button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><GridIcon size={16} /></button>
|
|
772
|
-
</Tip>
|
|
773
838
|
<i className="sep" />
|
|
774
839
|
<Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
|
|
775
840
|
<button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
|
|
@@ -786,6 +851,7 @@ export function App() {
|
|
|
786
851
|
</Tip>
|
|
787
852
|
|
|
788
853
|
<PlayOverlay />
|
|
854
|
+
<CommentsController />
|
|
789
855
|
|
|
790
856
|
{CONFIG.setup
|
|
791
857
|
? <div className="sh-banner">no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart</div>
|