@marver-design/marver 0.5.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.
@@ -3,18 +3,24 @@
3
3
  * the chosen viewport's exact CSS pixels, scaled to fit - or `fill`, where the frame IS
4
4
  * the window. The device hosts a single stage iframe that swaps frames in place.
5
5
  *
6
- * The shell owns everything except data-goto: chrome (top-right bar: board switcher,
7
- * devices + fill, theme, hide, exit), the bottom-left navigator (restart · prev · i/N ·
8
- * next), walk order, sizing, and the URL. Chrome auto-hides when idle and can be hidden
9
- * outright (H); hovering the top-right or bottom-left corner always reveals it - in fill
10
- * mode the stage reports those hovers, since the iframe covers the window.
6
+ * The prototype is a first-class review surface (prototype-review): the top-right pill is
7
+ * the SAME glass toolbar as the canvas (board · comment · laser · device · theme · hide ·
8
+ * collapse · exit), the stage runs the SAME laser/comment/anchor controller as canvas
9
+ * frames, and comments anchor to the walked frame. Chrome is shown unless Hide-UI (H) is
10
+ * on - no auto-fade, no hover magic. The bottom-left navigator (restart · prev · i/N ·
11
+ * next) stays prototype-only.
11
12
  */
12
13
  import { useEffect, useRef, useState } from 'react'
13
14
  import { useStore, CONFIG, boardLabel, cap, fetchBoardNames, type Node } from './store.ts'
15
+ import { useComments } from './comments-store.ts'
14
16
  import { ROUTE } from '../const.ts'
15
17
  import { canvasCtl } from './canvas/Canvas.tsx'
16
18
  import { Tip } from './Tip.tsx'
