@marver-design/marver 0.14.0 → 0.15.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.
@@ -1,31 +1,85 @@
1
1
  /**
2
- * Video (v1.5) - poster-first, glass-controlled.
2
+ * Video - poster-first, glass-controlled, in EVERY kind of frame.
3
3
  *
4
4
  * At rest there is NO <video> element and no media fetch: the poster <img>
5
- * plus a play glyph IS the slide (lean-DOM safe - a <video> element would
6
- * pin the frame live). In slides mode (useSlidePlay) the real element
7
- * mounts with the glass control strip: play/pause, seekable progress, mute,
8
- * fullscreen, auto-hiding. Leaving the slide (play flips off / unmount)
9
- * pauses everything.
5
+ * plus a play glyph IS the frame (lean-DOM safe - a <video> element would
6
+ * pin the frame live). The real element mounts with the glass control strip
7
+ * (play/pause, seekable progress, mute, fullscreen, auto-hiding) in two ways:
8
+ * - anywhere a frame is live (interact mode, play/present, focus, a
9
+ * published prototype): the poster is the play button - one click mounts
10
+ * the player and starts it, inside that gesture, so sound is allowed;
11
+ * - in slides mode (useSlidePlay): the player mounts on its own, the deck's
12
+ * entrance contract.
13
+ * `autoplay` is the third shape - an ambient loop (muted, no strip) for a hero
14
+ * or a product mockup; it mounts the element as soon as the frame is live,
15
+ * which keeps that frame live on the canvas (the serializer marks <video>
16
+ * degraded) - an explicit, per-video choice.
17
+ * Leaving (play flips off / unmount) pauses everything.
10
18
  *
11
- * Sources: a design-asset path (poster REQUIRED - it is the canvas
12
- * rendering) or a public https direct-file URL (mp4/webm; HLS where the
13
- * browser supports it natively).
19
+ * Sources: a design-asset path or a public https direct-file URL (mp4/webm;
20
+ * HLS where the browser supports it natively). The poster is the frame at
21
+ * rest: authored, or - omitted on a local clip - the generated
22
+ * `<clip>.poster.png` beside it (server/poster.ts renders it; the primitive
23
+ * asks the dev server once when the file is missing). `ratio` is the CSS aspect-ratio of the box
24
+ * (default 16 / 9; "9 / 16" for a vertical clip inside a phone screen).
14
25
  */
15
- import { useEffect, useRef, useState } from 'react'
26
+ import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
27
+ import { ROUTE } from '../const.ts'
16
28
  import { assetUrl } from './md.ts'
17
29
  import { useSlidePlay } from './slide.tsx'
18
30
 
19
31
  const isRemote = (s: string) => /^https:\/\//.test(s)
20
32
  const resolve = (s: string): string | null => (isRemote(s) ? s : assetUrl(s))
33
+ /** The generated poster's conventional name (server/poster.ts owns the rule). */
34
+ const posterNameFor = (src: string) => `${src}.poster.png`
35
+
36
+ // One generation request per clip per document: the dev server renders the poster from
37
+ // the clip's own first moments (server/poster.ts) and the <img> reloads. A published canvas
38
+ // has no such API (build already generated the file); a failed request just keeps the card.
39
+ const requested = new Map<string, Promise<boolean>>()
40
+ const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
41
+ // in-flight probes + generations, for the shot renderer's settle (like __mvLodBusy): a
42
+ // screenshot taken while a poster is still being rendered would show the empty box
43
+ let busy = 0
44
+ if (typeof window !== 'undefined') (window as { __mvPosterBusy?: () => number }).__mvPosterBusy = () => busy
45
+ const requestPoster = (src: string): Promise<boolean> => {
46
+ let p = requested.get(src)
47
+ if (!p) {
48
+ p = (async () => {
49
+ // the owner gate wants the double-submit cookie echoed; a frame host opened cold has not
50
+ // made an API GET yet, so prime it with the cheapest one first
51
+ if (!csrf()) await fetch(`${ROUTE}/api/policy`).catch(() => null)
52
+ const r = await fetch(`${ROUTE}/api/poster?src=${encodeURIComponent(src)}`, { headers: { 'x-mv-c': csrf() } }).catch(() => null)
53
+ return !!r?.ok
54
+ })()
55
+ requested.set(src, p)
56
+ // a failure is not forever: the next mount (a fix, Chrome installed) may ask again
57
+ void p.then((ok) => { if (!ok) requested.delete(src) })
58
+ }
59
+ return p
60
+ }
61
+
62
+ /** Is the human in the canvas's interact mode on this frame? The bridge mirrors sh:interactive
63
+ * on the document. Absent everywhere else (play, focus, published) - only the true->false
64
+ * transition matters: it disarms a playing clip when the mode is left. */
65
+ const subscribeInteractive = (cb: () => void) => {
66
+ if (typeof document === 'undefined') return () => {}
67
+ const mo = new MutationObserver(cb)
68
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sh-interactive'] })
69
+ return () => mo.disconnect()
70
+ }
71
+ const readInteractive = () => typeof document !== 'undefined' && document.documentElement.hasAttribute('data-sh-interactive')
72
+ const useInteractive = (): boolean => useSyncExternalStore(subscribeInteractive, readInteractive, () => false)
21
73
 
22
74
  const VIDEO_CSS = `
23
- .mv-video { position: relative; width: 100%; border-radius: 14px; overflow: hidden; background: #000; aspect-ratio: 16 / 9 }
75
+ .mv-video { position: relative; width: 100%; border-radius: 14px; overflow: hidden; background: #000; aspect-ratio: var(--mv-video-ratio, 16 / 9) }
76
+ .mv-video.armed { cursor: pointer }
77
+ .mv-video.armed:hover .glyph span { background: rgba(16, 16, 20, .75) }
24
78
  .mv-video > img, .mv-video > video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block }
25
79
  .mv-video .glyph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none }
26
80
  .mv-video .glyph span { width: 72px; height: 72px; border-radius: 999px; display: flex; align-items: center; justify-content: center;
27
81
  background: rgba(16, 16, 20, .55); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, .28) }
28
- .mv-video .glyph svg { margin-left: 4px }
82
+ .mv-video .glyph svg { margin-left: 2px }
29
83
  .mv-video .strip { position: absolute; left: 10px; right: 10px; bottom: 10px; display: flex; align-items: center; gap: 10px;
30
84
  padding: 8px 12px; border-radius: 999px; background: rgba(16, 16, 20, .55); backdrop-filter: blur(12px);
31
85
  border: 1px solid rgba(255, 255, 255, .22); color: #fff; opacity: 1; transition: opacity .25s }
@@ -46,42 +100,88 @@ const ensureCss = () => {
46
100
 
47
101
  const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
48
102
 
49
- const PlayGlyph = ({ size = 26 }: { size?: number }) => (
50
- <svg width={size} height={size} viewBox="0 0 24 24" fill="#fff"><path d="M8 5.5 19 12 8 18.5 Z" /></svg>
51
- )
52
- const PauseGlyph = () => (
53
- <svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>
103
+ // Phosphor icons (play/pause in the fill weight, speaker and corners regular), path data
104
+ // inlined from @phosphor-icons/core - MIT (c) Phosphor Icons - the same way the shell's
105
+ // icons.tsx does it: no icon dependency reaches the host.
106
+ const P = ({ d, size }: { d: string; size: number }) => (
107
+ <svg width={size} height={size} viewBox="0 0 256 256" fill="#fff" aria-hidden><path d={d} /></svg>
54
108
  )
109
+ const PLAY = 'M240,128a15.74,15.74,0,0,1-7.6,13.51L88.32,229.65a16,16,0,0,1-16.2.3A15.86,15.86,0,0,1,64,216.13V39.87a15.86,15.86,0,0,1,8.12-13.82,16,16,0,0,1,16.2.3L232.4,114.49A15.74,15.74,0,0,1,240,128Z'
110
+ const PAUSE = 'M216,48V208a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V48a16,16,0,0,1,16-16h40A16,16,0,0,1,216,48ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Z'
111
+ const SPEAKER = 'M155.51,24.81a8,8,0,0,0-8.42.88L77.25,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V32A8,8,0,0,0,155.51,24.81ZM32,96H72v64H32ZM144,207.64,88,164.09V91.91l56-43.55Zm54-106.08a40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.58,24,24,0,0,0,0-31.72,8,8,0,0,1,12-10.58ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z'
112
+ const SPEAKER_SLASH = 'M53.92,34.62A8,8,0,1,0,42.08,45.38L73.55,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V175.09l42.08,46.29a8,8,0,1,0,11.84-10.76ZM32,96H72v64H32ZM144,207.64,88,164.09V95.89l56,61.6Zm42-63.77a24,24,0,0,0,0-31.72,8,8,0,1,1,12-10.57,40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.59Zm-80.16-76a8,8,0,0,1,1.4-11.23l39.85-31A8,8,0,0,1,160,32v74.83a8,8,0,0,1-16,0V48.36l-26.94,21A8,8,0,0,1,105.84,67.91ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z'
113
+ const CORNERS = 'M216,48V88a8,8,0,0,1-16,0V56H168a8,8,0,0,1,0-16h40A8,8,0,0,1,216,48ZM88,200H56V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H88a8,8,0,0,0,0-16Zm120-40a8,8,0,0,0-8,8v32H168a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,208,160ZM88,40H48a8,8,0,0,0-8,8V88a8,8,0,0,0,16,0V56H88a8,8,0,0,0,0-16Z'
114
+ const PlayGlyph = ({ size = 30 }: { size?: number }) => <P d={PLAY} size={size} />
115
+ const PauseGlyph = ({ size = 18 }: { size?: number }) => <P d={PAUSE} size={size} />
55
116
 
56
- export function Video({ src, poster }: { src: string; poster?: string }) {
117
+ export function Video({ src, poster, ratio, autoplay = false }: { src: string; poster?: string; ratio?: string; autoplay?: boolean }) {
57
118
  ensureCss()
58
119
  const play = useSlidePlay()
59
- const posterUrl = poster ? resolve(poster) : null
120
+ const interactive = useInteractive()
121
+ const [armed, setArmed] = useState(false) // the poster was clicked in a live frame
122
+ // leaving interact mode disarms: the clip pauses (Player unmounts) and the frame returns to
123
+ // its poster, so the canvas can serialize it lean again
124
+ useEffect(() => { if (!interactive) setArmed(false) }, [interactive])
125
+ // poster omitted on a local clip: the generated one, by convention. `gen` walks
126
+ // probing -> ready (the file exists, or was just rendered - reloaded with a cache-buster)
127
+ // or failed (the card). The <img> mounts only once ready: no broken-image glyph while the
128
+ // dev server renders the poster, and a shot taken meanwhile waits on __mvPosterBusy.
129
+ const generated = !poster && !isRemote(src) ? posterNameFor(src) : null
130
+ const [gen, setGen] = useState<'probing' | 'ready' | 'failed'>(generated ? 'probing' : 'ready')
131
+ const [bust, setBust] = useState(0)
132
+ const posterUrl = poster ? resolve(poster) : generated ? resolve(generated) : null
133
+ const posterSrc = posterUrl && bust ? `${posterUrl}?v=${bust}` : posterUrl
60
134
  const srcUrl = resolve(src)
61
- const bad = !srcUrl || (!isRemote(src) && !posterUrl)
135
+ const style = ratio ? ({ '--mv-video-ratio': ratio } as CSSProperties) : undefined
136
+ useEffect(() => {
137
+ if (!generated || !posterUrl) { setGen('ready'); return }
138
+ let alive = true, pending = true
139
+ setGen('probing'); setBust(0)
140
+ busy++
141
+ const done = () => { if (pending) { pending = false; busy-- } }
142
+ const probe = new Image()
143
+ const settle = (state: 'ready' | 'failed', rebust = false) => { done(); if (!alive) return; if (rebust) setBust(Date.now()); setGen(state) }
144
+ probe.onload = () => settle('ready')
145
+ probe.onerror = () => {
146
+ // not there yet: ask the dev server to render it (once per clip per document)
147
+ void requestPoster(src).then((ok) => settle(ok ? 'ready' : 'failed', ok))
148
+ }
149
+ probe.src = posterUrl
150
+ return () => { alive = false; probe.onload = probe.onerror = null; done() }
151
+ }, [generated, posterUrl, src])
152
+ const bad = !srcUrl || (!isRemote(src) && !posterUrl) || gen === 'failed'
62
153
  if (bad) {
63
154
  return (
64
155
  <div className="mv-block mv-imgerr">
65
156
  <b>video unavailable</b>
66
157
  <span>{src}</span>
67
- <span className="dim">{!srcUrl ? 'must be a design/assets/ path or an https file URL' : 'a local video needs a poster - it IS the slide at rest'}</span>
158
+ <span className="dim">{!srcUrl ? 'must be a design/assets/ path or an https file URL' : `no poster - add poster="…", or put ${generated} beside the clip (the dev server renders it when Chrome is installed)`}</span>
159
+ </div>
160
+ )
161
+ }
162
+ // Ambient: a muted loop, no chrome - the author accepted that this frame stays live.
163
+ if (autoplay) {
164
+ return (
165
+ <div className="mv-block mv-video" style={style}>
166
+ <video src={srcUrl!} poster={gen === 'ready' ? posterSrc ?? undefined : undefined} autoPlay muted loop playsInline preload="auto" />
68
167
  </div>
69
168
  )
70
169
  }
71
- // AT REST: the poster and nothing else - no <video>, no fetch, still.
72
- if (!play) {
170
+ // AT REST: the poster and nothing else - no <video>, no fetch, still. In a lean cover the
171
+ // click never fires (no script); in a live frame it arms the player inside the gesture.
172
+ if (!play && !armed) {
73
173
  return (
74
- <div className="mv-block mv-video">
75
- {posterUrl ? <img src={posterUrl} alt="" loading="lazy" /> : null}
174
+ <div className="mv-block mv-video armed" style={style} role="button" aria-label="Play video" onClick={() => setArmed(true)}>
175
+ {posterSrc && gen === 'ready' ? <img src={posterSrc} alt="" loading="lazy" /> : null}
76
176
  <div className="glyph"><span><PlayGlyph /></span></div>
77
177
  </div>
78
178
  )
79
179
  }
80
- return <Player src={srcUrl!} poster={posterUrl ?? undefined} />
180
+ return <Player src={srcUrl!} poster={gen === 'ready' ? posterSrc ?? undefined : undefined} style={style} autoStart={armed} />
81
181
  }
82
182
 
83
183
  /** The glass strip - deliberately four controls and a clock, nothing more. */
84
- function Player({ src, poster }: { src: string; poster?: string }) {
184
+ function Player({ src, poster, style, autoStart = false }: { src: string; poster?: string; style?: CSSProperties; autoStart?: boolean }) {
85
185
  const ref = useRef<HTMLVideoElement>(null)
86
186
  const [playing, setPlaying] = useState(false)
87
187
  const [muted, setMuted] = useState(false)
@@ -95,9 +195,11 @@ function Player({ src, poster }: { src: string; poster?: string }) {
95
195
  idleTimer.current = setTimeout(() => setIdle(true), 2200)
96
196
  }
97
197
  useEffect(() => () => { if (idleTimer.current) clearTimeout(idleTimer.current); ref.current?.pause() }, [])
198
+ // armed by a click on the poster: start now - still inside that gesture's activation
199
+ useEffect(() => { if (autoStart) { void ref.current?.play().catch(() => {}); poke() } }, [autoStart])
98
200
  const toggle = () => { const v = ref.current; if (!v) return; if (v.paused) { void v.play(); poke() } else v.pause() }
99
201
  return (
100
- <div className="mv-block mv-video" onPointerMove={poke} onClick={(e) => { if (e.target === ref.current) toggle() }}>
202
+ <div className="mv-block mv-video" style={style} onPointerMove={poke} onClick={(e) => { if (e.target === ref.current) toggle() }}>
101
203
  <video ref={ref} src={src} poster={poster} preload="metadata" muted={muted} playsInline
102
204
  onPlay={() => { setPlaying(true); poke() }} onPause={() => { setPlaying(false); setIdle(false) }}
103
205
  onTimeUpdate={(e) => setT(e.currentTarget.currentTime)}
@@ -110,15 +212,10 @@ function Player({ src, poster }: { src: string; poster?: string }) {
110
212
  onChange={(e) => { const v = ref.current; if (v) { v.currentTime = Number(e.target.value); setT(v.currentTime) } }} />
111
213
  <span className="t">{fmt(dur)}</span>
112
214
  <button aria-label={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted((m) => !m)}>
113
- <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
114
- <path d="M11 5 6 9 H3 v6 h3 l5 4 Z" fill="#fff" stroke="none" />
115
- {muted ? <path d="m16 9 6 6 M22 9 l-6 6" /> : <path d="M15.5 8.5 a5 5 0 0 1 0 7 M18.5 6 a9 9 0 0 1 0 12" />}
116
- </svg>
215
+ <P d={muted ? SPEAKER_SLASH : SPEAKER} size={18} />
117
216
  </button>
118
217
  <button aria-label="Fullscreen" onClick={(e) => void (e.currentTarget.closest('.mv-video') as HTMLElement | null)?.requestFullscreen?.()}>
119
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
120
- <path d="M8 3 H4 a1 1 0 0 0 -1 1 v4 M16 3 h4 a1 1 0 0 1 1 1 v4 M8 21 H4 a1 1 0 0 1 -1 -1 v-4 M16 21 h4 a1 1 0 0 0 1 -1 v-4" />
121
- </svg>
218
+ <P d={CORNERS} size={18} />
122
219
  </button>
123
220
  </div>
124
221
  </div>
@@ -69,7 +69,12 @@ window.addEventListener('message', (e) => {
69
69
  if (e.origin && e.origin !== location.origin) return // a navigated frame must not spoof commands
70
70
  if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
71
71
  // B0.2: interact/play target owns its own wheel; passive frames forward it to the canvas
72
- if (e?.data?.type === 'sh:interactive') { interactiveOn = !!e.data.on }
72
+ if (e?.data?.type === 'sh:interactive') {
73
+ interactiveOn = !!e.data.on
74
+ // mirrored on the document so content primitives can observe it (Video disarms its
75
+ // player when the human leaves interact mode - a playing clip must not outlive the mode)
76
+ document.documentElement.toggleAttribute('data-sh-interactive', interactiveOn)
77
+ }
73
78
  })
74
79
 
75
80
  if (isHtmlFrame) {
@@ -7,7 +7,7 @@ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
7
  import { frameByWindow } from './canvas/frame-registry.ts'
8
8
  import { enterFocus, enterPlay, enterSlides, playCtl, PlayOverlay } from './Play.tsx'
9
9
  import { bootHash, parseHash, writeHash } from './hash.ts'
10
- import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
10
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
11
  import { CommentsController, revealThread } from './Comments.tsx'
12
12
  import { poweredByUrl } from '../../shared/utm.ts'
13
13
  import { avatarFallback, useComments } from './comments-store.ts'
@@ -323,6 +323,17 @@ function SelectionBar() {
323
323
  const t = setTimeout(() => setCopied(false), 1400)
324
324
  return () => clearTimeout(t)
325
325
  }, [pathPulse])
326
+ // copy-as-image: same flash contract (imagePulse bumps only on a real clipboard write),
327
+ // plus a busy state for the 1-4s headless render - the icon dims and the button locks
328
+ const imagePulse = useStore((s) => s.imagePulse)
329
+ const imageBusy = useStore((s) => s.imageBusy)
330
+ const [copiedImage, setCopiedImage] = useState(false)
331
+ useEffect(() => {
332
+ if (!imagePulse) return
333
+ setCopiedImage(true)
334
+ const t = setTimeout(() => setCopiedImage(false), 1400)
335
+ return () => clearTimeout(t)
336
+ }, [imagePulse])
326
337
  if (!node || !frame || node.missing) return null
327
338
  // anchor: centered over the bounding box of ALL selected frames, above the topmost
328
339
  const selNodes = nodes.filter((n) => selection.includes(n.key))
@@ -401,6 +412,17 @@ function SelectionBar() {
401
412
  () => toast('copy blocked - click the canvas first'))
402
413
  }}>{copied ? <CheckIcon size={15} /> : <SignpostIcon size={15} />}</button>
403
414
  </Tip>
415
+ {/* copy as image: dev only (the renderer is the dev server's headless Chrome) and one
416
+ frame at a time (a clipboard holds one image). I = 2x, Shift+I = 4x. */}
417
+ {!PUBLISHED && (
418
+ <Tip label={multi ? 'Select one frame to copy as image' : <><b>Copy as image</b><span className="k">⇧i</span></>}>
419
+ <button className={`icon${imageBusy ? ' busy' : ''}`} disabled={multi || imageBusy} aria-label="Copy as image"
420
+ data-state={copiedImage ? 'copied' : imageBusy ? 'busy' : 'idle'}
421
+ onClick={(e) => useStore.getState().copyFrameImage(e.shiftKey ? 4 : 2)}>
422
+ {copiedImage ? <CheckIcon size={15} /> : <ImagesSquareIcon size={15} />}
423
+ </button>
424
+ </Tip>
425
+ )}
404
426
  </div>
405
427
  )
406
428
  }
@@ -835,7 +857,11 @@ export function App() {
835
857
  // keyboard: t tidy · d theme · ⌘\ panel · Escape exit · shift+0/1/2 zoom
836
858
  useEffect(() => {
837
859
  const onKey = (e: KeyboardEvent) => {
838
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
860
+ // anything that edits text owns its keys: inputs, textareas, selects, contenteditable (the
861
+ // comment composer), and an IME mid-composition
862
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return
863
+ if (e.target instanceof HTMLElement && e.target.isContentEditable) return
864
+ if (e.isComposing) return
839
865
  const s = useStore.getState()
840
866
  if (s.play) return // play mode owns the keyboard (Play.tsx)
841
867
  if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); togglePanel(); return }
@@ -878,6 +904,8 @@ export function App() {
878
904
  c.setShowAnchor(!c.showAnchor)
879
905
  toast(c.showAnchor ? 'laser comment off' : 'laser comment on')
880
906
  }
907
+ // I = copy the selected frame as a 2x PNG · Shift+I = 4x (dev only; one frame at a time)
908
+ if ((e.key === 'i' || e.key === 'I') && !PUBLISHED && !e.altKey && !e.repeat) { s.copyFrameImage(e.shiftKey ? 4 : 2); return }
881
909
  if (e.key === 'P' && e.shiftKey && s.selection.length) {
882
910
  const paths = s.selection
883
911
  .map((k) => { const n = s.nodes.find((x) => x.key === k); const f = n && s.frameFor(n); return f ? framePath(s.board, f) : undefined })
@@ -38,6 +38,7 @@ export const DeviceTabletIcon = icon(<path d="M192,24H64A24,24,0,0,0,40,48V208a2
38
38
  export const LaptopIcon = icon(<path d="M232,168h-8V72a24,24,0,0,0-24-24H56A24,24,0,0,0,32,72v96H24a8,8,0,0,0-8,8v16a24,24,0,0,0,24,24H216a24,24,0,0,0,24-24V176A8,8,0,0,0,232,168ZM48,72a8,8,0,0,1,8-8H200a8,8,0,0,1,8,8v96H48ZM224,192a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8v-8H224ZM152,88a8,8,0,0,1-8,8H112a8,8,0,0,1,0-16h32A8,8,0,0,1,152,88Z" />)
39
39
  export const MonitorIcon = icon(<path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Z" />)
40
40
  export const TvIcon = icon(<path d="M216,64H147.31l34.35-34.34a8,8,0,1,0-11.32-11.32L128,60.69,85.66,18.34A8,8,0,0,0,74.34,29.66L108.69,64H40A16,16,0,0,0,24,80V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,136H40V80H216V200Z" />)
41
+ export const ImagesSquareIcon = icon(<path d="M208,32H80A16,16,0,0,0,64,48V64H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V192h16a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM80,48H208v69.38l-16.7-16.7a16,16,0,0,0-22.62,0L93.37,176H80Zm96,160H48V80H64v96a16,16,0,0,0,16,16h96Zm32-32H116l64-64,28,28v36Zm-88-64A24,24,0,1,0,96,88,24,24,0,0,0,120,112Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,120,80Z" />)
41
42
  export const SignpostIcon = icon(<path d="M246,106.65,212.33,69.3A16,16,0,0,0,200.44,64H136V32a8,8,0,0,0-16,0V64H40A16,16,0,0,0,24,80v64a16,16,0,0,0,16,16h80v64a8,8,0,0,0,16,0V160h64.44a16,16,0,0,0,11.89-5.3L246,117.35A8,8,0,0,0,246,106.65ZM200.44,144H40V80H200.44l28.8,32Z" />)
42
43
  export const ParallelogramDuoIcon = icon(<><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1" /><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z" /></>)
43
44
  /** The marver mark, SOLID fill (for the Marver avatar - a filled white parallelogram on accent). */
@@ -251,6 +251,8 @@ interface State {
251
251
  playUpdateRevision: string | null // a revision arrived while play is open
252
252
  playNav: number // bumps to reload the play stage on demand
253
253
  pathPulse: number // bumps on each successful path copy - flashes the toolbar icon into a check
254
+ imagePulse: number // bumps on each successful image copy - same flash, the images-square icon
255
+ imageBusy: boolean // a copy-as-image render is in flight (one at a time)
254
256
 
255
257
  boot(): Promise<boolean>
256
258
  applyManifest(m: Manifest): void
@@ -280,6 +282,7 @@ interface State {
280
282
  renameBoard(from: string, to: string): Promise<{ ok: boolean; error?: string }>
281
283
  reorderBoards(order: string[]): Promise<boolean>
282
284
  pulsePath(): void
285
+ copyFrameImage(scale: 2 | 4): void
283
286
  setScale(s: number): void
284
287
  togglePanel(): void
285
288
  setTheme(theme: string): void
@@ -564,7 +567,7 @@ export const useStore = create<State>((set, get) => {
564
567
  manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
565
568
  board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
566
569
  panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
567
- pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0,
570
+ pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
568
571
 
569
572
  async boot() {
570
573
  const seq = ++loadSeq
@@ -1017,6 +1020,53 @@ export const useStore = create<State>((set, get) => {
1017
1020
  },
1018
1021
  setScale(scale) { set({ scale }) },
1019
1022
  pulsePath() { set((s) => ({ pathPulse: s.pathPulse + 1 })) },
1023
+ // Copy the ONE selected frame to the clipboard as a PNG - the dev server's headless
1024
+ // renderer (/api/shot, the same picture `marver shot` gives an agent) sized to what the
1025
+ // node shows, at 2x (or 4x). The ClipboardItem takes a PROMISE: clipboard.write runs
1026
+ // inside the click/keydown gesture and the browser waits for the bytes - a plain
1027
+ // await-then-write would have lost the transient activation during the 1-4s render.
1028
+ copyFrameImage(scale) {
1029
+ const s = get()
1030
+ if (PUBLISHED || s.imageBusy || s.selection.length !== 1) return
1031
+ const node = s.nodes.find((n) => n.key === s.selection[0])
1032
+ if (!node || node.missing) return
1033
+ const frame = s.frameFor(node)
1034
+ if (!frame) { s.toast('frame is still indexing - try again in a second'); return }
1035
+ const CI = (globalThis as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
1036
+ if (!CI || !navigator.clipboard?.write || (CI.supports && !CI.supports('image/png'))) { s.toast("this browser can't put images on the clipboard - use Chrome, Edge or Safari"); return }
1037
+ set({ imageBusy: true })
1038
+ const done = () => set({ imageBusy: false })
1039
+ const qs = new URLSearchParams({ frame: frame.id, theme: node.theme, scale: String(scale), w: String(Math.round(node.w)), h: String(Math.round(node.h)), format: 'png' })
1040
+ // the render is serialized server-side behind any CLI/jam shots; a minute is the ceiling
1041
+ // before the UI gives up on this copy (the server's own watchdog is 45s per shot)
1042
+ const ctl = new AbortController()
1043
+ const timer = setTimeout(() => ctl.abort(), 60_000)
1044
+ let meta: { scale?: number; note?: string } = {}
1045
+ const png = fetch(`${ROUTE}/api/shot?${qs}`, { headers: { 'x-mv-c': csrf() }, signal: ctl.signal }).then(async (r) => {
1046
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error ?? `shot failed (${r.status})`)
1047
+ try { meta = JSON.parse(atob((r.headers.get('x-mv-shot') ?? '').replace(/-/g, '+').replace(/_/g, '/'))) } catch { /* summary is advisory */ }
1048
+ return r.blob()
1049
+ }).finally(() => clearTimeout(timer))
1050
+ let renderErr = ''
1051
+ png.catch((e: Error) => { renderErr = e.name === 'AbortError' ? 'timed out - the renderer is busy' : e.message })
1052
+ const fail = (err: unknown) => {
1053
+ done()
1054
+ // a prompt clipboard refusal (no gesture, focus lost) must not wait a minute on the
1055
+ // render: abandon the fetch, toast now. A render failure surfaces its own cause.
1056
+ if (!renderErr) ctl.abort()
1057
+ s.toast(renderErr ? `render failed - ${renderErr}` : (err as Error)?.name === 'NotAllowedError' ? 'copy blocked - click the canvas first' : `copy failed - ${(err as Error)?.message ?? err}`)
1058
+ }
1059
+ try {
1060
+ navigator.clipboard.write([new CI({ 'image/png': png })]).then(
1061
+ () => {
1062
+ done()
1063
+ const used = meta.scale ?? scale
1064
+ s.toast(used < scale ? `image copied at ${used}x - frame too tall for ${scale}x` : scale === 4 ? 'image copied (4x)' : 'image copied')
1065
+ set((st) => ({ imagePulse: st.imagePulse + 1 }))
1066
+ },
1067
+ fail)
1068
+ } catch (err) { fail(err) } // a synchronous constructor/type error must not wedge busy
1069
+ },
1020
1070
  togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
1021
1071
  // global theme = the VIEW preference: persists across boards + reloads, clears
1022
1072
  // per-frame pins. Frames declaring meta.theme keep their mode (they only work there).
@@ -413,6 +413,12 @@ body.sh-commenting .sh-lean { opacity: 0 }
413
413
  justify-content: center; padding: 0 6px; margin: 0 2px 0 3px }
414
414
  .sh-ctx button.on { background: var(--glass-accent-bg); color: var(--glass-accent) }
415
415
  .sh-ctx button:hover:not(.on) { background: var(--glass-hover); color: var(--glass-ink) }
416
+ /* multi-select / no-op states read as quiet, not broken */
417
+ .sh-ctx button:disabled { cursor: default; opacity: .45 }
418
+ .sh-ctx button:disabled:hover { background: none; color: var(--glass-ink-2) }
419
+ /* copy-as-image while the headless render runs (1-4s): a slow breathe on the icon */
420
+ .sh-ctx button.busy { opacity: 1; color: var(--glass-accent); animation: sh-ctx-breathe 1.1s ease-in-out infinite }
421
+ @keyframes sh-ctx-breathe { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
416
422
  .sh-ctx .sep { width: 1px; height: 14px; background: var(--glass-brd); margin: 0 3px }
417
423
 
418
424
  /* error / missing cards (inside the frame body) */
@@ -19,7 +19,7 @@ file in design/instructions/ - they are short, strict, and part of this contract
19
19
  | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
20
20
  | Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
21
21
  | Build | hi-fi frames from real components | instructions/craft.md + components.md |
22
- | Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
22
+ | Iterate | changing a frame the human has seen, a round of feedback on a scene, or retiring explorations | instructions/iterate.md |
23
23
  | Review | before presenting anything | instructions/review.md |
24
24
  | Boards | creating a board, choosing what ships | instructions/boards.md |
25
25
  | Slides | a deck is asked for, or `slide: true` frames exist | instructions/slides.md + design/slides.md |
@@ -58,6 +58,13 @@ Two channels carry element-precise feedback - honor both:
58
58
  thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
59
59
  per instructions/iterate.md; the comment names the div, so read the anchor before the words.
60
60
 
61
+ Either way, **a pointer names where the human noticed it, not the only place it is.** Before
62
+ you answer, look sideways: every other LIVE frame on that board that shares the component,
63
+ the pattern, the copy or the state (never `archive/` or a `<scene>-v<N>` version - history is
64
+ not a sibling). Same defect there? Fix it in the same pass and say which frames you touched.
65
+ A judgment call? Do the pinned one, then ask in the thread or the reply whether to roll it
66
+ across the others - never silently fix one and leave its siblings wrong.
67
+
61
68
  ## Show the work (working state)
62
69
 
63
70
  The canvas can wear your effort live. When a request will create or change frames, making
@@ -148,9 +155,17 @@ A board is a saved canvas: `design/boards/<name>.json` - you create and manage t
148
155
  by writing files; `all-scenes` is auto-managed, never write it. Compose a board
149
156
  deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
150
157
  whitespace tokens, and the same grammar per scene for frames (columns align left
151
- edges; a variant-group name is one indivisible atom). BEFORE creating a board or
152
- publishing anything, read instructions/boards.md (the layout grammar, file format,
153
- publishing rules).
158
+ edges; a variant-group name is one indivisible atom). **The default composition is
159
+ ONE horizontal band**: scenes side by side, frames flowing left to right; a second
160
+ band only when you can say why the eye should move down, and then with generous
161
+ vertical space. Without a recipe the shell stacks every scene as its own row - so
162
+ every curated board carries one. BEFORE creating a board or publishing anything,
163
+ read instructions/boards.md (the layout grammar, file format, publishing rules).
164
+
165
+ A round of feedback on a scene the human has already reviewed starts with a
166
+ **version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE
167
+ the first edit - the human iterates fast knowing every version is one board away.
168
+ instructions/iterate.md has the mechanics.
154
169
 
155
170
  ## Upstream feedback (when marver itself misbehaves)
156
171
 
@@ -19,7 +19,7 @@ file in design/instructions/ - they are short, strict, and part of this contract
19
19
  | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
20
20
  | Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
21
21
  | Build | hi-fi frames from real components | instructions/craft.md + components.md |
22
- | Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
22
+ | Iterate | changing a frame the human has seen, a round of feedback on a scene, or retiring explorations | instructions/iterate.md |
23
23
  | Review | before presenting anything | instructions/review.md |
24
24
  | Boards | creating a board, choosing what ships | instructions/boards.md |
25
25
  | Slides | a deck is asked for, or `slide: true` frames exist | instructions/slides.md + design/slides.md |
@@ -58,6 +58,13 @@ Two channels carry element-precise feedback - honor both:
58
58
  thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
59
59
  per instructions/iterate.md; the comment names the div, so read the anchor before the words.
60
60
 
61
+ Either way, **a pointer names where the human noticed it, not the only place it is.** Before
62
+ you answer, look sideways: every other LIVE frame on that board that shares the component,
63
+ the pattern, the copy or the state (never `archive/` or a `<scene>-v<N>` version - history is
64
+ not a sibling). Same defect there? Fix it in the same pass and say which frames you touched.
65
+ A judgment call? Do the pinned one, then ask in the thread or the reply whether to roll it
66
+ across the others - never silently fix one and leave its siblings wrong.
67
+
61
68
  ## Show the work (working state)
62
69
 
63
70
  The canvas can wear your effort live. When a request will create or change frames, making
@@ -148,9 +155,17 @@ A board is a saved canvas: `design/boards/<name>.json` - you create and manage t
148
155
  by writing files; `all-scenes` is auto-managed, never write it. Compose a board
149
156
  deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
150
157
  whitespace tokens, and the same grammar per scene for frames (columns align left
151
- edges; a variant-group name is one indivisible atom). BEFORE creating a board or
152
- publishing anything, read instructions/boards.md (the layout grammar, file format,
153
- publishing rules).
158
+ edges; a variant-group name is one indivisible atom). **The default composition is
159
+ ONE horizontal band**: scenes side by side, frames flowing left to right; a second
160
+ band only when you can say why the eye should move down, and then with generous
161
+ vertical space. Without a recipe the shell stacks every scene as its own row - so
162
+ every curated board carries one. BEFORE creating a board or publishing anything,
163
+ read instructions/boards.md (the layout grammar, file format, publishing rules).
164
+
165
+ A round of feedback on a scene the human has already reviewed starts with a
166
+ **version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE
167
+ the first edit - the human iterates fast knowing every version is one board away.
168
+ instructions/iterate.md has the mechanics.
154
169
 
155
170
  ## Upstream feedback (when marver itself misbehaves)
156
171
 
@@ -29,16 +29,58 @@ viewport and lays it out:
29
29
  EVERY frame, so it is the heavy one) and always sinks to the BOTTOM of the switcher -
30
30
  never the landing board, and never write its file.
31
31
  - Do not edit board files while the canvas is open unless asked; the shell owns
32
- their layout fields.
32
+ their layout fields (`x`/`y`/`w`/`h`, node keys). What is always yours, canvas
33
+ open or not: creating a board, appending nodes, and writing the `layout` recipe
34
+ of a board you curate (the `archive` board above all).
33
35
  - Use boards for comparisons: version A vs B vs C of a flow, side by side. Variant
34
36
  groups (letter-prefixed siblings) stay contiguous through every relayout
35
37
  automatically.
36
38
  - Content frames (specs, diagrams, mood boards - instructions/shape.md) are ordinary
37
39
  atoms in every layout scope: a feature-story board mixes them freely with UI frames.
38
- - The `archive` board (instructions/iterate.md) is the one board of retired
39
- explorations: curated over design/scenes/archive/, tidied with a recipe,
40
- every frame relabeled with what it was and why it retired. Winners live on
41
- the feature boards; the archive answers "what did we try?".
40
+ - The `archive` board (instructions/iterate.md) is the one board of history:
41
+ retired explorations (design/scenes/archive/, every frame relabeled with what
42
+ it was and why it retired) and **scene versions** (`<scene>-v1`, `<scene>-v2`
43
+ … - the whole flow as it stood before each round of feedback), one band per
44
+ version, oldest at the top. Winners live on the feature boards; the archive
45
+ answers "what did we try?" and "what did it look like before?".
46
+
47
+ ## The default composition: one horizontal band
48
+
49
+ A board reads like a page: left to right first, down only for a reason. The
50
+ default for every curated board is **one `rows` lane holding the scenes side by
51
+ side, in reading order, each scene's frames flowing left to right** - the whole
52
+ story on one horizontal band the human pans along. Without a recipe the shell
53
+ stacks every scene as its own row (a vertical pile of unrelated bands), so a board
54
+ without a `layout` is a board you have not composed yet.
55
+
56
+ ```json
57
+ "layout": { "rows": [["onboarding", "checkout", "account"]] }
58
+ ```
59
+
60
+ A **second band** is a decision, not a reflex. Open one when you can say in a
61
+ sentence why the eye should move down - a different chapter of the story (the
62
+ specs that argue for the flow above), a different audience (admin vs customer),
63
+ an archive or a version history, a scene so wide that beside the others it would
64
+ not be read. Then make the break unmistakable: the gap between bands must read as
65
+ "below", never as "next". Units are adaptive (proportional to the touching
66
+ frames), so judge the RENDERED gap: between rows of phone or laptop frames that
67
+ is `{ "space": 4 }`; after a band of tall spec frames `{ "space": 2 }`-`3` already
68
+ reads as a chapter break. Inside a band, `{ "space": 2 }`-`{ "space": 3 }`
69
+ separates clusters (a variant run, a scene that ends one thought and starts
70
+ another); plain adjacency joins.
71
+
72
+ Two boards are multi-band BY DESIGN and set their own gaps: the feature-story
73
+ board (instructions/shape.md - thinking, structure, answer, three bands) and the
74
+ `archive` board (instructions/iterate.md - one band per version). Everything else
75
+ starts as one band.
76
+
77
+ ```json
78
+ "layout": { "rows": [["onboarding", "checkout", "account"], { "space": 4 }, ["checkout-specs"]] }
79
+ ```
80
+
81
+ `columns` are for the rarer case where things must share a left edge (versions of
82
+ one flow stacked as a timeline, a parked archive under a hero) - never as a way to
83
+ fit more on screen.
42
84
 
43
85
  ## Composing the canvas: `layout`
44
86
 
@@ -96,6 +96,23 @@ thing - changes everything. This section is binding, not aspiration:
96
96
  - **Imagery is real imagery.** When the design calls for photos or screenshots,
97
97
  fetch and commit them locally with names that say what they are - never
98
98
  hotlink (published canvases make zero external requests, and remote URLs rot).
99
+ - **Charts are real charts.** A dashboard, a report, an analytics screen gets
100
+ `Chart` from `@marver-design/marver/content` - Apache ECharts behind a house
101
+ theme that inherits the SCREEN's ink, typeface and accent (light and dark),
102
+ renders SVG, sits still at rest and follows the layout on resize. Importing it
103
+ does not make the screen a content frame: it keeps its device, its height and
104
+ its place in the flow. Write the ECharts `option` with fixture data; never
105
+ set colors, fonts or animation in it. Never a static chart image, never
106
+ hand-drawn bars from divs when the real thing is one import away.
107
+ - **Video is a real video.** A hero loop, an onboarding clip, a story in a
108
+ phone screen: `Video` from `@marver-design/marver/content` - poster-first
109
+ (still on the canvas, no media fetched at rest), click-to-play wherever the
110
+ frame is live, `ratio="9 / 16"` for vertical, `autoplay` for a muted ambient
111
+ loop (an explicit choice: that frame stays live on the canvas). The poster
112
+ is rendered from the clip when you omit it (`<clip>.poster.png` beside it in
113
+ `design/assets/`); author one when the opening frame is not the picture.
114
+ Never a gray "video" box, never
115
+ a static screenshot standing in for motion the design depends on.
99
116
  - **Licensing sanity, briefly:** brand marks from official sources shown to
100
117
  identify the brand are fine; photos come from sources that permit the use.
101
118
  Unsure about one? Use it, and flag it to the human in the same message.