@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.
@@ -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.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
+ })
@@ -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, GridIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
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') s.interact ? setInteract(null) : select(null)
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
- if (e.key === 'c' && s.selection.length) {
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>