@marver-design/marver 0.6.0 → 0.7.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,346 @@
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
+ let hoverEl = null, labelEl = null
56
+ let lockedEl = null, highlightAnchor = null // the persistent outline (#4 lock / #5 open)
57
+ let copiedTimer = null, copySeq = 0, copyEl = null
58
+
59
+ // every listener goes through `on` so dispose() can remove them all - a stage remount
60
+ // (Fast Refresh) would otherwise stack duplicate controllers posting duplicate picks
61
+ const removers = []
62
+ const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); removers.push(() => target.removeEventListener(type, fn, opts)) }
63
+
64
+ const modeActive = () => laserOn || pickOn
65
+
66
+ // the mode stylesheet: laser's full-board rainbow, or comment's single-element hover.
67
+ // Both colour by the element's depth hue (--mv-hue lives in the always-on base sheet).
68
+ const laserCss = () => {
69
+ let rules = DEPTH_HUE // depth hues live only while a mode is active
70
+ if (laserOn)
71
+ 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'
72
+ if (pickOn || laserOn)
73
+ rules += `body, body * { cursor: ${pickOn ? PICK_CURSOR : LASER_CURSOR} !important }\n`
74
+ // comment mode: the hovered element pops in its OWN hue (outline + fill + ring), so it
75
+ // reads as "the thing you'll comment on" in the same colour it wears in laser. Laser
76
+ // keeps its lighter single outline so the whole board stays legible.
77
+ const hover = pickOn ? HUE_HL('[data-mv-hover]', '--mv-hue') : `
78
+ body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-offset: -2px;
79
+ background-image: linear-gradient(hsl(var(--mv-hue) 95% 50% / .08), hsl(var(--mv-hue) 95% 50% / .08)) }`
80
+ return rules + hover + `
81
+ #mv-laser-label { position: fixed; z-index: 2147483647; pointer-events: none; outline: none !important;
82
+ display: flex; align-items: center; gap: 5px; width: max-content;
83
+ font: 600 10px -apple-system, system-ui, sans-serif; color: #fff; background: rgba(20, 20, 24, .92);
84
+ padding: 3px 7px; border-radius: 5px; max-width: 340px; white-space: nowrap; overflow: hidden;
85
+ text-overflow: ellipsis; letter-spacing: .01em }
86
+ #mv-laser-label svg { flex: none; display: block; outline: none !important }
87
+ #mv-laser-label.mv-copied { animation: mv-pop .22s cubic-bezier(.32, .72, .35, 1) }
88
+ @keyframes mv-pop { from { transform: scale(.85); opacity: .5 } }
89
+ `
90
+ }
91
+
92
+ // the lock outline is always available (a thread can be opened while NOT in a mode),
93
+ // so its rule lives in a tiny base sheet injected once - comment green, distinct from
94
+ // the mode hover, and it wins by being 2px with an offset.
95
+ const injectBase = () => {
96
+ if (document.getElementById(BASE_ID)) return
97
+ const s = document.createElement('style')
98
+ s.id = BASE_ID
99
+ // the persistent lock, tinted in the anchored element's stored hue (set inline as
100
+ // --mv-lock-hue by applyLock; fallback to a calm blue for any pre-hue anchor)
101
+ s.textContent = HUE_HL('[data-mv-locked]', '--mv-lock-hue, 210', ' !important')
102
+ document.head.appendChild(s)
103
+ }
104
+
105
+ const applyModes = () => {
106
+ const cur = document.getElementById(LASER_ID)
107
+ if (!modeActive()) { if (cur) cur.remove(); clearHover(); return }
108
+ const s = cur ?? document.createElement('style')
109
+ s.id = LASER_ID
110
+ s.textContent = laserCss()
111
+ if (!cur) document.head.appendChild(s)
112
+ }
113
+
114
+ const describe = (el) => {
115
+ const tag = el.tagName.toLowerCase()
116
+ const loc = el.dataset.mvLoc
117
+ const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
118
+ return loc ? `${tag} · ${loc}` : text ? `${tag} · “${text}”` : tag
119
+ }
120
+
121
+ const clearHover = () => {
122
+ if (hoverEl) { delete hoverEl.dataset.mvHover; hoverEl = null }
123
+ if (labelEl) { labelEl.remove(); labelEl = null }
124
+ clearTimeout(copiedTimer)
125
+ }
126
+
127
+ // the persistent lock (#4/#5): one element at a time, its own attribute so a
128
+ // hover clear never touches it and the mode-off teardown leaves it alone.
129
+ const applyLock = (el, hue) => {
130
+ // stamp the stored hue first, so re-confirming the SAME element still refreshes its
131
+ // colour; a missing/invalid hue REMOVES the var so the CSS fallback (210) applies
132
+ if (el) {
133
+ if (Number.isFinite(hue)) el.style.setProperty('--mv-lock-hue', String(hue))
134
+ else el.style.removeProperty('--mv-lock-hue')
135
+ }
136
+ if (lockedEl === el) return
137
+ if (lockedEl) { delete lockedEl.dataset.mvLocked; lockedEl.style.removeProperty('--mv-lock-hue') }
138
+ lockedEl = el
139
+ if (el) el.dataset.mvLocked = '1'
140
+ }
141
+ const clearLock = () => {
142
+ if (lockedEl) { delete lockedEl.dataset.mvLocked; lockedEl.style.removeProperty('--mv-lock-hue') }
143
+ lockedEl = null; highlightAnchor = null
144
+ }
145
+
146
+ const showCopied = (seq) => {
147
+ if (seq !== copySeq || hoverEl !== copyEl || !labelEl) return
148
+ labelEl.classList.add('mv-copied')
149
+ labelEl.innerHTML = COPIED_HTML
150
+ clearTimeout(copiedTimer)
151
+ copiedTimer = setTimeout(() => {
152
+ if (!labelEl) return
153
+ labelEl.classList.remove('mv-copied')
154
+ if (hoverEl) labelEl.textContent = describe(hoverEl)
155
+ else { labelEl.remove(); labelEl = null }
156
+ }, 2000)
157
+ }
158
+
159
+ // the label hangs BELOW the cursor with a gap, left-aligned under it - never over the
160
+ // element you are trying to click. It only shifts left when it would run off the right
161
+ // edge (so the text is never clipped), and flips ABOVE the cursor near the bottom edge.
162
+ const place = (label, e) => {
163
+ const gap = 18
164
+ const w = label.offsetWidth, h = label.offsetHeight
165
+ let x = e.clientX // left edge under the cursor
166
+ if (x + w > innerWidth - 4) x = innerWidth - 4 - w // keep fully in view instead of clipping
167
+ x = Math.max(4, x)
168
+ let y = e.clientY + gap // below, clear of the cursor
169
+ if (y + h > innerHeight - 4) y = e.clientY - gap - h // no room below -> flip above
170
+ y = Math.max(4, y)
171
+ label.style.left = x + 'px'
172
+ label.style.top = y + 'px'
173
+ }
174
+
175
+ // hover-follow: the label chases the cursor while a mode is on - BUT a locked
176
+ // element (#4 composing / #5 thread open) freezes it: the lit element is the one
177
+ // being discussed, not whatever the pointer grazes.
178
+ on(document, 'mousemove', (e) => {
179
+ if (!modeActive() || lockedEl) return
180
+ const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
181
+ if (!el || el === hoverEl) { if (labelEl && hoverEl) place(labelEl, e); return }
182
+ clearHover()
183
+ hoverEl = el
184
+ el.dataset.mvHover = '1'
185
+ labelEl = document.createElement('div')
186
+ labelEl.id = 'mv-laser-label'
187
+ labelEl.textContent = describe(el)
188
+ document.body.appendChild(labelEl)
189
+ place(labelEl, e)
190
+ }, true)
191
+
192
+ const cssPath = (el) => {
193
+ const seg = []
194
+ for (let cur = el; cur && cur !== document.body; cur = cur.parentElement) {
195
+ if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
196
+ const tag = cur.tagName.toLowerCase()
197
+ let n = 1
198
+ for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
199
+ if (sib.tagName === cur.tagName) n++
200
+ seg.unshift(n > 1 ? `${tag}:nth-of-type(${n})` : tag)
201
+ }
202
+ return seg.join(' > ')
203
+ }
204
+ // the element's live depth hue (0..300) - so the shell can tint the pin / composer /
205
+ // thread card in the same colour the element wears in laser, and keep it after a reload
206
+ const hueOf = (el) => {
207
+ const raw = getComputedStyle(el).getPropertyValue('--mv-hue').trim() // set by the mode sheet
208
+ const n = raw === '' ? NaN : Number(raw)
209
+ return Number.isFinite(n) ? ((n % 360) + 360) % 360 : 210 // fallback: a calm blue
210
+ }
211
+ const anchorBundle = (el, e) => {
212
+ const r = el.getBoundingClientRect()
213
+ const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
214
+ return {
215
+ el: {
216
+ semantics: {
217
+ tag: el.tagName.toLowerCase(),
218
+ role: el.getAttribute('role') ?? undefined,
219
+ ariaLabel: el.getAttribute('aria-label') ?? undefined,
220
+ testId: el.getAttribute('data-testid') ?? undefined,
221
+ quote: text.slice(0, 200) || undefined,
222
+ },
223
+ cssPath: cssPath(el),
224
+ source: el.dataset.mvLoc ?? undefined,
225
+ hue: hueOf(el),
226
+ },
227
+ pos: {
228
+ fx: r.width ? Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) : .5,
229
+ fy: r.height ? Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)) : .5,
230
+ },
231
+ rect: { x: r.left, y: r.top, w: r.width, h: r.height },
232
+ }
233
+ }
234
+
235
+ // every press is reported so the shell can close an open thread card when the click
236
+ // lands INSIDE a frame (shell-document listeners never see those)
237
+ on(document, 'pointerdown', () => post({ type: 'sh:frame-down', id: getId() }), true)
238
+
239
+ on(document, 'click', (e) => {
240
+ if (!modeActive()) return
241
+ e.preventDefault()
242
+ e.stopPropagation()
243
+ const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
244
+ if (!el) return
245
+ if (pickOn) {
246
+ // #4: lock the chosen element the instant it is picked - no round-trip flicker.
247
+ // The shell re-confirms via sh:highlight-anchor once the draft is staged.
248
+ applyLock(el, hueOf(el))
249
+ clearHover()
250
+ post({ type: 'sh:picked', id: getId(), anchor: anchorBundle(el, e) })
251
+ } else {
252
+ copyEl = el
253
+ post({ type: 'sh:laser-copy', id: getId(), seq: ++copySeq, path: cssPath(el), source: el.dataset.mvLoc ?? null })
254
+ }
255
+ }, true)
256
+
257
+ const resolveAnchor = (anchor) => {
258
+ const want = anchor?.el?.semantics ?? {}
259
+ const match = (el) => {
260
+ if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
261
+ if (want.role && el.getAttribute('role') !== want.role) return false
262
+ if (want.ariaLabel && el.getAttribute('aria-label') !== want.ariaLabel) return false
263
+ if (want.testId && el.getAttribute('data-testid') !== want.testId) return false
264
+ const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
265
+ if (want.quote && !(text.startsWith(want.quote.slice(0, 60)) || text.includes(want.quote.slice(0, 40)))) return false
266
+ return true
267
+ }
268
+ try {
269
+ const byPath = anchor?.el?.cssPath && document.querySelector(anchor.el.cssPath)
270
+ if (byPath && match(byPath)) return byPath
271
+ } catch { /* stale selector */ }
272
+ try {
273
+ if (want.testId) {
274
+ const el = document.querySelector(`[data-testid="${CSS.escape(want.testId)}"]`)
275
+ if (el && match(el)) return el
276
+ }
277
+ if (want.quote && typeof want.tag === 'string' && /^[a-z][a-z0-9-]*$/.test(want.tag)) {
278
+ for (const el of document.querySelectorAll(want.tag))
279
+ if (match(el)) return el
280
+ }
281
+ } catch { /* malformed semantics must not sink the whole batch */ }
282
+ return null
283
+ }
284
+
285
+ // re-resolve + re-apply the shell-driven highlight. Called on set and on every
286
+ // resolve-anchors pass, so a stage frame swapped in AFTER the highlight arrived
287
+ // (the DOM had not committed yet) still lights up on the next cycle - and, just as
288
+ // important, a target that has SINCE detached clears the lock (applyLock(null))
289
+ // instead of stranding a stale outline / a stuck hover-suppression.
290
+ const reapplyHighlight = () => {
291
+ if (!highlightAnchor) return
292
+ applyLock(resolveAnchor(highlightAnchor), highlightAnchor?.el?.hue)
293
+ }
294
+
295
+ const onMessage = (e) => {
296
+ if (e.source !== window.parent || window.parent === window) return
297
+ if (e.origin && e.origin !== location.origin) return // a navigated frame must not spoof shell commands
298
+ const m = e?.data
299
+ if (!m || typeof m !== 'object') return
300
+ if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes(); onModeChange?.(laserOn, pickOn) }
301
+ else if (m.type === 'sh:pick') {
302
+ pickOn = !!m.on; applyModes()
303
+ // leaving comment mode drops a provisional self-lock the shell never confirmed;
304
+ // a shell-owned highlight (highlightAnchor set, e.g. #5) survives
305
+ if (!pickOn && !highlightAnchor) clearLock()
306
+ onModeChange?.(laserOn, pickOn)
307
+ }
308
+ else if (m.type === 'sh:copy-ok') showCopied(m.seq)
309
+ else if (m.type === 'sh:highlight-anchor') {
310
+ // null clears; an anchor locks its element (the pick draft #4, or the opened thread
311
+ // #5). No frame-id guard: a canvas frame only ever receives its OWN highlights (the
312
+ // shell posts to that one iframe), and an HTML frame reports id=pathname != frame.id
313
+ // so a guard would wrongly drop every one. Stage-swap staleness is handled instead by
314
+ // resolveAnchor failing on the wrong DOM (-> clear) and reapplyHighlight relighting on
315
+ // the next resolve tick once the new frame has committed.
316
+ if (!m.anchor) { clearLock(); return }
317
+ highlightAnchor = m.anchor
318
+ applyLock(resolveAnchor(m.anchor), m.anchor?.el?.hue)
319
+ }
320
+ else if (m.type === 'sh:resolve-anchors' && Array.isArray(m.anchors)) {
321
+ const rects = m.anchors.slice(0, 200).map((a) => {
322
+ let el = null
323
+ try { el = resolveAnchor(a.anchor) } catch { /* one bad anchor, not the batch */ }
324
+ if (!el) return { key: a.key, orphan: true }
325
+ const r = el.getBoundingClientRect()
326
+ return { key: a.key, rect: { x: r.left, y: r.top, w: r.width, h: r.height } }
327
+ })
328
+ reapplyHighlight()
329
+ post({ type: 'sh:anchor-rects', id: getId(), rects })
330
+ }
331
+ }
332
+ on(window, 'message', onMessage)
333
+
334
+ injectBase()
335
+ return {
336
+ modeActive, isLaser: () => laserOn, isPick: () => pickOn,
337
+ // remove every listener + injected style + clear timers/locks (stage remount safety)
338
+ dispose() {
339
+ removers.forEach((f) => f())
340
+ clearTimeout(copiedTimer)
341
+ clearHover(); clearLock()
342
+ document.getElementById(LASER_ID)?.remove()
343
+ document.getElementById(BASE_ID)?.remove()
344
+ },
345
+ }
346
+ }
@@ -1,5 +1,4 @@
1
1
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
3
2
  import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