17
- import { ArrowLeftIcon, ArrowRightIcon, CaretIcon, CheckIcon, FrameCornersIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ReloadIcon, SunIcon, XIcon, deviceIcon } from './icons.tsx'
19
+ import { CommentButton, DevicePicker, HideUIButton, isHideUI, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
20
+ import { DraftComposer, hueVars, MarkerFace, ThreadCard } from './Comments.tsx'
21
+ import { ArrowLeftIcon, ArrowRightIcon, CaretIcon, CheckIcon, PanelFilledIcon, PanelHollowIcon, ReloadIcon, XIcon } from './icons.tsx'
22
+
23
+ const commentsStore = () => useComments.getState()
18
24
 
19
25
  /** Board-order frame ids playable on the stage (tsx only - html frames are their own
20
26
  * documents and cannot mount into the persistent chain), deduped. */
@@ -82,37 +88,121 @@ export function PlayOverlay() {
82
88
  return <PlayInner key={board} />
83
89
  }
84
90
 
85
- /** Board switcher dropdown in the play bar. */
91
+ /** Board switcher dropdown in the play pill - the shared glass popover. */
86
92
  function BoardMenu({ current }: { current: string }) {
87
- const [open, setOpen] = useState(false)
93
+ const pop = usePopover()
88
94
  const [names, setNames] = useState<string[]>([current])
89
- const boxRef = useRef<HTMLDivElement>(null)
90
95
  // refreshed on every open - agents create boards while you present
91
- useEffect(() => { if (open) fetchBoardNames().then(setNames).catch(() => {}) }, [open])
92
- useEffect(() => {
93
- if (!open) return
94
- const close = (e: PointerEvent) => { if (!boxRef.current?.contains(e.target as globalThis.Node)) setOpen(false) }
95
- window.addEventListener('pointerdown', close)
96
- return () => window.removeEventListener('pointerdown', close)
97
- }, [open])
96
+ useEffect(() => { if (pop.open) fetchBoardNames().then(setNames).catch(() => {}) }, [pop.open])
98
97
  return (
99
- <div className="bd-wrap" ref={boxRef}>
100
- <Tip inv side="bottom" label={<b>Switch board</b>}>
101
- <button className="bd" onClick={() => setOpen(!open)}>
98
+ <div className="sh-theme" ref={pop.boxRef}>
99
+ <Tip side="bottom" label={<b>Switch board</b>}>
100
+ <button className="sh-pill-btn bd" onClick={pop.toggle}>
102
101
  {boardLabel(current)}
103
- <CaretIcon size={10} style={{ transform: open ? 'rotate(180deg)' : undefined }} />
102
+ <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
104
103
  </button>
105
104
  </Tip>
106
- {open && (
107
- <div className="sh-play-menu">
108
- {names.map((n) => (
109
- <button key={n} onClick={() => { setOpen(false); switchPlayBoard(n) }}>
110
- <span>{boardLabel(n)}</span>
111
- {n === current && <CheckIcon size={12} />}
112
- </button>
113
- ))}
114
- </div>
115
- )}
105
+ <Popover pop={pop} dark>
106
+ {names.map((n) => (
107
+ <button key={n} onClick={() => { pop.setOpen(false); switchPlayBoard(n) }}>
108
+ <span>{boardLabel(n)}</span>
109
+ {n === current && <CheckIcon size={13} className="chk" />}
110
+ </button>
111
+ ))}
112
+ </Popover>
113
+ </div>
114
+ )
115
+ }
116
+
117
+ /** The comment overlay over the single stage frame (prototype-review Phase 2). Per-frame:
118
+ * it shows only threads on the walked frame (play.at) and follows the walk. It sits inset:0
119
+ * inside the device wrapper (no JS coordinates - it tracks the centered stage for free);
120
+ * pins/cards are placed in the scaled stage space, #4/#5 highlight is driven into the frame. */
121
+ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
122
+ iframe: React.RefObject<HTMLIFrameElement | null>
123
+ frameId: string
124
+ vp: { width: number; height: number }
125
+ dw: number; dh: number; ready: number
126
+ }) {
127
+ const show = useComments((s) => s.show)
128
+ const active = useComments((s) => s.active)
129
+ const draft = useComments((s) => s.draft)
130
+ const allThreads = useComments((s) => s.threads)
131
+ const { setActive } = useComments.getState()
132
+ const threads = allThreads.filter((t) => t.frame === frameId && !t.resolved)
133
+ const anchored = threads.filter((t) => (t.anchor as any)?.el)
134
+ const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
135
+ const sx = vp.width ? dw / vp.width : 1
136
+ const sy = vp.height ? dh / vp.height : 1
137
+
138
+ // resolve anchors against the stage's current frame; re-ask on a slow interval (the
139
+ // stage swaps in place, so the DOM under an anchor can change without a remount)
140
+ useEffect(() => {
141
+ const win = iframe.current?.contentWindow
142
+ if (!win || !anchored.length) { setRects({}); return }
143
+ const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: anchored.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
144
+ const onMsg = (e: MessageEvent) => {
145
+ // reject a reply from a frame we already walked off (data.id is the frame that resolved)
146
+ if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects' || e.data.id !== frameId) return
147
+ const next: typeof rects = {}
148
+ for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.rect
149
+ setRects(next)
150
+ }
151
+ window.addEventListener('message', onMsg)
152
+ ask()
153
+ // a silent walk swaps the stage a frame or two AFTER frameId changes; ask again quickly
154
+ // so pins/highlight land within ~1s instead of waiting on the 3s poll (the frame drops
155
+ // stale replies by id, so an early ask against the old DOM is harmless)
156
+ const t1 = setTimeout(ask, 250)
157
+ const t2 = setTimeout(ask, 700)
158
+ const iv = setInterval(ask, 3000)
159
+ return () => { clearTimeout(t1); clearTimeout(t2); clearInterval(iv); window.removeEventListener('message', onMsg) }
160
+ }, [frameId, anchored.map((t) => t.id).join(','), iframe, ready])
161
+
162
+ // #4/#5: drive the persistent element highlight into the frame - the composing draft, or
163
+ // the open thread's anchor. null clears it. The frame guards against a stage-swap race;
164
+ // `ready` re-drives it after a stage reload wiped the frame's lock.
165
+ useEffect(() => {
166
+ const win = iframe.current?.contentWindow
167
+ if (!win) return
168
+ // only while the thread is active (or composing) AND pins are shown - closing or ⇧C clears it
169
+ const at = !show ? null : (draft?.frame === frameId ? draft.anchor
170
+ : (active ? (threads.find((t) => t.id === active && (t.anchor as any)?.el)?.anchor ?? null) : null))
171
+ win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: at ?? null }, location.origin)
172
+ }, [active, draft, show, frameId, iframe, rects, ready])
173
+
174
+ if (!show) return null
175
+
176
+ const pinAt = (t: typeof threads[number]) => {
177
+ const a = t.anchor as any
178
+ const r = rects[t.id]
179
+ if (a?.el && r) return { x: (r.x + (a.pos?.fx ?? .5) * r.w) * sx, y: (r.y + (a.pos?.fy ?? .5) * r.h) * sy, orphan: false }
180
+ if (a?.el && r === null) return { x: dw - 16, y: 16, orphan: true } // orphan parks top-right
181
+ const p = a?.pos // frame-level fraction
182
+ return { x: (p?.fx ?? .94) * dw, y: (p?.fy ?? .06) * dh, orphan: false }
183
+ }
184
+ const activeThread = threads.find((t) => t.id === active)
185
+ const draftAt = draft?.frame === frameId ? (() => {
186
+ const a = draft.anchor as any
187
+ return { x: ((a?.rect?.x ?? 0) + (a?.pos?.fx ?? .5) * (a?.rect?.w ?? 0)) * sx, y: ((a?.rect?.y ?? 0) + (a?.pos?.fy ?? .5) * (a?.rect?.h ?? 0)) * sy }
188
+ })() : null
189
+
190
+ return (
191
+ <div className="sh-play-comments">
192
+ {threads.map((t) => {
193
+ const { x, y, orphan } = pinAt(t)
194
+ return (
195
+ <div key={t.id} className={`cm-pin sh-no-pan${t.id === active ? ' on' : ''}${orphan ? ' orphan' : ''}`}
196
+ style={{ left: x, top: y, ...hueVars((t.anchor as any)?.el?.hue) }}
197
+ onPointerDown={(e) => e.stopPropagation()}
198
+ onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
199
+ title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
200
+ <MarkerFace threads={[t]} />
201
+ </div>
202
+ )
203
+ })}
204
+ {activeThread && <ThreadCard key={active!} thread={activeThread} at={pinAt(activeThread)} bounds={{ w: dw, h: dh }} />}
205
+ {draftAt && <DraftComposer at={draftAt} bounds={{ w: dw, h: dh }} hue={(draft?.anchor as any)?.el?.hue} />}
116
206
  </div>
117
207
  )
