@marver-design/marver 0.2.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 (51) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +38 -0
  4. package/dist/build-D0GnIR4G.mjs +244 -0
  5. package/dist/cli.mjs +58 -0
  6. package/dist/config-DMBEpdEN.mjs +132 -0
  7. package/dist/dev-BK4x3PBr.mjs +76 -0
  8. package/dist/init-DcOy1krf.mjs +168 -0
  9. package/dist/plugin-DB5t2WUl.mjs +437 -0
  10. package/dist/serve-BPNmWeJx.mjs +200 -0
  11. package/package.json +59 -0
  12. package/src/client/const.ts +5 -0
  13. package/src/client/frame-host/bridge.js +37 -0
  14. package/src/client/frame-host/index.html +11 -0
  15. package/src/client/frame-host/main.tsx +73 -0
  16. package/src/client/frame-host/registry.ts +40 -0
  17. package/src/client/runtime/index.ts +8 -0
  18. package/src/client/shell/App.tsx +633 -0
  19. package/src/client/shell/Play.tsx +399 -0
  20. package/src/client/shell/Tip.tsx +43 -0
  21. package/src/client/shell/canvas/Canvas.tsx +285 -0
  22. package/src/client/shell/canvas/FrameNode.tsx +194 -0
  23. package/src/client/shell/favicon/android-chrome-192x192-interactive.png +0 -0
  24. package/src/client/shell/favicon/android-chrome-192x192.png +0 -0
  25. package/src/client/shell/favicon/android-chrome-512x512-interactive.png +0 -0
  26. package/src/client/shell/favicon/android-chrome-512x512.png +0 -0
  27. package/src/client/shell/favicon/apple-touch-icon-interactive.png +0 -0
  28. package/src/client/shell/favicon/apple-touch-icon.png +0 -0
  29. package/src/client/shell/favicon/favicon-16x16-interactive.png +0 -0
  30. package/src/client/shell/favicon/favicon-16x16.png +0 -0
  31. package/src/client/shell/favicon/favicon-32x32-interactive.png +0 -0
  32. package/src/client/shell/favicon/favicon-32x32.png +0 -0
  33. package/src/client/shell/favicon/favicon.ico +0 -0
  34. package/src/client/shell/favicon/site.webmanifest +11 -0
  35. package/src/client/shell/hash.ts +67 -0
  36. package/src/client/shell/icons.tsx +53 -0
  37. package/src/client/shell/index.html +17 -0
  38. package/src/client/shell/main.tsx +9 -0
  39. package/src/client/shell/store.ts +540 -0
  40. package/src/client/shell/styles.css +508 -0
  41. package/src/client/shell/tidy.ts +24 -0
  42. package/src/client/stage/index.html +11 -0
  43. package/src/client/stage/main.tsx +160 -0
  44. package/templates/AGENTS-embedded.md +76 -0
  45. package/templates/AGENTS-studio.md +76 -0
  46. package/templates/demo/_fixtures.ts +9 -0
  47. package/templates/demo/dark.tsx +17 -0
  48. package/templates/demo/form.tsx +51 -0
  49. package/templates/demo/welcome.tsx +21 -0
  50. package/templates/design-tsconfig.json +8 -0
  51. package/templates/root-layout.tsx +7 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Play mode (SPEC-M2 §1): full-window near-black backdrop, ONE device shell centered at
3
+ * the chosen viewport's exact CSS pixels, scaled to fit - or `fill`, where the frame IS
4
+ * the window. The device hosts a single stage iframe that swaps frames in place.
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.
11
+ */
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import { useStore, CONFIG, boardLabel, cap, fetchBoardNames, type Node } from './store.ts'
14
+ import { ROUTE } from '../const.ts'
15
+ import { canvasCtl } from './canvas/Canvas.tsx'
16
+ import { Tip } from './Tip.tsx'
17
+ import { ArrowLeftIcon, ArrowRightIcon, CaretIcon, CheckIcon, FrameCornersIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ReloadIcon, SunIcon, XIcon, deviceIcon } from './icons.tsx'
18
+
19
+ /** Board-order frame ids playable on the stage (tsx only - html frames are their own
20
+ * documents and cannot mount into the persistent chain), deduped. */
21
+ function playList(): string[] {
22
+ const s = useStore.getState()
23
+ const seen = new Set<string>()
24
+ const out: string[] = []
25
+ for (const n of s.nodes) {
26
+ if (n.missing || seen.has(n.frame)) continue
27
+ const f = s.frameFor(n)
28
+ if (!f || f.kind !== 'tsx') continue
29
+ seen.add(n.frame)
30
+ out.push(n.frame)
31
+ }
32
+ return out
33
+ }
34
+
35
+ /** Enter play on the current board: first selected node starts, else the first node.
36
+ * `over` (deep links) can pin the start frame, device, and theme - the start may be any
37
+ * playable manifest frame, not just a board node: the stage itself allows data-goto to
38
+ * off-board frames, so a link captured mid-flow must restore to the same screen. */
39
+ export function enterPlay(over?: { at?: string; device?: string; theme?: string }) {
40
+ const s = useStore.getState()
41
+ const list = playList()
42
+ if (!list.length) { s.toast('nothing to play on this board'); return }
43
+ const overAt = over?.at && s.manifest?.frames.some((f) => f.id === over.at && f.kind === 'tsx') ? over.at : undefined
44
+ const selNode = s.selection.map((k) => s.nodes.find((n) => n.key === k)).find((n): n is Node => !!n && list.includes(n.frame))
45
+ // node is undefined only for an off-board overAt - frame meta then carries the defaults
46
+ const node = (overAt ? s.nodes.find((n) => n.frame === overAt) : undefined)
47
+ ?? (overAt ? undefined : selNode ?? s.nodes.find((n) => list.includes(n.frame)))
48
+ const at = overAt ?? node!.frame
49
+ const frame = s.manifest?.frames.find((f) => f.id === at)
50
+ // device: the link's; else the node's width names it; else the frame's declared viewport
51
+ const names = Object.keys(CONFIG.viewports)
52
+ const device = (over?.device && (CONFIG.viewports[over.device] || over.device === 'fill') ? over.device : undefined)
53
+ ?? (node ? names.find((v) => CONFIG.viewports[v].width === node.w) : undefined)
54
+ ?? (frame?.viewport && CONFIG.viewports[frame.viewport] ? frame.viewport : names[0])
55
+ const theme = (over?.theme && CONFIG.themes.includes(over.theme) ? over.theme : undefined)
56
+ ?? node?.theme ?? frame?.theme ?? s.viewTheme
57
+ s.setPlay({ at, device, theme })
58
+ }
59
+
60
+ /** Switch boards WITHOUT leaving play: the overlay stays up over the canvas churn, then
61
+ * a fresh stage mounts at the new board's start (PlayInner is keyed by board). */
62
+ async function switchPlayBoard(name: string) {
63
+ const s = useStore.getState()
64
+ if (name === s.board) return
65
+ const device = s.play?.device // the device is the viewer's choice - it survives the switch
66
+ await s.switchBoard(name)
67
+ if (useStore.getState().board === name) enterPlay(device ? { device } : undefined)
68
+ }
69
+
70
+ /** Control channel for history restores: the popstate handler steers the mounted stage
71
+ * without a remount. Assigned by PlayInner; a no-op while play is closed. */
72
+ export const playCtl = {
73
+ setAt: (_at: string) => {},
74
+ /** Apply a parsed play hash to the OPEN session - at, device, and theme alike. */
75
+ sync: (_p: { at?: string; device?: string; theme?: string }) => {},
76
+ }
77
+
78
+ export function PlayOverlay() {
79
+ const play = useStore((s) => s.play)
80
+ const board = useStore((s) => s.board)
81
+ if (!play) return null
82
+ return <PlayInner key={board} />
83
+ }
84
+
85
+ /** Board switcher dropdown in the play bar. */
86
+ function BoardMenu({ current }: { current: string }) {
87
+ const [open, setOpen] = useState(false)
88
+ const [names, setNames] = useState<string[]>([current])
89
+ const boxRef = useRef<HTMLDivElement>(null)
90
+ // 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])
98
+ 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)}>
102
+ {boardLabel(current)}
103
+ <CaretIcon size={10} style={{ transform: open ? 'rotate(180deg)' : undefined }} />
104
+ </button>
105
+ </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
+ )}
116
+ </div>
117
+ )
118
+ }
119
+
120
+ function PlayInner() {
121
+ const play = useStore((s) => s.play)
122
+ const board = useStore((s) => s.board)
123
+ const iframeRef = useRef<HTMLIFrameElement>(null)
124
+ // the src is frozen at mount - navigation happens INSIDE the stage; device and theme
125
+ // changes must never reload it (a phone does not remount when you flip dark mode)
126
+ const src = useRef(play ? `${ROUTE}/stage/?at=${encodeURIComponent(play.at)}&theme=${encodeURIComponent(play.theme)}` : '')
127
+ const [win, setWin] = useState({ w: window.innerWidth, h: window.innerHeight })
128
+ const [idle, setIdle] = useState(false)
129
+ // chrome has three states, like the sidebar's panel/fab ladder: open (full bar + nav),
130
+ // collapsed (a single chip to expand back, nav stays), hidden (immersive - nothing).
131
+ const [chrome, setChrome] = useState<'open' | 'collapsed' | 'hidden'>('open')
132
+ const chromeRef = useRef(chrome) // handleKey lives in a mount-time closure
133
+ chromeRef.current = chrome
134
+ const fill = play?.device === 'fill'
135
+ const [over, setOver] = useState(false) // pointer ON a chrome piece - never hide under the cursor
136
+ // The H coach pill: hidden mode is ABSOLUTE (no corner-hover reveal - two mutually
137
+ // blind pointer sources made it stick in both directions). The pill on entering hidden
138
+ // is the recovery path: OK snoozes it 15 minutes, "Don't show again" retires it for
139
+ // good; either way H is the only way back, and a fresh session opens with controls on.
140
+ const [neverHint, setNeverHint] = useState(() => !!localStorage.getItem('mv-play-hint-off'))
141
+ const [hint, setHint] = useState(false)
142
+ const hintTimer = useRef<number | undefined>(undefined)
143
+
144
+ const postStage = (msg: Record<string, unknown>) => iframeRef.current?.contentWindow?.postMessage(msg, '*')
145
+
146
+ const exit = () => {
147
+ const { at } = useStore.getState().play ?? {}
148
+ useStore.getState().setPlay(null)
149
+ // land back on the canvas at the frame you ended on
150
+ const n = useStore.getState().nodes.find((x) => x.frame === at && !x.missing)
151
+ if (n) { useStore.getState().select(n.key); setTimeout(() => canvasCtl.fitNode(n.key), 30) }
152
+ }
153
+
154
+ const setDevice = (name: string) => {
155
+ const p = useStore.getState().play
156
+ if (p && (CONFIG.viewports[name] || name === 'fill')) useStore.getState().setPlay({ ...p, device: name })
157
+ }
158
+ const setTheme = (t: string) => {
159
+ const p = useStore.getState().play
160
+ if (!p) return
161
+ useStore.getState().setPlay({ ...p, theme: t })
162
+ postStage({ type: 'sh:set-theme', theme: t })
163
+ }
164
+ /** Walk to a frame: the stage swaps silently; state + URL follow via the projection. */
165
+ const goTo = (at: string) => playCtl.setAt(at)
166
+ const step = (dir: 1 | -1) => {
167
+ const p = useStore.getState().play
168
+ if (!p) return
169
+ const list = playList()
170
+ if (!list.length) return
171
+ const i = list.indexOf(p.at)
172
+ // an off-board frame has no position: → restarts, ← goes to the last board frame
173
+ goTo(i === -1 ? (dir === 1 ? list[0] : list[list.length - 1]) : list[(i + dir + list.length) % list.length])
174
+ }
175
+ const restart = () => { const list = playList(); if (list.length) goTo(list[0]) }
176
+
177
+ // history restores + walk: swap the stage silently (no sh:stage-at back) and track here
178
+ useEffect(() => {
179
+ playCtl.setAt = (at: string) => {
180
+ postStage({ type: 'sh:stage-set', at })
181
+ const p = useStore.getState().play
182
+ if (p && p.at !== at) useStore.getState().setPlay({ ...p, at })
183
+ }
184
+ playCtl.sync = (p) => {
185
+ const cur = useStore.getState().play
186
+ if (!cur) return
187
+ if (p.device && p.device !== cur.device) setDevice(p.device)
188
+ if (p.theme && p.theme !== cur.theme) setTheme(p.theme)
189
+ if (p.at && p.at !== cur.at) playCtl.setAt(p.at)
190
+ }
191
+ return () => { playCtl.setAt = () => {}; playCtl.sync = () => {} }
192
+ }, [])
193
+
194
+ // messages from the stage; source-validated against our one iframe
195
+ useEffect(() => {
196
+ const onMsg = (e: MessageEvent) => {
197
+ if (e.source !== iframeRef.current?.contentWindow) return
198
+ const data = e.data
199
+ if (!data || typeof data.type !== 'string') return
200
+ const s = useStore.getState()
201
+ if (data.type === 'sh:stage-ready') {
202
+ // an iframe reload (registry HMR invalidation) boots at the frozen initial src -
203
+ // resync it to the shell's current truth so navigation and theme survive reloads
204
+ const p = s.play
205
+ if (p) {
206
+ if (typeof data.at === 'string' && data.at !== p.at) postStage({ type: 'sh:stage-set', at: p.at })
207
+ postStage({ type: 'sh:set-theme', theme: p.theme })
208
+ }
209
+ } else if (data.type === 'sh:stage-at') {
210
+ const p = s.play
211
+ if (p && typeof data.at === 'string') s.setPlay({ ...p, at: data.at })
212
+ } else if (data.type === 'sh:stage-exit') {
213
+ exit()
214
+ } else if (data.type === 'sh:stage-error') {
215
+ s.toast(`play: ${String(data.message ?? 'frame error')}`)
216
+ } else if (data.type === 'sh:stage-key') {
217
+ if (data.meta && data.key === '/') toggleCollapse()
218
+ else handleKey(String(data.key), String(data.code))
219
+ }
220
+ }
221
+ window.addEventListener('message', onMsg)
222
+ return () => window.removeEventListener('message', onMsg)
223
+ }, [])
224
+
225
+ /** Enter immersive mode; the coach pill teaches the way back unless snoozed/retired.
226
+ * localStorage is read here, not via state - this runs in a mount-time closure. */
227
+ const hideAll = () => {
228
+ setChrome('hidden')
229
+ if (localStorage.getItem('mv-play-hint-off')) return
230
+ if (Number(localStorage.getItem('mv-play-hint-snooze') ?? 0) > Date.now()) return
231
+ setHint(true)
232
+ window.clearTimeout(hintTimer.current)
233
+ hintTimer.current = window.setTimeout(() => setHint(false), 6000)
234
+ }
235
+ // Both dismissals unmount the pill UNDER the pointer - its pointerleave never fires,
236
+ // so `over` must be cleared by hand or it sticks true and the idle fade never recovers.
237
+ const snoozeHint = () => {
238
+ localStorage.setItem('mv-play-hint-snooze', String(Date.now() + 15 * 60_000))
239
+ setHint(false)
240
+ setOver(false)
241
+ }
242
+ const dismissHintForever = () => {
243
+ localStorage.setItem('mv-play-hint-off', '1')
244
+ setNeverHint(true)
245
+ setHint(false)
246
+ setOver(false)
247
+ }
248
+
249
+ // shared handler: keys arrive directly (focus in shell) or forwarded by the stage
250
+ const handleKey = (key: string, code: string) => {
251
+ if (key === 'Escape') { exit(); return }
252
+ if (key === 'ArrowRight') { step(1); return }
253
+ if (key === 'ArrowLeft') { step(-1); return }
254
+ if (key === 'r') { restart(); return }
255
+ if (key === 'h') { chromeRef.current === 'hidden' ? setChrome('open') : hideAll(); return }
256
+ if (/^Digit[1-9]$/.test(code)) {
257
+ const names = Object.keys(CONFIG.viewports)
258
+ const idx = Number(code.slice(5))
259
+ if (idx <= names.length) setDevice(names[idx - 1])
260
+ else if (idx === names.length + 1) setDevice('fill')
261
+ }
262
+ if (key === 'd' && CONFIG.themes.length > 1) {
263
+ const p = useStore.getState().play!
264
+ setTheme(CONFIG.themes[(CONFIG.themes.indexOf(p.theme) + 1) % CONFIG.themes.length])
265
+ }
266
+ }
267
+ /** ⌘/ toggles the toolbar - same shortcut as design mode (⌘\ is the sidebar's).
268
+ * From hidden it reveals EVERYTHING: collapsed is a state of visible chrome, so any
269
+ * chrome shortcut pressed while hidden means "show me the controls", never a silent
270
+ * hop into a half-revealed state. */
271
+ const toggleCollapse = () => setChrome(chromeRef.current === 'open' ? 'collapsed' : 'open')
272
+ useEffect(() => {
273
+ const onKey = (e: KeyboardEvent) => {
274
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
275
+ if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); toggleCollapse(); return }
276
+ if (e.metaKey || e.ctrlKey) return
277
+ handleKey(e.key, e.code)
278
+ }
279
+ window.addEventListener('keydown', onKey)
280
+ return () => window.removeEventListener('keydown', onKey)
281
+ }, [])
282
+
283
+ useEffect(() => {
284
+ const onResize = () => setWin({ w: window.innerWidth, h: window.innerHeight })
285
+ window.addEventListener('resize', onResize)
286
+ return () => window.removeEventListener('resize', onResize)
287
+ }, [])
288
+
289
+ // chrome auto-fades after 2.5 s of stillness; movement or a tap brings it back.
290
+ // Coarse pointers (touch) never idle - there is no hover to wake a faded bar with,
291
+ // and an unreachable close button would trap the viewer in play mode.
292
+ useEffect(() => {
293
+ if (window.matchMedia('(pointer: coarse)').matches) return
294
+ let t = window.setTimeout(() => setIdle(true), 2500)
295
+ const wake = () => { setIdle(false); window.clearTimeout(t); t = window.setTimeout(() => setIdle(true), 2500) }
296
+ window.addEventListener('pointermove', wake)
297
+ window.addEventListener('pointerdown', wake)
298
+ return () => { window.clearTimeout(t); window.removeEventListener('pointermove', wake); window.removeEventListener('pointerdown', wake) }
299
+ }, [])
300
+
301
+ if (!play) return null // parent gates on play; belt to its braces
302
+
303
+ const vp = fill ? { width: win.w, height: win.h } : CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
304
+ const scale = fill ? 1 : Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
305
+ // awake beats the idle fade; a pointer ON a chrome piece always keeps it. Hidden is
306
+ // absolute: nothing shows but the coach pill, and H is the only way back.
307
+ const awake = !idle || over
308
+ const barOn = chrome === 'open' && awake
309
+ const chipOn = chrome === 'collapsed' && awake
310
+ const navOn = chrome !== 'hidden' && awake
311
+ const hintOn = chrome === 'hidden' && !neverHint && (hint || over)
312
+ const chromeProps = { onPointerEnter: () => setOver(true), onPointerLeave: () => setOver(false) }
313
+ const names = Object.keys(CONFIG.viewports)
314
+ const list = playList()
315
+ const pos = list.indexOf(play.at)
316
+
317
+ // whole-pixel wrapper + per-axis scale so the iframe lands exactly on its edges -
318
+ // fractional sizes left subpixel seams glowing at the corners on dark frames
319
+ const dw = Math.round(vp.width * scale)
320
+ const dh = Math.round(vp.height * scale)
321
+
322
+ return (
323
+ <div className={`sh-play${fill ? ' fill' : ''}`}>
324
+ <div className="dev" data-theme={play.theme} style={{ width: dw, height: dh }}>
325
+ <iframe
326
+ ref={iframeRef}
327
+ src={src.current}
328
+ title="play"
329
+ style={{ width: vp.width, height: vp.height, transform: `scale(${dw / vp.width}, ${dh / vp.height})` }}
330
+ />
331
+ </div>
332
+
333
+ <div className={`sh-play-bar${barOn ? '' : ' idle'}`} {...chromeProps}>
334
+ <BoardMenu current={board} />
335
+ <i className="sep" />
336
+ {Object.entries(CONFIG.viewports).map(([name, v], i) => (
337
+ <Tip key={name} inv side="bottom" label={<><b>{cap(name)}</b><span>{v.width} × {v.height}</span><span className="k">{i + 1}</span></>}>
338
+ <button className={play.device === name ? 'on' : undefined} onClick={() => setDevice(name)}>
339
+ {deviceIcon(name, 15)}
340
+ </button>
341
+ </Tip>
342
+ ))}
343
+ <Tip inv side="bottom" label={<><b>Fill window</b><span className="k">{names.length + 1}</span></>}>
344
+ <button className={fill ? 'on' : undefined} onClick={() => setDevice('fill')}>
345
+ <FrameCornersIcon size={15} />
346
+ </button>
347
+ </Tip>
348
+ <i className="sep" />
349
+ {CONFIG.themes.map((t) => (
350
+ <Tip key={t} inv side="bottom" label={<><b>{cap(t)} theme</b><span className="k">D</span></>}>
351
+ <button className={play.theme === t ? 'on' : undefined} onClick={() => setTheme(t)}>
352
+ {t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
353
+ </button>
354
+ </Tip>
355
+ ))}
356
+ <i className="sep" />
357
+ <Tip inv side="bottom" label={<><b>Collapse toolbar</b><span>H hides everything</span><span className="k">⌘/</span></>}>
358
+ <button onClick={() => setChrome('collapsed')}>
359
+ <PanelFilledIcon size={16} style={{ transform: 'rotate(90deg)' }} />
360
+ </button>
361
+ </Tip>
362
+ <Tip inv side="bottom" label={<><b>Exit Prototype view</b><span className="k">Esc</span></>}>
363
+ <button onClick={exit}><XIcon size={14} /></button>
364
+ </Tip>
365
+ </div>
366
+
367
+ <Tip inv side="bottom" label={<><b>Show toolbar</b><span>H hides everything</span><span className="k">⌘/</span></>}>
368
+ <button className={`sh-play-chip${chipOn ? '' : ' idle'}`} onClick={() => setChrome('open')} {...chromeProps}>
369
+ <PanelHollowIcon size={16} style={{ transform: 'rotate(90deg)' }} />
370
+ </button>
371
+ </Tip>
372
+
373
+ {hintOn && (
374
+ <div className="sh-play-hint" {...chromeProps}>
375
+ <span>Controls hidden - press <kbd>H</kbd> to bring them back</span>
376
+ <i className="sep" />
377
+ <Tip inv side="bottom" label="Snooze for 15 minutes">
378
+ <button onClick={snoozeHint}>OK</button>
379
+ </Tip>
380
+ <button className="dim" onClick={dismissHintForever}>Don't show again</button>
381
+ </div>
382
+ )}
383
+
384
+ <div className={`sh-play-nav${navOn ? '' : ' idle'}`} {...chromeProps}>
385
+ <Tip inv label={<><b>Restart</b><span className="k">R</span></>}>
386
+ <button onClick={restart}><ReloadIcon size={14} /></button>
387
+ </Tip>
388
+ <i className="sep" />
389
+ <Tip inv label={<><b>Previous frame</b><span className="k">←</span></>}>
390
+ <button onClick={() => step(-1)}><ArrowLeftIcon size={14} /></button>
391
+ </Tip>
392
+ <span className="pos">{pos === -1 ? '·' : pos + 1}<em>/</em>{list.length}</span>
393
+ <Tip inv label={<><b>Next frame</b><span className="k">→</span></>}>
394
+ <button onClick={() => step(1)}><ArrowRightIcon size={14} /></button>
395
+ </Tip>
396
+ </div>
397
+ </div>
398
+ )
399
+ }
@@ -0,0 +1,43 @@
1
+ import { cloneElement, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ /** shadcn-style tooltip: snappy (150ms in, instant out), contrast-flipped, zoom-fade.
5
+ * Portaled to the app root - glass never nests, and neither do overlays. `inv` pins the
6
+ * flipped (light) bubble for surfaces with fixed dark chrome (play mode), where the
7
+ * theme-following default would sit dark-on-dark. */
8
+ export function Tip({ label, side = 'top', inv = false, children }: { label: ReactNode; side?: 'top' | 'bottom'; inv?: boolean; children: ReactElement }) {
9
+ const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
10
+ const timer = useRef<number | undefined>(undefined)
11
+ const show = (e: React.MouseEvent) => {
12
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
13
+ const y = side === 'top' ? r.top - 7 : r.bottom + 7
14
+ timer.current = window.setTimeout(() => setPos({ x: r.left + r.width / 2, y }), 150)
15
+ }
16
+ const hide = () => { window.clearTimeout(timer.current); setPos(null) }
17
+ const app = document.querySelector('.sh-app')
18
+ const child = children as ReactElement<any>
19
+ // clamp into the viewport: edge-of-screen triggers (play button) otherwise clip
20
+ const tipRef = useRef<HTMLDivElement>(null)
21
+ useLayoutEffect(() => {
22
+ const el = tipRef.current
23
+ if (!el) return
24
+ el.style.marginLeft = '0px'
25
+ const r = el.getBoundingClientRect()
26
+ const over = r.right - (window.innerWidth - 8)
27
+ if (over > 0) el.style.marginLeft = `${-over}px`
28
+ else if (r.left < 8) el.style.marginLeft = `${8 - r.left}px`
29
+ }, [pos])
30
+ return (
31
+ <>
32
+ {cloneElement(child, {
33
+ onMouseEnter: (e: React.MouseEvent) => { child.props.onMouseEnter?.(e); show(e) },
34
+ onMouseLeave: (e: React.MouseEvent) => { child.props.onMouseLeave?.(e); hide() },
35
+ onClick: (e: React.MouseEvent) => { child.props.onClick?.(e); hide() },
36
+ })}
37
+ {pos && app && createPortal(
38
+ <div ref={tipRef} className={`sh-tip${side === 'bottom' ? ' below' : ''}${inv ? ' inv' : ''}`} style={{ left: pos.x, top: pos.y }}>{label}</div>,
39
+ app,
40
+ )}
41
+ </>
42
+ )
43
+ }