4
3
  import { Tip } from './Tip.tsx'
5
4
  import { PKG, ROUTE } from '../const.ts'
@@ -7,9 +6,10 @@ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
6
  import { frameByWindow } from './canvas/frame-registry.ts'
8
7
  import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
9
8
  import { bootHash, parseHash, writeHash } from './hash.ts'
10
- 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'
9
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
10
  import { CommentsController } from './Comments.tsx'
12
11
  import { useComments } from './comments-store.ts'
12
+ import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
13
13
 
14
14
  const commentsStore = () => useComments.getState()
15
15
 
@@ -48,41 +48,6 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
48
48
  }
49
49
  }
50
50
 
51
- /** Shared popover machinery: trigger position, outside-click close, portal to the app
52
- * root (glass never nests - a nested backdrop-filter cannot sample the page). */
53
- function usePopover() {
54
- const [open, setOpen] = useState(false)
55
- const [pos, setPos] = useState({ left: 0, top: 0 })
56
- const boxRef = useRef<HTMLDivElement>(null)
57
- const menuRef = useRef<HTMLDivElement>(null)
58
- const toggle = () => {
59
- if (!open && boxRef.current) {
60
- const r = boxRef.current.getBoundingClientRect()
61
- setPos({ left: r.left, top: r.bottom + 10 })
62
- }
63
- setOpen(!open)
64
- }
65
- useEffect(() => {
66
- if (!open) return
67
- const close = (e: PointerEvent) => {
68
- const t = e.target as globalThis.Node
69
- if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false)
70
- }
71
- window.addEventListener('pointerdown', close)
72
- return () => window.removeEventListener('pointerdown', close)
73
- }, [open])
74
- return { open, setOpen, pos, boxRef, menuRef, toggle }
75
- }
76
-
77
- function Popover({ pop, children }: { pop: ReturnType<typeof usePopover>; children: ReactNode }) {
78
- const app = document.querySelector('.sh-app')
79
- if (!pop.open || !app) return null
80
- return createPortal(
81
- <div className="sh-menu" ref={pop.menuRef} style={{ left: pop.pos.left, top: pop.pos.top }}>{children}</div>,
82
- app,
83
- )
84
- }
85
-
86
51
  /** Boards live flat in the sidebar - always visible, one click to switch. The list
87
52
  * refreshes on mount, window focus, and a slow poll so agent-created board files
88
53
  * appear without a reload. Active board = accent icon + wash, same language as scenes. */
