@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.
- package/CHANGELOG.md +49 -0
- package/package.json +1 -1
- package/src/client/frame-host/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +346 -0
- package/src/client/shell/App.tsx +25 -110
- package/src/client/shell/Comments.tsx +56 -11
- package/src/client/shell/Play.tsx +214 -155
- package/src/client/shell/Toolbar.tsx +189 -0
- package/src/client/shell/canvas/FrameNode.tsx +26 -2
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/styles.css +63 -10
- package/src/client/stage/main.tsx +35 -11
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
|
91
|
+
/** Board switcher dropdown in the play pill - the shared glass popover. */
|
|
86
92
|
function BoardMenu({ current }: { current: string }) {
|
|
87
|
-
const
|
|
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="
|
|
100
|
-
<Tip
|
|
101
|
-
<button className="bd" onClick={
|
|
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={
|
|
102
|
+
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
104
103
|
</button>
|
|
105
104
|
</Tip>
|
|
106
|
-
{
|
|
107
|
-
|
|
108
|
-
{
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 [
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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') {
|
|
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') {
|
|
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
|
-
/** ⌘/
|
|
308
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
<
|
|
462
|
+
<nav className={`sh-pill sh-play-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
|
|
375
463
|
<BoardMenu current={board} />
|
|
376
464
|
<i className="sep" />
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
{
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
<
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
-
</
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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>
|