118
208
  }
@@ -126,28 +216,20 @@ function PlayInner() {
126
216
  // the stage at its current position on an explicit click.
127
217
  const playUpdateRevision = useStore((s) => s.playUpdateRevision)
128
218
  const playNav = useStore((s) => s.playNav)
219
+ const laser = useStore((s) => s.laser)
220
+ const commentMode = useComments((s) => s.commentMode)
129
221
  const iframeRef = useRef<HTMLIFrameElement>(null)
130
222
  // the src is frozen at mount - navigation happens INSIDE the stage; device and theme
131
223
  // changes must never reload it (a phone does not remount when you flip dark mode)
132
224
  const src = useRef(play ? `${ROUTE}/stage/?at=${encodeURIComponent(play.at)}&theme=${encodeURIComponent(play.theme)}` : '')
133
225
  const [win, setWin] = useState({ w: window.innerWidth, h: window.innerHeight })
134
- const [idle, setIdle] = useState(false)
135
- // chrome has three states, like the sidebar's panel/fab ladder: open (full bar + nav),
136
- // collapsed (a single chip to expand back, nav stays), hidden (immersive - nothing).
137
- const [chrome, setChrome] = useState<'open' | 'collapsed' | 'hidden'>('open')
138
- const chromeRef = useRef(chrome) // handleKey lives in a mount-time closure
139
- chromeRef.current = chrome
226
+ const [pillOpen, setPillOpen] = useState(true) // collapse (pill -> re-open fab), like the canvas
227
+ const [stageReady, setStageReady] = useState(0) // bumps on each sh:stage-ready (reload) - re-drives the highlight
140
228
  const fill = play?.device === 'fill'
141
- const [over, setOver] = useState(false) // pointer ON a chrome piece - never hide under the cursor
142
- // The H coach pill: hidden mode is ABSOLUTE (no corner-hover reveal - two mutually
143
- // blind pointer sources made it stick in both directions). The pill on entering hidden
144
- // is the recovery path: OK snoozes it 15 minutes, "Don't show again" retires it for
145
- // good; either way H is the only way back, and a fresh session opens with controls on.
146
- const [neverHint, setNeverHint] = useState(() => !!localStorage.getItem('mv-play-hint-off'))
147
- const [hint, setHint] = useState(false)
148
- const hintTimer = useRef<number | undefined>(undefined)
149
229
 
150
- const postStage = (msg: Record<string, unknown>) => iframeRef.current?.contentWindow?.postMessage(msg, '*')
230
+ // the stage is same-origin (/__mv/stage/); a fixed target origin keeps anchor bundles
231
+ // from leaking if a link ever navigates the iframe cross-origin
232
+ const postStage = (msg: Record<string, unknown>) => iframeRef.current?.contentWindow?.postMessage(msg, location.origin)
151
233
 
152
234
  // apply a deferred update: reload the stage iframe at its CURRENT frame + theme on a fresh
153
235
  // rev-stamped URL. sh:stage-ready replays position; device size is shell-owned.
@@ -159,8 +241,14 @@ function PlayInner() {
159
241
  }, [playNav])
