@marver-design/marver 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/{build-BZaPa2DS.mjs → build-B8z902TW.mjs} +2 -2
  3. package/dist/cli.mjs +5 -5
  4. package/dist/{collab-CXy8gqoz.mjs → collab-pLGzSvm5.mjs} +16 -5
  5. package/dist/{comments-odHzYdO3.mjs → comments-BrpC86Kf.mjs} +9 -15
  6. package/dist/{comments-Ba8mU600.mjs → comments-DSwMhdbZ.mjs} +2 -8
  7. package/dist/daemon-C-huGHAM.mjs +899 -0
  8. package/dist/{dev-DaPQ9xA5.mjs → dev-BYm9ebfN.mjs} +14 -3
  9. package/dist/events-BMtBvvgU.mjs +101 -0
  10. package/dist/{init-DsCUmlCW.mjs → init-DrLomSWq.mjs} +13 -1
  11. package/dist/ledger-wFvEIEGi.mjs +64 -0
  12. package/dist/{manifest-C8FODq2S.mjs → manifest-D3eaARf4.mjs} +16 -1
  13. package/dist/{plugin-wMY9lNf3.mjs → plugin-BVFuRfEo.mjs} +56 -40
  14. package/dist/profile-BkiWglVE.mjs +39 -0
  15. package/dist/{serve-D_KBK7Oy.mjs → serve-CwAfayJk.mjs} +3 -3
  16. package/dist/{sync-CkBk-tUk.mjs → sync-Ch4Bymb1.mjs} +4 -102
  17. package/package.json +1 -1
  18. package/src/client/frame-host/bridge.js +28 -242
  19. package/src/client/frame-host/inspect.d.ts +6 -0
  20. package/src/client/frame-host/inspect.js +359 -0
  21. package/src/client/shell/App.tsx +107 -110
  22. package/src/client/shell/Comments.tsx +465 -70
  23. package/src/client/shell/Play.tsx +218 -155
  24. package/src/client/shell/Toolbar.tsx +201 -0
  25. package/src/client/shell/canvas/FrameNode.tsx +59 -8
  26. package/src/client/shell/comments-store.ts +47 -6
  27. package/src/client/shell/icons.tsx +5 -0
  28. package/src/client/shell/mentions.ts +18 -0
  29. package/src/client/shell/store.ts +30 -2
  30. package/src/client/shell/styles.css +356 -30
  31. package/src/client/stage/main.tsx +35 -11
  32. package/src/shared/events.ts +22 -5
  33. package/templates/AGENTS-embedded.md +1 -0
  34. package/templates/AGENTS-studio.md +1 -0
  35. package/templates/instructions/configure.md +15 -0
  36. package/templates/instructions/jam.md +85 -0
  37. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -0,0 +1,359 @@