@@ -213,12 +178,12 @@ function SelectionBar() {
213
178
  }
214
179
 
215
180
  /** Devices view: one click sizes frames to a device width, tidies, and fits. Scoped
216
- * like the digit keys: the selection when one exists, the whole board otherwise. */
181
+ * like the digit keys: the selection when one exists, the whole board otherwise. The
182
+ * dropdown chrome is the shared DevicePicker; this adapter owns the canvas wiring. */
217
183
  function DeviceMenu() {
218
184
  const deviceView = useStore((s) => s.deviceView)
219
185
  const selection = useStore((s) => s.selection)
220
186
  const nodes = useStore((s) => s.nodes)
221
- const pop = usePopover()
222
187
  const scoped = selection.length > 0
223
188
  const pick = (name: string | null) => {
224
189
  animateLayout()
@@ -230,7 +195,6 @@ function DeviceMenu() {
230
195
  st.setDeviceView(name)
231
196
  setTimeout(() => canvasCtl.fitAll(), 30)
232
197
  }
233
- pop.setOpen(false)
234
198
  }
235
199
  const entries = Object.entries(CONFIG.viewports)
236
200
  // active check: board-wide it is the device view; scoped, the device every selected frame wears
@@ -238,31 +202,8 @@ function DeviceMenu() {
238
202
  const active = scoped
239
203
  ? entries.find(([, vp]) => selNodes.length > 0 && selNodes.every((n) => n.w === vp.width))?.[0] ?? null
240
204
  : deviceView
241
- return (
242
- <div className="sh-theme" ref={pop.boxRef}>
243
- <Tip side="bottom" label={<><b>Device view</b><span>{scoped ? `${selection.length} selected` : deviceView ? `${cap(deviceView)} · 0 resets` : `keys 1-${entries.length}`}</span></>}>
244
- <button className="sh-pill-btn" onClick={pop.toggle}>
245
- {deviceIcon(active, 16)}
246
- <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
247
- </button>
248
- </Tip>
249
- <Popover pop={pop}>
250
- <Tip side="bottom" label={scoped ? 'Selected frames at their default sizes' : 'Every frame at its own default size'}>
251
- <button onClick={() => pick(null)}>
252
- <DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
253
- {!scoped && deviceView === null && <CheckIcon size={13} className="chk" />}
254
- </button>
255
- </Tip>
256
- <i className="div" />
257
- {entries.map(([name, vp], i) => (
258
- <button key={name} onClick={() => pick(name)} title={`${vp.width} × ${vp.height}`}>
259
- {deviceIcon(name)}<span>{cap(name)}</span><kbd>{i < 9 ? i + 1 : ''}</kbd>
260
- {active === name && <CheckIcon size={13} className="chk" />}
261
- </button>
262
- ))}
263
- </Popover>
264
- </div>
265
- )
205
+ const hint = scoped ? `${selection.length} selected` : deviceView ? cap(deviceView) : `keys 1-${entries.length}`
206
+ return <DevicePicker value={active} onSelect={pick} includeDefault hint={hint} />
266
207
  }
267
208
 
268
209
  /** Update pill (dev only): the daily registry check surfaces here - same glass, same
@@ -337,46 +278,25 @@ function ZoomMenu() {
337
278
  /** Theme dropdown, scoped like the device digits: the selection when one exists, every
338
279
  * frame otherwise. The trigger reflects the scope's MAJORITY (per-frame overrides never
339
280
  * flip a board-level trigger - it reports the level it acts on, same rule as the shell).
340
- * The menu is PORTALED out of the pill: an element with backdrop-filter is a backdrop root,
341
- * so a nested backdrop-filter samples the pill's surface instead of the page - flat grey. */
281
+ * The dropdown chrome is the shared ThemePicker; this adapter owns the canvas wiring. */
342
282
  function ThemeMenu() {
343
283
  const nodes = useStore((s) => s.nodes)
344
284
  const selection = useStore((s) => s.selection)
345
285
  const viewTheme = useStore((s) => s.viewTheme)
346
- const pop = usePopover()
347
286
  const scoped = selection.length > 0
348
- // scoped: the trigger + check reflect the selection; global: the VIEW preference
349
287
  const scope = scoped ? nodes.filter((n) => selection.includes(n.key)) : []
350
- const uniform = scoped
351
- ? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null)
352
- : viewTheme
353
288
  const majority = scoped
354
289
  ? (scope.length ? [...scope.reduce((m, n) => m.set(n.theme, (m.get(n.theme) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1])[0][0] : viewTheme)
355
290
  : viewTheme
291
+ // the tick shows only a UNIFORM scope (mixed selection = no theme is "the" one), while
292
+ // the trigger reports the majority - the level the control acts on
293
+ const uniform = scoped ? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null) : viewTheme
356
294
  const pick = (t: string) => {
357
295
  const st = useStore.getState()
358
296
  scoped ? st.setSelectedTheme(t) : st.setTheme(t)
359
- pop.setOpen(false)
360
297
  }
361
- return (
362
- <div className="sh-theme" ref={pop.boxRef}>
363
- <Tip side="bottom" label={<><b>Theme</b><span>{scoped ? `${selection.length} selected · D` : 'all frames · D'}</span></>}>
364
- <button className="sh-pill-btn" onClick={pop.toggle}>
365
- {majority === 'dark' ? <MoonIcon size={16} /> : <SunIcon size={16} />}
366
- <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
367
- </button>
368
- </Tip>
369
- <Popover pop={pop}>
370
- {CONFIG.themes.map((t) => (
371
- <button key={t} onClick={() => pick(t)}>
372
- {t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
373
- <span>{t}</span>
374
- {uniform === t && <CheckIcon size={13} className="chk" />}
375
- </button>
376
- ))}
377
- </Popover>
378
- </div>
379
- )
298
+ const hint = scoped ? `${selection.length} selected · D` : 'all frames · D'
299
+ return <ThemePicker value={majority} checked={uniform} onSelect={pick} hint={hint} />
380
300
  }
381
301
 
382
302
  export function App() {
@@ -549,6 +469,7 @@ export function App() {
549
469
  const onMsg = (e: MessageEvent) => {
550
470
  const data = e.data
551
471
  if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
472
+ if (e.origin && e.origin !== location.origin) return // a cross-origin-navigated frame keeps its WindowProxy; reject it
552
473
  // B0.3: O(1) registry lookup - also the security gate (unknown source = not a
553
474
  // registered frame = dropped), replacing a per-message iframe scan + DOM walk.
554
475
  const reg = frameByWindow(e.source)
@@ -649,9 +570,13 @@ export function App() {
649
570
  if (e.key === 'Escape') {
650
571
  const c = commentsStore()
651
572
  if (c.commentMode || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null) }
573
+ else if (s.laser) s.setLaser(false) // Escape also exits laser mode (parity with comment)
652
574
  else s.interact ? setInteract(null) : select(null)
653
575
  }
654
576
  if (e.key === 'p') enterPlay()
577
+ // H hides all chrome (shared binary Hide-UI); press again to reveal. Not persisted,
578
+ // so a refresh always restores it (the safety net for a forgotten toggle).
579
+ if (e.key === 'h') { toggleHideUI(); return }
655
580
  if (e.key === 't') { animateLayout(); runTidy() }
656
581
  // laser and comment mode are one-at-a-time: comment mode already highlights
657
582
  // what you'd click, so stacking the full rainbow on top only adds noise
@@ -869,33 +794,23 @@ export function App() {
869
794
 
870
795
  {/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
871
796
  <nav className={`sh-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
872
- {/* far-left section: single actions - comment mode, laser, tidy */}
873
- <Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
874
- <button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
875
- const c = commentsStore()
876
- if (!c.commentMode) useStore.getState().setLaser(false)
877
- c.setMode(!c.commentMode)
878
- toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
879
- }}><CommentIcon size={16} /></button>
880
- </Tip>
881
- <Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
882
- <button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
883
- if (!laser) commentsStore().setMode(false)
884
- useStore.getState().setLaser(!laser)
885
- }}><LaserIcon size={16} /></button>
886
- </Tip>
797
+ {/* far-left section: actions - comment mode, laser, tidy, prototype */}
798
+ <CommentButton />
799
+ <LaserButton />
887
800
  <Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
888
801
  <button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><ColumnsIcon size={16} /></button>
889
802
  </Tip>
803
+ <Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
804
+ <button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
805
+ </Tip>
890
806
  <i className="sep" />
891
807
  <DeviceMenu />
892
808
  <ThemeMenu />
893
809
  <i className="sep" />
894
810
  <ZoomMenu />
895
811
  <i className="sep" />
896
- <Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
897
- <button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
898
- </Tip>
812
+ {/* far right: UI management only - hide, collapse */}
813
+ <HideUIButton />
899
814
  <Tip side="bottom" label={<><b>Collapse toolbar</b><span>⌘/</span></>}>
900
815
  <button className="sh-pill-btn" onClick={() => setPillOpen(false)} tabIndex={pillOpen ? 0 : -1}>
901
816
  <PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />