@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
@@ -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,123 @@ 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 showAnchor = useComments((s) => s.showAnchor)
129
+ const active = useComments((s) => s.active)
130
+ const draft = useComments((s) => s.draft)
131
+ const allThreads = useComments((s) => s.threads)
132
+ const { setActive } = useComments.getState()
133
+ const threads = allThreads.filter((t) => t.frame === frameId && !t.resolved)
134
+ const anchored = threads.filter((t) => (t.anchor as any)?.el)
135
+ const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
136
+ const sx = vp.width ? dw / vp.width : 1
137
+ const sy = vp.height ? dh / vp.height : 1
138
+
139
+ // resolve anchors against the stage's current frame; re-ask on a slow interval (the
140
+ // stage swaps in place, so the DOM under an anchor can change without a remount)
141
+ useEffect(() => {
142
+ const win = iframe.current?.contentWindow
143
+ if (!win || !anchored.length) { setRects({}); return }
144
+ const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: anchored.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
145
+ const onMsg = (e: MessageEvent) => {
146
+ // reject a reply from a frame we already walked off (data.id is the frame that resolved)
147
+ if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects' || e.data.id !== frameId) return
148
+ const next: typeof rects = {}
149
+ for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.rect
150
+ setRects(next)
151
+ }
152
+ window.addEventListener('message', onMsg)
153
+ ask()
154
+ // a silent walk swaps the stage a frame or two AFTER frameId changes; ask again quickly
155
+ // so pins/highlight land within ~1s instead of waiting on the 3s poll (the frame drops
156
+ // stale replies by id, so an early ask against the old DOM is harmless)
157
+ const t1 = setTimeout(ask, 250)
158
+ const t2 = setTimeout(ask, 700)
159
+ const iv = setInterval(ask, 3000)
160
+ return () => { clearTimeout(t1); clearTimeout(t2); clearInterval(iv); window.removeEventListener('message', onMsg) }
161
+ }, [frameId, anchored.map((t) => t.id).join(','), iframe, ready])
162
+
163
+ // #4/#5: drive the persistent element highlight into the frame - the composing draft, or
164
+ // the open thread's anchor. null clears it. The frame guards against a stage-swap race;
165
+ // `ready` re-drives it after a stage reload wiped the frame's lock.
166
+ useEffect(() => {
167
+ const win = iframe.current?.contentWindow
168
+ if (!win) return
169
+ // only while the thread is active (or composing) AND pins are shown AND element focus is
170
+ // on (⇧L) - closing, ⇧C, or dimming focus clears it
171
+ const at = !show || !showAnchor ? null : (draft?.frame === frameId ? draft.anchor
172
+ : (active ? (threads.find((t) => t.id === active && (t.anchor as any)?.el)?.anchor ?? null) : null))
173
+ win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: at ?? null }, location.origin)
174
+ }, [active, draft, show, showAnchor, frameId, iframe, rects, ready])
175
+
176
+ if (!show) return null
177
+
178
+ const pinAt = (t: typeof threads[number]) => {
179
+ const a = t.anchor as any
180
+ const r = rects[t.id]
181
+ 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 }
182
+ if (a?.el && r === null) return { x: dw - 16, y: 16, orphan: true } // orphan parks top-right
183
+ const p = a?.pos // frame-level fraction
184
+ return { x: (p?.fx ?? .94) * dw, y: (p?.fy ?? .06) * dh, orphan: false }
185
+ }
186
+ const activeThread = threads.find((t) => t.id === active)
187
+ const draftAt = draft?.frame === frameId ? (() => {
188
+ const a = draft.anchor as any
189
+ 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 }
190
+ })() : null
191
+
192
+ return (
193
+ <div className="sh-play-comments">
194
+ {threads.map((t) => {
195
+ const { x, y, orphan } = pinAt(t)
196
+ return (
197
+ <div key={t.id} className={`cm-pin sh-no-pan${t.id === active ? ' on' : ''}${orphan ? ' orphan' : ''}`}
198
+ style={{ left: x, top: y, ...hueVars((t.anchor as any)?.el?.hue) }}
199
+ onPointerDown={(e) => e.stopPropagation()}
200
+ onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
201
+ title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
202
+ <MarkerFace threads={[t]} />
203
+ </div>
204
+ )
205
+ })}
206
+ {activeThread && <ThreadCard key={active!} thread={activeThread} at={pinAt(activeThread)} bounds={{ w: dw, h: dh }} stage />}
207
+ {draftAt && <DraftComposer at={draftAt} bounds={{ w: dw, h: dh }} hue={(draft?.anchor as any)?.el?.hue} />}
116
208
  </div>
117
209
  )