1
+ // Shared frame-inspect controller (SPEC-M3 §5/§7 + prototype-review #4/#5).
2
+ //
3
+ // Laser outlines, comment-pick, anchor capture/resolve, and a persistent element
4
+ // lock ("highlight") - the review machinery shared by the canvas frame bridge
5
+ // (bridge.js, one iframe per frame) AND the prototype stage (stage/main.tsx, one
6
+ // persistent tree that swaps frames in place). Keeping ONE source means the two
7
+ // surfaces never drift.
8
+ //
9
+ // The HOST owns wheel + navigation ownership, theme, and error wiring; inspect only
10
+ // touches laser/pick/anchor/highlight. It self-installs the pointer + message
11
+ // listeners and returns { modeActive } so the host can guard its own nav clicks.
12
+ //
13
+ // createInspect({ post, getId, onModeChange? })
14
+ // post(msg) send a message up to the shell (parent)
15
+ // getId() the frame id to stamp on posts (static on canvas, the
16
+ // current stage frame in play - it swaps without reload)
17
+ // onModeChange?() called after any laser/pick flip (the canvas bridge uses it
18
+ // to report its interaction lease; the stage ignores it)
19
+ //
20
+ // shell -> frame : sh:laser · sh:pick · sh:copy-ok · sh:resolve-anchors · sh:highlight-anchor
21
+ // frame -> shell : sh:frame-down · sh:picked · sh:laser-copy · sh:anchor-rects
22
+
23
+ // comment-mode cursor: the pin's teardrop in the comment green, duotone (dark rim,
24
+ // lighter inner) with a white halo ring so it pops on any content; hotspot at the tail
25
+ 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`
26
+ // laser-mode cursor: the crosshair reticle in accent blue with a white halo ring,
27
+ // hotspot dead center
28
+ 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`
29
+
30
+ // filled clipboard with a check inside (Phosphor clipboard-fill + check): the label
31
+ // wears this while confirming a copy
32
+ 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>`
33
+
34
+ const LASER_ID = 'mv-laser-style'
35
+ const BASE_ID = 'mv-inspect-base'
36
+
37
+ // hue-tinted highlight (an element's OWN depth hue - the same rainbow laser uses):
38
+ // 2px outline + soft fill + ring, driven by whatever hue CSS var the caller names. The
39
+ // comment-mode hover reads the LIVE depth hue (--mv-hue, mode-only); the persistent lock
40
+ // reads a STORED hue (--mv-lock-hue, set inline per element) so the frame outline never
41
+ // diverges from the pin/card colour if the DOM reparents.
42
+ const HUE_HL = (sel, hueVar, imp = '') => `
43
+ body ${sel} { outline: 2px solid hsl(var(${hueVar}) 95% 45%)${imp}; outline-offset: -2px;
44
+ background-image: linear-gradient(hsl(var(${hueVar}) 95% 50% / .10), hsl(var(${hueVar}) 95% 50% / .10));
45
+ box-shadow: 0 0 0 3px hsl(var(${hueVar}) 95% 50% / .32) }`
46
+ // depth hue for every element (role/nesting colour) - only injected while a mode is active
47
+ const DEPTH_HUE = (() => {
48
+ let r = 'body { --mv-hue: 0 }\n'
49
+ for (let d = 1; d <= 12; d++) r += `body ${'> * '.repeat(d)}{ --mv-hue: ${(d % 6) * 60} }\n`
50
+ return r
51
+ })()
52
+
53
+ export function createInspect({ post, getId, onModeChange }) {
54
+ let laserOn = false, pickOn = false
55
+ // quiet pick (⇧L laser comment off): clicks still anchor comments, but NO hover outline,
56
+ // label, or lock lighting inside the artwork - the shell decides, the frame obeys
57
+ let pickQuiet = false
58
+ let hoverEl = null, labelEl = null
59
+ let lockedEl = null, highlightAnchor = null // the persistent outline (#4 lock / #5 open)
60
+ let copiedTimer = null, copySeq = 0, copyEl = null
61
+
62
+ // every listener goes through `on` so dispose() can remove them all - a stage remount
63
+ // (Fast Refresh) would otherwise stack duplicate controllers posting duplicate picks
64
+ const removers = []
65
+ const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); removers.push(() => target.removeEventListener(type, fn, opts)) }
66
+
67
+ const modeActive = () => laserOn || pickOn
68
+
69
+ // the mode stylesheet: laser's full-board rainbow, or comment's single-element hover.
70
+ // Both colour by the element's depth hue (--mv-hue lives in the always-on base sheet).
71
+ const laserCss = () => {
72
+ let rules = DEPTH_HUE // depth hues live only while a mode is active
73
+ if (laserOn)
74
+ 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'
75
+ if (pickOn || laserOn)
76
+ rules += `body, body * { cursor: ${pickOn ? PICK_CURSOR : LASER_CURSOR} !important }\n`
77
+ // comment mode: the hovered element pops in its OWN hue (outline + fill + ring), so it
78
+ // reads as "the thing you'll comment on" in the same colour it wears in laser. Laser
79
+ // keeps its lighter single outline so the whole board stays legible.
80
+ const hover = pickOn ? HUE_HL('[data-mv-hover]', '--mv-hue') : `
81
+ body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-offset: -2px;
82
+ background-image: linear-gradient(hsl(var(--mv-hue) 95% 50% / .08), hsl(var(--mv-hue) 95% 50% / .08)) }`
83
+ return rules + hover + `
84
+ #mv-laser-label { position: fixed; z-index: 2147483647; pointer-events: none; outline: none !important;
85
+ display: flex; align-items: center; gap: 5px; width: max-content;
86
+ font: 600 10px -apple-system, system-ui, sans-serif; color: #fff; background: rgba(20, 20, 24, .92);
87
+ padding: 3px 7px; border-radius: 5px; max-width: 340px; white-space: nowrap; overflow: hidden;
88
+ text-overflow: ellipsis; letter-spacing: .01em }
89
+ #mv-laser-label svg { flex: none; display: block; outline: none !important }
90
+ #mv-laser-label.mv-copied { animation: mv-pop .22s cubic-bezier(.32, .72, .35, 1) }
91
+ @keyframes mv-pop { from { transform: scale(.85); opacity: .5 } }
92
+ `
93
+ }
94
+
95
+ // the lock outline is always available (a thread can be opened while NOT in a mode),
96
+ // so its rule lives in a tiny base sheet injected once - comment green, distinct from
97
+ // the mode hover, and it wins by being 2px with an offset.
98
+ const injectBase = () => {
99
+ if (document.getElementById(BASE_ID)) return
100
+ const s = document.createElement('style')
101
+ s.id = BASE_ID
102
+ // the persistent lock, tinted in the anchored element's stored hue (set inline as
103
+ // --mv-lock-hue by applyLock; fallback to a calm blue for any pre-hue anchor)
104
+ s.textContent = HUE_HL('[data-mv-locked]', '--mv-lock-hue, 210', ' !important')
105
+ document.head.appendChild(s)
106
+ }
107
+
108
+ const applyModes = () => {
109
+ const cur = document.getElementById(LASER_ID)
110
+ if (!modeActive()) { if (cur) cur.remove(); clearHover(); return }
111
+ const s = cur ?? document.createElement('style')
112
+ s.id = LASER_ID
113
+ s.textContent = laserCss()
114
+ if (!cur) document.head.appendChild(s)
115
+ }
116
+
117
+ const describe = (el) => {
118
+ const tag = el.tagName.toLowerCase()
119
+ const loc = el.dataset.mvLoc
120
+ const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
121
+ return loc ? `${tag} · ${loc}` : text ? `${tag} · “${text}”` : tag
122
+ }
123
+
124
+ const clearHover = () => {
125
+ if (hoverEl) { delete hoverEl.dataset.mvHover; hoverEl = null }
126
+ if (labelEl) { labelEl.remove(); labelEl = null }
127
+ clearTimeout(copiedTimer)
128
+ }
129
+
130
+ // the persistent lock (#4/#5): one element at a time, its own attribute so a
131
+ // hover clear never touches it and the mode-off teardown leaves it alone.
132
+ const applyLock = (el, hue) => {
133
+ // stamp the stored hue first, so re-confirming the SAME element still refreshes its
134
+ // colour; a missing/invalid hue REMOVES the var so the CSS fallback (210) applies
135
+ if (el) {
136
+ if (Number.isFinite(hue)) el.style.setProperty('--mv-lock-hue', String(hue))
137
+ else el.style.removeProperty('--mv-lock-hue')
138
+ }
139
+ if (lockedEl === el) return
140
+ if (lockedEl) { delete lockedEl.dataset.mvLocked; lockedEl.style.removeProperty('--mv-lock-hue') }
141
+ lockedEl = el
142
+ if (el) el.dataset.mvLocked = '1'
143
+ }
144
+ const clearLock = () => {
145
+ if (lockedEl) { delete lockedEl.dataset.mvLocked; lockedEl.style.removeProperty('--mv-lock-hue') }
146
+ lockedEl = null; highlightAnchor = null
147
+ }
148
+
149
+ const showCopied = (seq) => {
150
+ if (seq !== copySeq || hoverEl !== copyEl || !labelEl) return
151
+ labelEl.classList.add('mv-copied')
152
+ labelEl.innerHTML = COPIED_HTML
153
+ clearTimeout(copiedTimer)
154
+ copiedTimer = setTimeout(() => {
155
+ if (!labelEl) return
156
+ labelEl.classList.remove('mv-copied')
157
+ if (hoverEl) labelEl.textContent = describe(hoverEl)
158
+ else { labelEl.remove(); labelEl = null }
159
+ }, 2000)
160
+ }
161
+
162
+ // the label hangs BELOW the cursor with a gap, left-aligned under it - never over the
163
+ // element you are trying to click. It only shifts left when it would run off the right
164
+ // edge (so the text is never clipped), and flips ABOVE the cursor near the bottom edge.
165
+ const place = (label, e) => {
166
+ const gap = 18
167
+ const w = label.offsetWidth, h = label.offsetHeight
168
+ let x = e.clientX // left edge under the cursor
169
+ if (x + w > innerWidth - 4) x = innerWidth - 4 - w // keep fully in view instead of clipping
170
+ x = Math.max(4, x)
171
+ let y = e.clientY + gap // below, clear of the cursor
172
+ if (y + h > innerHeight - 4) y = e.clientY - gap - h // no room below -> flip above
173
+ y = Math.max(4, y)
174
+ label.style.left = x + 'px'
175
+ label.style.top = y + 'px'
176
+ }
177
+
178
+ // the pointer LEAVING this frame takes its hover lighting with it: each frame is its own
179
+ // document, so once the cursor is gone no mousemove ever fires here again - without this,
180
+ // the last hovered outline + label fossilize on every frame the cursor crossed.
181
+ // (relatedTarget null = left the window/iframe, not just moved between elements)
182
+ on(document, 'mouseout', (e) => { if (!e.relatedTarget) clearHover() })
183
+ on(window, 'blur', clearHover)
184
+
185
+ // hover-follow: the label chases the cursor while a mode is on - BUT a locked
186
+ // element (#4 composing / #5 thread open) freezes it: the lit element is the one
187
+ // being discussed, not whatever the pointer grazes.
188
+ on(document, 'mousemove', (e) => {
189
+ if (!modeActive() || lockedEl) return
190
+ if (pickOn && pickQuiet) return // quiet pick: no hover visuals, clicks still land
191
+ const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
192
+ if (!el || el === hoverEl) { if (labelEl && hoverEl) place(labelEl, e); return }
193
+ clearHover()
194
+ hoverEl = el
195
+ el.dataset.mvHover = '1'
196
+ labelEl = document.createElement('div')
197
+ labelEl.id = 'mv-laser-label'
198
+ labelEl.textContent = describe(el)
199
+ document.body.appendChild(labelEl)
200
+ place(labelEl, e)
201
+ }, true)
202
+
203
+ const cssPath = (el) => {
204
+ const seg = []
205
+ for (let cur = el; cur && cur !== document.body; cur = cur.parentElement) {
206
+ if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
207
+ const tag = cur.tagName.toLowerCase()
208
+ let n = 1
209
+ for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
210
+ if (sib.tagName === cur.tagName) n++
211
+ seg.unshift(n > 1 ? `${tag}:nth-of-type(${n})` : tag)
212
+ }
213
+ return seg.join(' > ')
214
+ }
215
+ // the element's live depth hue (0..300) - so the shell can tint the pin / composer /
216
+ // thread card in the same colour the element wears in laser, and keep it after a reload
217
+ const hueOf = (el) => {
218
+ const raw = getComputedStyle(el).getPropertyValue('--mv-hue').trim() // set by the mode sheet
219
+ const n = raw === '' ? NaN : Number(raw)
220
+ return Number.isFinite(n) ? ((n % 360) + 360) % 360 : 210 // fallback: a calm blue
221
+ }
222
+ const anchorBundle = (el, e) => {
223
+ const r = el.getBoundingClientRect()
224
+ const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
225
+ return {
226
+ el: {
227
+ semantics: {
228
+ tag: el.tagName.toLowerCase(),
229
+ role: el.getAttribute('role') ?? undefined,
230
+ ariaLabel: el.getAttribute('aria-label') ?? undefined,
231
+ testId: el.getAttribute('data-testid') ?? undefined,
232
+ quote: text.slice(0, 200) || undefined,
233
+ },
234
+ cssPath: cssPath(el),
235
+ source: el.dataset.mvLoc ?? undefined,
236
+ hue: hueOf(el),
237
+ },
238
+ pos: {
239
+ fx: r.width ? Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) : .5,
240
+ fy: r.height ? Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)) : .5,
241
+ },
242
+ rect: { x: r.left, y: r.top, w: r.width, h: r.height },
243
+ }
244
+ }
245
+
246
+ // every press is reported so the shell can close an open thread card when the click
247
+ // lands INSIDE a frame (shell-document listeners never see those)
248
+ on(document, 'pointerdown', () => post({ type: 'sh:frame-down', id: getId() }), true)
249
+
250
+ on(document, 'click', (e) => {
251
+ if (!modeActive()) return
252
+ e.preventDefault()
253
+ e.stopPropagation()
254
+ const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
255
+ if (!el) return
256
+ if (pickOn) {
257
+ // #4: lock the chosen element the instant it is picked - no round-trip flicker.
258
+ // The shell re-confirms via sh:highlight-anchor once the draft is staged.
259
+ // Quiet pick (⇧L off) anchors WITHOUT lighting the element.
260
+ if (!pickQuiet) applyLock(el, hueOf(el))
261
+ clearHover()
262
+ post({ type: 'sh:picked', id: getId(), anchor: anchorBundle(el, e) })
263
+ } else {
264
+ copyEl = el
265
+ post({ type: 'sh:laser-copy', id: getId(), seq: ++copySeq, path: cssPath(el), source: el.dataset.mvLoc ?? null })
266
+ }
267
+ }, true)
268
+
269
+ const resolveAnchor = (anchor) => {
270
+ const want = anchor?.el?.semantics ?? {}
271
+ const match = (el) => {
272
+ if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
273
+ if (want.role && el.getAttribute('role') !== want.role) return false
274
+ if (want.ariaLabel && el.getAttribute('aria-label') !== want.ariaLabel) return false
275
+ if (want.testId && el.getAttribute('data-testid') !== want.testId) return false
276
+ const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
277
+ if (want.quote && !(text.startsWith(want.quote.slice(0, 60)) || text.includes(want.quote.slice(0, 40)))) return false
278
+ return true
279
+ }
280
+ try {
281
+ const byPath = anchor?.el?.cssPath && document.querySelector(anchor.el.cssPath)
282
+ if (byPath && match(byPath)) return byPath
283
+ } catch { /* stale selector */ }
284
+ try {
285
+ if (want.testId) {
286
+ const el = document.querySelector(`[data-testid="${CSS.escape(want.testId)}"]`)
287
+ if (el && match(el)) return el
288
+ }
289
+ if (want.quote && typeof want.tag === 'string' && /^[a-z][a-z0-9-]*$/.test(want.tag)) {
290
+ for (const el of document.querySelectorAll(want.tag))
291
+ if (match(el)) return el
292
+ }
293
+ } catch { /* malformed semantics must not sink the whole batch */ }
294
+ return null
295
+ }
296
+
297
+ // re-resolve + re-apply the shell-driven highlight. Called on set and on every
298
+ // resolve-anchors pass, so a stage frame swapped in AFTER the highlight arrived
299
+ // (the DOM had not committed yet) still lights up on the next cycle - and, just as
300
+ // important, a target that has SINCE detached clears the lock (applyLock(null))
301
+ // instead of stranding a stale outline / a stuck hover-suppression.
302
+ const reapplyHighlight = () => {
303
+ if (!highlightAnchor) return
304
+ applyLock(resolveAnchor(highlightAnchor), highlightAnchor?.el?.hue)
305
+ }
306
+
307
+ const onMessage = (e) => {
308
+ if (e.source !== window.parent || window.parent === window) return
309
+ if (e.origin && e.origin !== location.origin) return // a navigated frame must not spoof shell commands
310
+ const m = e?.data
311
+ if (!m || typeof m !== 'object') return
312
+ if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes(); onModeChange?.(laserOn, pickOn) }
313
+ else if (m.type === 'sh:pick') {
314
+ pickOn = !!m.on; pickQuiet = !!m.quiet; applyModes()
315
+ if (pickQuiet) clearHover() // a hover lit before the toggle landed must not linger
316
+ // leaving comment mode drops a provisional self-lock the shell never confirmed;
317
+ // a shell-owned highlight (highlightAnchor set, e.g. #5) survives
318
+ if (!pickOn && !highlightAnchor) clearLock()
319
+ onModeChange?.(laserOn, pickOn)
320
+ }
321
+ else if (m.type === 'sh:copy-ok') showCopied(m.seq)
322
+ else if (m.type === 'sh:highlight-anchor') {
323
+ // null clears; an anchor locks its element (the pick draft #4, or the opened thread
324
+ // #5). No frame-id guard: a canvas frame only ever receives its OWN highlights (the
325
+ // shell posts to that one iframe), and an HTML frame reports id=pathname != frame.id
326
+ // so a guard would wrongly drop every one. Stage-swap staleness is handled instead by
327
+ // resolveAnchor failing on the wrong DOM (-> clear) and reapplyHighlight relighting on
328
+ // the next resolve tick once the new frame has committed.
329
+ if (!m.anchor) { clearLock(); return }
330
+ highlightAnchor = m.anchor
331
+ applyLock(resolveAnchor(m.anchor), m.anchor?.el?.hue)
332
+ }
333
+ else if (m.type === 'sh:resolve-anchors' && Array.isArray(m.anchors)) {
334
+ const rects = m.anchors.slice(0, 200).map((a) => {
335
+ let el = null
336
+ try { el = resolveAnchor(a.anchor) } catch { /* one bad anchor, not the batch */ }
337
+ if (!el) return { key: a.key, orphan: true }
338
+ const r = el.getBoundingClientRect()
339
+ return { key: a.key, rect: { x: r.left, y: r.top, w: r.width, h: r.height } }
340
+ })
341
+ reapplyHighlight()
342
+ post({ type: 'sh:anchor-rects', id: getId(), rects })
343
+ }
344
+ }
345
+ on(window, 'message', onMessage)
346
+
347
+ injectBase()
348
+ return {
349
+ modeActive, isLaser: () => laserOn, isPick: () => pickOn,
350
+ // remove every listener + injected style + clear timers/locks (stage remount safety)
351
+ dispose() {
352
+ removers.forEach((f) => f())
353
+ clearTimeout(copiedTimer)
354
+ clearHover(); clearLock()
355
+ document.getElementById(LASER_ID)?.remove()
356
+ document.getElementById(BASE_ID)?.remove()
357
+ },
358
+ }
359
+ }