160
242
  const applyUpdate = () => useStore.getState().applyPlayUpdate()
161
243
 
244
+ // laser / comment ride the same rail as canvas frames, broadcast to the stage iframe.
245
+ // Re-sent on stage-ready (below) so a stage reload restores the mode.
246
+ useEffect(() => { postStage({ type: 'sh:laser', on: laser }) }, [laser])
247
+ useEffect(() => { postStage({ type: 'sh:pick', on: commentMode }) }, [commentMode])
248
+
162
249
  const exit = () => {
163
250
  const { at } = useStore.getState().play ?? {}
251
+ if (isHideUI()) toggleHideUI() // never strand the canvas with its chrome hidden
164
252
  useStore.getState().setPlay(null)
165
253
  // land back on the canvas at the frame you ended on
166
254
  const n = useStore.getState().nodes.find((x) => x.frame === at && !x.missing)
@@ -232,18 +320,21 @@ function PlayInner() {
232
320
  // messages from the stage; source-validated against our one iframe
233
321
  useEffect(() => {
234
322
  const onMsg = (e: MessageEvent) => {
235
- if (e.source !== iframeRef.current?.contentWindow) return
323
+ if (e.source !== iframeRef.current?.contentWindow || (e.origin && e.origin !== location.origin)) return
236
324
  const data = e.data
237
325
  if (!data || typeof data.type !== 'string') return
238
326
  const s = useStore.getState()
239
327
  if (data.type === 'sh:stage-ready') {
240
328
  // an iframe reload (registry HMR invalidation) boots at the frozen initial src -
241
- // resync it to the shell's current truth so navigation and theme survive reloads
329
+ // resync it to the shell's current truth: navigation, theme, AND review modes
242
330
  const p = s.play
243
331
  if (p) {
244
332
  if (typeof data.at === 'string' && data.at !== p.at) postStage({ type: 'sh:stage-set', at: p.at })
245
333
  postStage({ type: 'sh:set-theme', theme: p.theme })
246
334
  }
335
+ postStage({ type: 'sh:laser', on: s.laser })
336
+ postStage({ type: 'sh:pick', on: commentsStore().commentMode })
337
+ setStageReady((n) => n + 1) // a reload wiped the frame's lock; let PlayComments re-drive it
247
338
  } else if (data.type === 'sh:stage-at') {
248
339
  const p = s.play
249
340
  if (p && typeof data.at === 'string') s.setPlay({ ...p, at: data.at })
@@ -254,45 +345,59 @@ function PlayInner() {
254
345
  } else if (data.type === 'sh:stage-key') {
255
346
  if (data.meta && data.key === '/') toggleCollapse()
256
347
  else handleKey(String(data.key), String(data.code))
348
+ } else if (data.type === 'sh:picked') {
349
+ // comment pick from the stage: stage the draft on the walked frame (its canvas
350
+ // node when one is on this board, so the thread also lands on the canvas). Stamp
351
+ // it with the frame the pick actually came FROM (data.id) and drop it if that no
352
+ // longer matches the shell frame - a click that landed mid-swap on stale DOM.
353
+ const c = commentsStore()
354
+ if (c.active) c.setActive(null)
355
+ if (!c.commentMode) return
356
+ const at = String(data.id ?? '')
357
+ if (!at || at !== s.play?.at) return
358
+ const n = s.nodes.find((x) => x.frame === at && !x.missing)
359
+ c.setDraft({ nodeKey: n?.key ?? '', frame: at, anchor: data.anchor })
360
+ } else if (data.type === 'sh:frame-down') {
361
+ const c = commentsStore()
362
+ if (c.active) c.setActive(null)
363
+ } else if (data.type === 'sh:laser-copy') {
364
+ // laser click in the prototype: copy the element's address (frame file + css path)
365
+ // and confirm in the frame's own hover label, exactly like the canvas does
366
+ const at = s.play?.at
367
+ const f = at ? s.manifest?.frames.find((x) => x.id === at) : undefined
368
+ if (f) {
369
+ const addr = `${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
370
+ navigator.clipboard.writeText(addr).then(
371
+ () => postStage({ type: 'sh:copy-ok', seq: data.seq }),
372
+ () => s.toast('copy blocked - click the canvas first'))
373
+ }
257
374
  }
258
375
  }
259
376
  window.addEventListener('message', onMsg)
260
377
  return () => window.removeEventListener('message', onMsg)
261
378
  }, [])
262
379
 
263
- /** Enter immersive mode; the coach pill teaches the way back unless snoozed/retired.
264
- * localStorage is read here, not via state - this runs in a mount-time closure. */
265
- const hideAll = () => {
266
- setChrome('hidden')
267
- if (localStorage.getItem('mv-play-hint-off')) return
268
- if (Number(localStorage.getItem('mv-play-hint-snooze') ?? 0) > Date.now()) return
269
- setHint(true)
270
- window.clearTimeout(hintTimer.current)
271
- hintTimer.current = window.setTimeout(() => setHint(false), 6000)
272
- }
273
- // Both dismissals unmount the pill UNDER the pointer - its pointerleave never fires,
274
- // so `over` must be cleared by hand or it sticks true and the idle fade never recovers.
275
- const snoozeHint = () => {
276
- localStorage.setItem('mv-play-hint-snooze', String(Date.now() + 15 * 60_000))
277
- setHint(false)
278
- setOver(false)
279
- }
280
- const dismissHintForever = () => {
281
- localStorage.setItem('mv-play-hint-off', '1')
282
- setNeverHint(true)
283
- setHint(false)
284
- setOver(false)
285
- }
286
-
287
380
  // shared handler: keys arrive directly (focus in shell) or forwarded by the stage
288
381
  const handleKey = (key: string, code: string) => {
289
- if (key === 'Escape') { exit(); return }
382
+ if (key === 'Escape') {
383
+ // a live review mode swallows Escape first (cancel), so it never ejects you from
384
+ // the prototype mid-comment; a second Escape (nothing live) exits
385
+ const c = commentsStore()
386
+ const s = useStore.getState()
387
+ if (c.commentMode || s.laser || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null); s.setLaser(false) }
388
+ else exit()
389
+ return
390
+ }
290
391
  if (key === 'ArrowRight') { step(1); return }
291
392
  if (key === 'ArrowLeft') { step(-1); return }
292
393
  if (key === '[') { switchVariant(-1); return }
293
394
  if (key === ']') { switchVariant(1); return }
294
395
  if (key === 'r') { restart(); return }
295
- if (key === 'h') { chromeRef.current === 'hidden' ? setChrome('open') : hideAll(); return }
396
+ if (key === 'h') { toggleHideUI(); return }
397
+ if (key === 'l') { const s = useStore.getState(); if (!s.laser) commentsStore().setMode(false); s.setLaser(!s.laser); return }
398
+ if (key === 'c') { const c = commentsStore(); if (!c.commentMode) useStore.getState().setLaser(false); c.setMode(!c.commentMode); return }
399
+ if (key === 'C') { const c = commentsStore(); c.setShow(!c.show); return } // ⇧C hides/shows pins
400
+
296
401
  if (/^Digit[1-9]$/.test(code)) {
297
402
  const names = Object.keys(CONFIG.viewports)
298
403
  const idx = Number(code.slice(5))
@@ -304,11 +409,8 @@ function PlayInner() {
304
409
  setTheme(CONFIG.themes[(CONFIG.themes.indexOf(p.theme) + 1) % CONFIG.themes.length])
305
410
  }
306
411
  }
307
- /** ⌘/ toggles the toolbar - same shortcut as design mode (⌘\ is the sidebar's).
308
- * From hidden it reveals EVERYTHING: collapsed is a state of visible chrome, so any
309
- * chrome shortcut pressed while hidden means "show me the controls", never a silent
310
- * hop into a half-revealed state. */
311
- const toggleCollapse = () => setChrome(chromeRef.current === 'open' ? 'collapsed' : 'open')
412
+ /** ⌘/ collapses the toolbar to a re-open fab - same shortcut + ladder as design mode. */
413
+ const toggleCollapse = () => setPillOpen((o) => !o)
312
414
  useEffect(() => {
313
415
  const onKey = (e: KeyboardEvent) => {
314
416
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
@@ -326,30 +428,10 @@ function PlayInner() {
326
428
  return () => window.removeEventListener('resize', onResize)
327
429
  }, [])
328
430
 
329
- // chrome auto-fades after 2.5 s of stillness; movement or a tap brings it back.
330
- // Coarse pointers (touch) never idle - there is no hover to wake a faded bar with,
331
- // and an unreachable close button would trap the viewer in play mode.
332
- useEffect(() => {
333
- if (window.matchMedia('(pointer: coarse)').matches) return
334
- let t = window.setTimeout(() => setIdle(true), 2500)
335
- const wake = () => { setIdle(false); window.clearTimeout(t); t = window.setTimeout(() => setIdle(true), 2500) }
336
- window.addEventListener('pointermove', wake)
337
- window.addEventListener('pointerdown', wake)
338
- return () => { window.clearTimeout(t); window.removeEventListener('pointermove', wake); window.removeEventListener('pointerdown', wake) }
339
- }, [])
340
-
341
431
  if (!play) return null // parent gates on play; belt to its braces
342
432
 
343
433
  const vp = fill ? { width: win.w, height: win.h } : CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
344
434
  const scale = fill ? 1 : Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
345
- // awake beats the idle fade; a pointer ON a chrome piece always keeps it. Hidden is
346
- // absolute: nothing shows but the coach pill, and H is the only way back.
347
- const awake = !idle || over
348
- const barOn = chrome === 'open' && awake
349
- const chipOn = chrome === 'collapsed' && awake
350
- const navOn = chrome !== 'hidden' && awake
351
- const hintOn = chrome === 'hidden' && !neverHint && (hint || over)
352
- const chromeProps = { onPointerEnter: () => setOver(true), onPointerLeave: () => setOver(false) }
353
435
  const names = Object.keys(CONFIG.viewports)
354
436
  const list = playList()
355
437
  const pos = list.indexOf(play.at)
@@ -359,78 +441,55 @@ function PlayInner() {
359
441
  // fractional sizes left subpixel seams glowing at the corners on dark frames
360
442
  const dw = Math.round(vp.width * scale)
361
443
  const dh = Math.round(vp.height * scale)
444
+ const deviceHint = fill ? 'Fill window' : `${vp.width} × ${vp.height} · keys 1-${names.length + 1}`
362
445
 
363
446
  return (
364
447
  <div className={`sh-play${fill ? ' fill' : ''}`}>
365
- <div className="dev" data-theme={play.theme} style={{ width: dw, height: dh }}>
366
- <iframe
367
- ref={iframeRef}
368
- src={src.current}
369
- title="play"
370
- style={{ width: vp.width, height: vp.height, transform: `scale(${dw / vp.width}, ${dh / vp.height})` }}
371
- />
448
+ {/* device + comment overlay share ONE flex-centered box; the overlay is inset:0 over
449
+ it (a sibling of the clipping .dev, so a thread card is never eaten by overflow) */}
450
+ <div className="sh-play-stage" style={{ width: dw, height: dh }}>
451
+ <div className="dev" data-theme={play.theme} style={{ width: dw, height: dh }}>
452
+ <iframe
453
+ ref={iframeRef}
454
+ src={src.current}
455
+ title="play"
456
+ style={{ width: vp.width, height: vp.height, transform: `scale(${dw / vp.width}, ${dh / vp.height})` }}
457
+ />
458
+ </div>
459
+ <PlayComments iframe={iframeRef} frameId={play.at} vp={vp} dw={dw} dh={dh} ready={stageReady} />
372
460
  </div>
373
461
 
374
- <div className={`sh-play-bar${barOn ? '' : ' idle'}`} {...chromeProps}>
462
+ <nav className={`sh-pill sh-play-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
375
463
  <BoardMenu current={board} />
376
464
  <i className="sep" />
377
- {Object.entries(CONFIG.viewports).map(([name, v], i) => (
378
- <Tip key={name} inv side="bottom" label={<><b>{cap(name)}</b><span>{v.width} × {v.height}</span><span className="k">{i + 1}</span></>}>
379
- <button className={play.device === name ? 'on' : undefined} onClick={() => setDevice(name)}>
380
- {deviceIcon(name, 15)}
381
- </button>
382
- </Tip>
383
- ))}
384
- <Tip inv side="bottom" label={<><b>Fill window</b><span className="k">{names.length + 1}</span></>}>
385
- <button className={fill ? 'on' : undefined} onClick={() => setDevice('fill')}>
386
- <FrameCornersIcon size={15} />
387
- </button>
388
- </Tip>
465
+ <CommentButton />
466
+ <LaserButton />
389
467
  <i className="sep" />
390
- {CONFIG.themes.map((t) => (
391
- <Tip key={t} inv side="bottom" label={<><b>{cap(t)} theme</b><span className="k">D</span></>}>
392
- <button className={play.theme === t ? 'on' : undefined} onClick={() => setTheme(t)}>
393
- {t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
394
- </button>
468
+ <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill hint={deviceHint} dark />
469
+ <ThemePicker value={play.theme} onSelect={setTheme} hint="D" dark />
470
+ {playUpdateRevision && <>
471
+ <i className="sep" />
472
+ <Tip side="bottom" label={<><b>Update ready</b><span>an edit landed - reload this prototype</span></>}>
473
+ <button className="sh-pill-btn sh-play-update" onClick={applyUpdate}><ReloadIcon size={13} /><span>Update</span></button>
395
474
  </Tip>
396
- ))}
397
- {playUpdateRevision && (
398
- <>
399
- <i className="sep" />
400
- <Tip inv side="bottom" label={<><b>Update ready</b><span>an edit landed - reload this prototype</span></>}>
401
- <button className="sh-play-update" onClick={applyUpdate}><ReloadIcon size={13} /><span>Update</span></button>
402
- </Tip>
403
- </>
404
- )}
475
+ </>}
405
476
  <i className="sep" />
406
- <Tip inv side="bottom" label={<><b>Collapse toolbar</b><span>H hides everything</span><span className="k">⌘/</span></>}>
407
- <button onClick={() => setChrome('collapsed')}>
408
- <PanelFilledIcon size={16} style={{ transform: 'rotate(90deg)' }} />
477
+ <HideUIButton />
478
+ <Tip side="bottom" label={<><b>Collapse toolbar</b><span>H hides everything · ⌘/</span></>}>
479
+ <button className="sh-pill-btn" onClick={() => setPillOpen(false)} tabIndex={pillOpen ? 0 : -1}>
480
+ <PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />
409
481
  </button>
410
482
  </Tip>
411
- <Tip inv side="bottom" label={<><b>Exit Prototype view</b><span className="k">Esc</span></>}>
412
- <button onClick={exit}><XIcon size={14} /></button>
483
+ <Tip side="bottom" label={<><b>Exit Prototype view</b><span>Esc</span></>}>
484
+ <button className="sh-pill-btn" onClick={exit}><XIcon size={14} /></button>
413
485
  </Tip>
414
- </div>
415
-
416
- <Tip inv side="bottom" label={<><b>Show toolbar</b><span>H hides everything</span><span className="k">⌘/</span></>}>
417
- <button className={`sh-play-chip${chipOn ? '' : ' idle'}`} onClick={() => setChrome('open')} {...chromeProps}>
418
- <PanelHollowIcon size={16} style={{ transform: 'rotate(90deg)' }} />
419
- </button>
486
+ </nav>
487
+ <Tip side="bottom" label={<><b>Open toolbar</b><span>⌘/</span></>}>
488
+ <button className={`sh-pill-fab${pillOpen ? ' hidden' : ''}`} onClick={() => setPillOpen(true)}
489
+ aria-hidden={pillOpen} tabIndex={pillOpen ? -1 : 0}><PanelHollowIcon size={18} style={{ transform: 'rotate(90deg)' }} /></button>
420
490
  </Tip>
421
491
 
422
- {hintOn && (
423
- <div className="sh-play-hint" {...chromeProps}>
424
- <span>Controls hidden - press <kbd>H</kbd> to bring them back</span>
425
- <i className="sep" />
426
- <Tip inv side="bottom" label="Snooze for 15 minutes">
427
- <button onClick={snoozeHint}>OK</button>
428
- </Tip>
429
- <button className="dim" onClick={dismissHintForever}>Don't show again</button>
430
- </div>
431
- )}
432
-
433
- <div className={`sh-play-nav${navOn ? '' : ' idle'}`} {...chromeProps}>
492
+ <div className="sh-play-nav">
434
493
  <Tip inv label={<><b>Restart</b><span className="k">R</span></>}>
435
494
  <button onClick={restart}><ReloadIcon size={14} /></button>
436
495
  </Tip>