118
210
  }
@@ -126,28 +218,21 @@ function PlayInner() {
126
218
  // the stage at its current position on an explicit click.
127
219
  const playUpdateRevision = useStore((s) => s.playUpdateRevision)
128
220
  const playNav = useStore((s) => s.playNav)
221
+ const laser = useStore((s) => s.laser)
222
+ const commentMode = useComments((s) => s.commentMode)
223
+ const playShowAnchor = useComments((s) => s.showAnchor)
129
224
  const iframeRef = useRef<HTMLIFrameElement>(null)
130
225
  // the src is frozen at mount - navigation happens INSIDE the stage; device and theme
131
226
  // changes must never reload it (a phone does not remount when you flip dark mode)
132
227
  const src = useRef(play ? `${ROUTE}/stage/?at=${encodeURIComponent(play.at)}&theme=${encodeURIComponent(play.theme)}` : '')
133
228
  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
229
+ const [pillOpen, setPillOpen] = useState(true) // collapse (pill -> re-open fab), like the canvas
230
+ const [stageReady, setStageReady] = useState(0) // bumps on each sh:stage-ready (reload) - re-drives the highlight
140
231
  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
232
 
150
- const postStage = (msg: Record<string, unknown>) => iframeRef.current?.contentWindow?.postMessage(msg, '*')
233
+ // the stage is same-origin (/__mv/stage/); a fixed target origin keeps anchor bundles
234
+ // from leaking if a link ever navigates the iframe cross-origin
235
+ const postStage = (msg: Record<string, unknown>) => iframeRef.current?.contentWindow?.postMessage(msg, location.origin)
151
236
 
152
237
  // apply a deferred update: reload the stage iframe at its CURRENT frame + theme on a fresh
153
238
  // rev-stamped URL. sh:stage-ready replays position; device size is shell-owned.
@@ -159,8 +244,14 @@ function PlayInner() {
159
244
  }, [playNav])
160
245
  const applyUpdate = () => useStore.getState().applyPlayUpdate()
161
246
 
247
+ // laser / comment ride the same rail as canvas frames, broadcast to the stage iframe.
248
+ // Re-sent on stage-ready (below) so a stage reload restores the mode.
249
+ useEffect(() => { postStage({ type: 'sh:laser', on: laser }) }, [laser])
250
+ useEffect(() => { postStage({ type: 'sh:pick', on: commentMode, quiet: !useComments.getState().showAnchor }) }, [commentMode, playShowAnchor])
251
+
162
252
  const exit = () => {
163
253
  const { at } = useStore.getState().play ?? {}
254
+ if (isHideUI()) toggleHideUI() // never strand the canvas with its chrome hidden
164
255
  useStore.getState().setPlay(null)
165
256
  // land back on the canvas at the frame you ended on
166
257
  const n = useStore.getState().nodes.find((x) => x.frame === at && !x.missing)
@@ -232,18 +323,21 @@ function PlayInner() {
232
323
  // messages from the stage; source-validated against our one iframe
233
324
  useEffect(() => {
234
325
  const onMsg = (e: MessageEvent) => {
235
- if (e.source !== iframeRef.current?.contentWindow) return
326
+ if (e.source !== iframeRef.current?.contentWindow || (e.origin && e.origin !== location.origin)) return
236
327
  const data = e.data
237
328
  if (!data || typeof data.type !== 'string') return
238
329
  const s = useStore.getState()
239
330
  if (data.type === 'sh:stage-ready') {
240
331
  // 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
332
+ // resync it to the shell's current truth: navigation, theme, AND review modes
242
333
  const p = s.play
243
334
  if (p) {
244
335
  if (typeof data.at === 'string' && data.at !== p.at) postStage({ type: 'sh:stage-set', at: p.at })
245
336
  postStage({ type: 'sh:set-theme', theme: p.theme })
246
337
  }
338
+ postStage({ type: 'sh:laser', on: s.laser })
339
+ postStage({ type: 'sh:pick', on: commentsStore().commentMode, quiet: !commentsStore().showAnchor })
340
+ setStageReady((n) => n + 1) // a reload wiped the frame's lock; let PlayComments re-drive it
247
341
  } else if (data.type === 'sh:stage-at') {
248
342
  const p = s.play
249
343
  if (p && typeof data.at === 'string') s.setPlay({ ...p, at: data.at })
@@ -254,45 +348,60 @@ function PlayInner() {
254
348
  } else if (data.type === 'sh:stage-key') {
255
349
  if (data.meta && data.key === '/') toggleCollapse()
256
350
  else handleKey(String(data.key), String(data.code))
351
+ } else if (data.type === 'sh:picked') {
352
+ // comment pick from the stage: stage the draft on the walked frame (its canvas
353
+ // node when one is on this board, so the thread also lands on the canvas). Stamp
354
+ // it with the frame the pick actually came FROM (data.id) and drop it if that no
355
+ // longer matches the shell frame - a click that landed mid-swap on stale DOM.
356
+ const c = commentsStore()
357
+ if (c.active) c.setActive(null)
358
+ if (!c.commentMode) return
359
+ const at = String(data.id ?? '')
360
+ if (!at || at !== s.play?.at) return
361
+ const n = s.nodes.find((x) => x.frame === at && !x.missing)
362
+ c.setDraft({ nodeKey: n?.key ?? '', frame: at, anchor: data.anchor })
363
+ } else if (data.type === 'sh:frame-down') {
364
+ const c = commentsStore()
365
+ if (c.active) c.setActive(null)
366
+ } else if (data.type === 'sh:laser-copy') {
367
+ // laser click in the prototype: copy the element's address (frame file + css path)
368
+ // and confirm in the frame's own hover label, exactly like the canvas does
369
+ const at = s.play?.at
370
+ const f = at ? s.manifest?.frames.find((x) => x.id === at) : undefined
371
+ if (f) {
372
+ const addr = `${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
373
+ navigator.clipboard.writeText(addr).then(
374
+ () => postStage({ type: 'sh:copy-ok', seq: data.seq }),
375
+ () => s.toast('copy blocked - click the canvas first'))
376
+ }
257
377
  }
258
378
  }
259
379
  window.addEventListener('message', onMsg)
260
380
  return () => window.removeEventListener('message', onMsg)
261
381
  }, [])
262
382
 
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
383
  // shared handler: keys arrive directly (focus in shell) or forwarded by the stage
288
384
  const handleKey = (key: string, code: string) => {
289
- if (key === 'Escape') { exit(); return }
385
+ if (key === 'Escape') {
386
+ // a live review mode swallows Escape first (cancel), so it never ejects you from
387
+ // the prototype mid-comment; a second Escape (nothing live) exits
388
+ const c = commentsStore()
389
+ const s = useStore.getState()
390
+ if (c.commentMode || s.laser || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null); s.setLaser(false) }
391
+ else exit()
392
+ return
393
+ }
290
394
  if (key === 'ArrowRight') { step(1); return }
291
395
  if (key === 'ArrowLeft') { step(-1); return }
292
396
  if (key === '[') { switchVariant(-1); return }
293
397
  if (key === ']') { switchVariant(1); return }
294
398
  if (key === 'r') { restart(); return }
295
- if (key === 'h') { chromeRef.current === 'hidden' ? setChrome('open') : hideAll(); return }
399
+ if (key === 'h') { toggleHideUI(); return }
400
+ if (key === 'l') { const s = useStore.getState(); if (!s.laser) commentsStore().setMode(false); s.setLaser(!s.laser); return }
401
+ if (key === 'c') { const c = commentsStore(); if (!c.commentMode) useStore.getState().setLaser(false); c.setMode(!c.commentMode); return }
402
+ if (key === 'C') { const c = commentsStore(); c.setShow(!c.show); return } // ⇧C hides/shows pins
403
+ if (key === 'L') { const c = commentsStore(); c.setShowAnchor(!c.showAnchor); return } // ⇧L element focus
404
+
296
405
  if (/^Digit[1-9]$/.test(code)) {
297
406
  const names = Object.keys(CONFIG.viewports)
298
407
  const idx = Number(code.slice(5))
@@ -304,11 +413,8 @@ function PlayInner() {
304
413
  setTheme(CONFIG.themes[(CONFIG.themes.indexOf(p.theme) + 1) % CONFIG.themes.length])
305
414
  }
306
415
  }
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')
416
+ /** ⌘/ collapses the toolbar to a re-open fab - same shortcut + ladder as design mode. */
417
+ const toggleCollapse = () => setPillOpen((o) => !o)
312
418
  useEffect(() => {
313
419
  const onKey = (e: KeyboardEvent) => {
314
420
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
@@ -326,30 +432,10 @@ function PlayInner() {
326
432
  return () => window.removeEventListener('resize', onResize)
327
433
  }, [])
328
434
 
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
435
  if (!play) return null // parent gates on play; belt to its braces
342
436
 
343
437
  const vp = fill ? { width: win.w, height: win.h } : CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
344
438
  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
439
  const names = Object.keys(CONFIG.viewports)
354
440
  const list = playList()
355
441
  const pos = list.indexOf(play.at)
@@ -359,78 +445,55 @@ function PlayInner() {
359
445
  // fractional sizes left subpixel seams glowing at the corners on dark frames
360
446
  const dw = Math.round(vp.width * scale)
361
447
  const dh = Math.round(vp.height * scale)
448
+ const deviceHint = fill ? 'Fill window' : `${vp.width} × ${vp.height} · keys 1-${names.length + 1}`
362
449
 
363
450
  return (
364
451
  <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
- />
452
+ {/* device + comment overlay share ONE flex-centered box; the overlay is inset:0 over
453
+ it (a sibling of the clipping .dev, so a thread card is never eaten by overflow) */}
454
+ <div className="sh-play-stage" style={{ width: dw, height: dh }}>
455
+ <div className="dev" data-theme={play.theme} style={{ width: dw, height: dh }}>
456
+ <iframe
457
+ ref={iframeRef}
458
+ src={src.current}
459
+ title="play"
460
+ style={{ width: vp.width, height: vp.height, transform: `scale(${dw / vp.width}, ${dh / vp.height})` }}
461
+ />
462
+ </div>
463
+ <PlayComments iframe={iframeRef} frameId={play.at} vp={vp} dw={dw} dh={dh} ready={stageReady} />
372
464
  </div>
373
465
 
374
- <div className={`sh-play-bar${barOn ? '' : ' idle'}`} {...chromeProps}>
466
+ <nav className={`sh-pill sh-play-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
375
467
  <BoardMenu current={board} />
376
468
  <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>
469
+ <CommentButton />
470
+ <LaserButton />
389
471
  <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>
472
+ <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill hint={deviceHint} dark />
473
+ <ThemePicker value={play.theme} onSelect={setTheme} hint="D" dark />
474
+ {playUpdateRevision && <>
475
+ <i className="sep" />
476
+ <Tip side="bottom" label={<><b>Update ready</b><span>an edit landed - reload this prototype</span></>}>
477
+ <button className="sh-pill-btn sh-play-update" onClick={applyUpdate}><ReloadIcon size={13} /><span>Update</span></button>
395
478
  </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
- )}
479
+ </>}
405
480
  <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)' }} />
481
+ <HideUIButton />
482
+ <Tip side="bottom" label={<><b>Collapse toolbar</b><span>H hides everything · ⌘/</span></>}>
483
+ <button className="sh-pill-btn" onClick={() => setPillOpen(false)} tabIndex={pillOpen ? 0 : -1}>
484
+ <PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />
409
485
  </button>
410
486
  </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>
487
+ <Tip side="bottom" label={<><b>Exit Prototype view</b><span>Esc</span></>}>
488
+ <button className="sh-pill-btn" onClick={exit}><XIcon size={14} /></button>
413
489
  </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>
490
+ </nav>
491
+ <Tip side="bottom" label={<><b>Open toolbar</b><span>⌘/</span></>}>
492
+ <button className={`sh-pill-fab${pillOpen ? ' hidden' : ''}`} onClick={() => setPillOpen(true)}
493
+ aria-hidden={pillOpen} tabIndex={pillOpen ? -1 : 0}><PanelHollowIcon size={18} style={{ transform: 'rotate(90deg)' }} /></button>
420
494
  </Tip>
421
495
 
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}>
496
+ <div className="sh-play-nav">
434
497
  <Tip inv label={<><b>Restart</b><span className="k">R</span></>}>
435
498
  <button onClick={restart}><ReloadIcon size={14} /></button>
436
499
  </Tip>