@marver-design/marver 0.14.0 → 0.16.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 (42) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -4
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
  6. package/dist/cli.mjs +15 -6
  7. package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
  9. package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
  11. package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
  12. package/dist/poster-DOY7pax8.mjs +143 -0
  13. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  14. package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
  15. package/docs/live-jam.md +6 -2
  16. package/docs/publish.md +4 -1
  17. package/docs/slides.md +9 -3
  18. package/package.json +1 -1
  19. package/src/client/content/chart.tsx +62 -24
  20. package/src/client/content/index.tsx +5 -2
  21. package/src/client/content/video.tsx +132 -35
  22. package/src/client/frame-host/bridge.js +6 -1
  23. package/src/client/shell/App.tsx +33 -247
  24. package/src/client/shell/BoardList.tsx +408 -0
  25. package/src/client/shell/ContextMenu.tsx +59 -0
  26. package/src/client/shell/icons.tsx +7 -0
  27. package/src/client/shell/store.ts +156 -48
  28. package/src/client/shell/styles.css +42 -4
  29. package/src/shared/board-tree.ts +285 -0
  30. package/templates/AGENTS-embedded.md +35 -7
  31. package/templates/AGENTS-studio.md +35 -7
  32. package/templates/instructions/boards.md +120 -7
  33. package/templates/instructions/craft.md +21 -0
  34. package/templates/instructions/discover.md +7 -2
  35. package/templates/instructions/iterate.md +114 -18
  36. package/templates/instructions/jam.md +18 -2
  37. package/templates/instructions/review.md +4 -0
  38. package/templates/instructions/shape.md +16 -2
  39. package/templates/instructions/slides.md +5 -1
  40. package/templates/instructions/welcome.md +4 -1
  41. package/templates/instructions/wireframe.md +3 -0
  42. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -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) {
@@ -1,13 +1,14 @@
1
- import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
3
2
  import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, type FrameEntry } from './store.ts'
3
+ import { BoardList } from './BoardList.tsx'
4
+ import { ContextMenu, copyToClipboard, framePath, useContextMenu, type MenuItem } from './ContextMenu.tsx'
4
5
  import { Tip } from './Tip.tsx'
5
6
  import { PKG, ROUTE } from '../const.ts'
6
7
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
8
  import { frameByWindow } from './canvas/frame-registry.ts'
8
9
  import { enterFocus, enterPlay, enterSlides, playCtl, PlayOverlay } from './Play.tsx'
9
10
  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'
11
+ import { CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
12
  import { CommentsController, revealThread } from './Comments.tsx'
12
13
  import { poweredByUrl } from '../../shared/utm.ts'
13
14
  import { avatarFallback, useComments } from './comments-store.ts'
@@ -17,60 +18,6 @@ const commentsStore = () => useComments.getState()
17
18
 
18
19
  let booted = false // survives Fast Refresh; see the boot effect
19
20
 
20
- /** Copy text to the clipboard, toasting the outcome. Success is confirmed out loud; a
21
- * blocked clipboard (no user gesture / permission) says so instead of failing silently. */
22
- function copyToClipboard(text: string, okMsg: string) {
23
- const { toast } = useStore.getState()
24
- navigator.clipboard.writeText(text).then(() => toast(okMsg), () => toast('copy blocked - click the canvas first'))
25
- }
26
-
27
- /** The address a frame copies - the same string from the sidebar right-click, the floating
28
- * toolbar, and ⇧P: the board it sits on, the frame id, and its file. */
29
- // stripped build: the file column would only show the opaque token, so the
30
- // copy keeps what is still honest - the board and the frame id
31
- const framePath = (board: string, f: { id: string; file: string }) =>
32
- SOURCE_REVEALED ? `board: ${board} · frame: ${f.id} (${f.file})` : `board: ${board} · frame: ${f.id}`
33
-
34
- type MenuItem = { label: string; icon: ReactNode; onClick: () => void }
35
- type MenuState = { x: number; y: number; items: MenuItem[] }
36
-
37
- /** A right-click menu for the sidebar. One instance lives in App; `open(e, items)` positions
38
- * it at the cursor, CLAMPED into the viewport. It closes on outside pointerdown, on pick, and
39
- * on Escape - the Escape listener runs in CAPTURE phase so it does not also trip App's global
40
- * keydown (which would clear the selection/laser underneath the menu). */
41
- function useContextMenu() {
42
- const [menu, setMenu] = useState<MenuState | null>(null)
43
- const open = (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => {
44
- e.preventDefault()
45
- const MENU_W = 184
46
- const h = items.length * 32 + 12
47
- setMenu({ x: Math.min(e.clientX, window.innerWidth - MENU_W - 8), y: Math.min(e.clientY, window.innerHeight - h - 8), items })
48
- }
49
- return { menu, open, close: () => setMenu(null) }
50
- }
51
-
52
- function ContextMenu({ menu, close }: { menu: MenuState | null; close: () => void }) {
53
- const ref = useRef<HTMLDivElement>(null)
54
- useEffect(() => {
55
- if (!menu) return
56
- const onDown = (e: PointerEvent) => { if (!ref.current?.contains(e.target as globalThis.Node)) close() }
57
- const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close() } }
58
- window.addEventListener('pointerdown', onDown)
59
- window.addEventListener('keydown', onEsc, true) // capture: beat App's bubble-phase Escape
60
- return () => { window.removeEventListener('pointerdown', onDown); window.removeEventListener('keydown', onEsc, true) }
61
- }, [menu])
62
- const app = document.querySelector('.sh-app')
63
- if (!menu || !app) return null
64
- return createPortal(
65
- <div className="sh-menu sh-ctxmenu" ref={ref} style={{ left: menu.x, top: menu.y }}>
66
- {menu.items.map((it) => (
67
- <button key={it.label} onClick={() => { it.onClick(); close() }}>{it.icon}<span>{it.label}</span></button>
68
- ))}
69
- </div>,
70
- app,
71
- )
72
- }
73
-
74
21
  /** One collapsible scene group in the sidebar. `held` marks a scene that contains a
75
22
  * selected frame - a quiet secondary wash so ancestry survives collapsing the group. */
76
23
  function SceneGroup({ name, count, held, onPick, onContextMenu, children }: { name: string; count: number; held: boolean; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
@@ -104,194 +51,6 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
104
51
  }
105
52
  }
106
53
 
107
- /** Boards live flat in the sidebar - always visible, one click to switch. The list
108
- * refreshes on mount, window focus, and a slow poll so agent-created board files
109
- * appear without a reload. Active board = accent icon + wash, same language as scenes. */
110
- const BOARD_NAME_RE = /^[a-z0-9][a-z0-9-]*$/
111
-
112
- function BoardList({ onMenu }: { onMenu: (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => void }) {
113
- const board = useStore((s) => s.board)
114
- const [names, setNames] = useState<string[]>(['all-scenes'])
115
- const [editing, setEditing] = useState<string | null>(null)
116
- const [drag, setDrag] = useState<string | null>(null) // board being dragged
117
- // insertion index in the CURATED list (0..len): one value per gap, so the divider lands
118
- // once between two boards - not twice (A's bottom-half and B's top-half were separate spots)
119
- const [dropIndex, setDropIndex] = useState<number | null>(null)
120
- // genRef invalidates every async setNames: a mutation bumps it, so a poll/fetch that
121
- // STARTED earlier can never clobber a fresh optimistic reorder or a just-renamed list.
122
- const genRef = useRef(0)
123
- const busyRef = useRef(0) // reorders in flight; while >0 polls hold off
124
- const commitBusy = useRef(false) // guards Enter+blur firing two renames
125
- const chainRef = useRef<Promise<unknown>>(Promise.resolve()) // serializes reorder POSTs
126
- // reorder runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
127
- // the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
128
- // the grabbing cursor for the whole drag via a body class.
129
- const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; name: string; dragging: boolean; el: HTMLElement } | null>(null)
130
- const refresh = () => {
131
- if (busyRef.current > 0) return // a reorder is mid-flight; its optimistic order stands until it settles
132
- const gen = genRef.current
133
- fetchBoardNames().then((list) => { if (gen === genRef.current) setNames(list) }).catch(() => { /* keep last known */ })
134
- }
135
- useEffect(() => {
136
- refresh()
137
- const t = setInterval(refresh, 8000)
138
- window.addEventListener('focus', refresh)
139
- return () => { clearInterval(t); window.removeEventListener('focus', refresh) }
140
- // eslint-disable-next-line react-hooks/exhaustive-deps
141
- }, [])
142
- const pick = async (name: string) => {
143
- if (name === useStore.getState().board) return
144
- await useStore.getState().switchBoard(name)
145
- setTimeout(() => canvasCtl.fitAll(), 60)
146
- }
147
- const commit = async (old: string, raw: string) => {
148
- if (commitBusy.current) return // Enter already fired this; ignore the follow-up blur
149
- const next = raw.trim()
150
- if (!next || next === old) { setEditing(null); return }
151
- if (!BOARD_NAME_RE.test(next) || next.length > 64 || next === 'all-scenes' || names.includes(next)) {
152
- useStore.getState().toast('use a free name - lowercase letters, numbers and dashes'); return // stay editing
153
- }
154
- commitBusy.current = true
155
- try {
156
- const r = await useStore.getState().renameBoard(old, next)
157
- if (r.ok) { setEditing(null); genRef.current++; refresh() }
158
- else useStore.getState().toast(r.error ?? 'rename failed') // stay editing
159
- } finally { commitBusy.current = false }
160
- }
161
- // the INSERTION INDEX (in the curated list, 0..len) nearest the pointer - one value per
162
- // gap, so a single divider lands between two boards instead of one per row-half
163
- const indexAt = (x: number, y: number): number | null => {
164
- const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-board-row]') as HTMLElement | null
165
- const name = el?.dataset.board
166
- if (!name) return null
167
- const curated = names.filter((n) => n !== 'all-scenes')
168
- if (name === 'all-scenes') return curated.length // over the pinned last row = end slot
169
- const ci = curated.indexOf(name)
170
- if (ci < 0) return null
171
- const r = el!.getBoundingClientRect()
172
- return y > r.top + r.height / 2 ? ci + 1 : ci
173
- }
174
- const resetPointer = () => {
175
- const g = gestureRef.current
176
- gestureRef.current = null
177
- if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
178
- document.body.classList.remove('sh-board-dragging')
179
- setDrag(null)
180
- setDropIndex(null)
181
- }
182
- // move the dragged board to the insertion index and persist the new order optimistically
183
- const applyReorder = (dragName: string, dropIndex: number) => {
184
- if (dragName === 'all-scenes') return
185
- const curated = names.filter((n) => n !== 'all-scenes')
186
- const from = curated.indexOf(dragName)
187
- if (from < 0) return
188
- const to = dropIndex > from ? dropIndex - 1 : dropIndex // removing `from` shifts later indices left
189
- if (to === from) return // dropped in its own slot - no move
190
- curated.splice(from, 1)
191
- curated.splice(to, 0, dragName)
192
- const prev = names
193
- genRef.current++
194
- const gen = genRef.current
195
- busyRef.current++ // hold polls off until this settles
196
- setNames(names.includes('all-scenes') ? [...curated, 'all-scenes'] : curated)
197
- chainRef.current = chainRef.current.then(async () => {
198
- try {
199
- const ok = await useStore.getState().reorderBoards(curated)
200
- if (gen !== genRef.current) return // a newer drop superseded this one
201
- if (!ok) { setNames(prev); useStore.getState().toast('could not save order') }
202
- } catch { if (gen === genRef.current) { setNames(prev); useStore.getState().toast('could not save order') } }
203
- finally { busyRef.current-- }
204
- })
205
- }
206
- const onBoardPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, n: string) => {
207
- if (e.button !== 0) return // left button only; right-click opens the menu
208
- const el = e.currentTarget
209
- try { el.setPointerCapture(e.pointerId) } catch { /* capture can fail on rapid input */ }
210
- gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, name: n, dragging: false, el }
211
- }
212
- const onBoardPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
213
- const g = gestureRef.current
214
- if (!g || g.pointerId !== e.pointerId) return
215
- if (!g.dragging) {
216
- if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
217
- g.dragging = true
218
- setDrag(g.name)
219
- document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
220
- }
221
- const idx = indexAt(e.clientX, e.clientY)
222
- // hide the seam at the dragged board's OWN slot (index from or from+1 = no move)
223
- const from = names.filter((nm) => nm !== 'all-scenes').indexOf(g.name)
224
- setDropIndex(idx === null || idx === from || idx === from + 1 ? null : idx)
225
- }
226
- const onBoardPointerUp = (e: ReactPointerEvent<HTMLButtonElement>, n: string) => {
227
- const g = gestureRef.current
228
- if (!g || g.pointerId !== e.pointerId) return
229
- const dragged = g.dragging
230
- const idx = dragged ? indexAt(e.clientX, e.clientY) : null
231
- resetPointer()
232
- if (dragged) { if (idx !== null) applyReorder(n, idx) }
233
- else void pick(n) // a tap switches boards (the trailing mouse click is ignored)
234
- }
235
- // cancel a drag on Escape (capture phase, so the app's global Escape never sees it) or focus loss
236
- useEffect(() => {
237
- const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && gestureRef.current?.dragging) { e.preventDefault(); e.stopPropagation(); resetPointer() } }
238
- const onBlur = () => { if (gestureRef.current) resetPointer() }
239
- window.addEventListener('keydown', onEsc, true)
240
- window.addEventListener('blur', onBlur)
241
- return () => { window.removeEventListener('keydown', onEsc, true); window.removeEventListener('blur', onBlur); resetPointer() }
242
- // eslint-disable-next-line react-hooks/exhaustive-deps
243
- }, [])
244
- const menuFor = (n: string): MenuItem[] => [
245
- { label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') },
246
- ...(!PUBLISHED && n !== 'all-scenes' ? [{ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setEditing(n) }] : []),
247
- ]
248
- const curated = names.filter((nm) => nm !== 'all-scenes')
249
- return (
250
- <>
251
- {names.map((n) => {
252
- if (editing === n) return (
253
- <div key={n} className="it board editing">
254
- {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
255
- <input autoFocus defaultValue={n} spellCheck={false}
256
- onFocus={(e) => e.currentTarget.select()}
257
- onKeyDown={(e) => {
258
- if (e.key === 'Enter') { e.preventDefault(); void commit(n, e.currentTarget.value) }
259
- else if (e.key === 'Escape') { e.preventDefault(); setEditing(null) }
260
- }}
261
- onBlur={(e) => { if (editing === n) void commit(n, e.currentTarget.value) }} />
262
- </div>
263
- )
264
- const canDrag = !PUBLISHED && n !== 'all-scenes'
265
- // ONE seam per gap, from the insertion index: drop-before the row AT that index, or
266
- // drop-after the last curated row when the index is the end. Overlay (::after), so no
267
- // layout shift. Never on the row being dragged.
268
- const ci = curated.indexOf(n)
269
- const dropCls = drag == null || dropIndex == null || n === drag ? ''
270
- : ci === dropIndex ? ' drop-before'
271
- : dropIndex === curated.length && ci === curated.length - 1 ? ' drop-after'
272
- : ''
273
- return (
274
- <button key={n} data-board-row data-board={n} data-reorderable={canDrag || undefined}
275
- className={`it board${n === board ? ' cur' : ''}${drag === n ? ' dragging' : ''}${dropCls}`}
276
- // draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
277
- // click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
278
- // (detail === 0) and non-draggable rows (all-scenes) switch here as normal
279
- onClick={(e) => { if (!canDrag || e.detail === 0) void pick(n) }}
280
- onContextMenu={(e) => onMenu(e, menuFor(n))}
281
- onPointerDown={canDrag ? (e) => onBoardPointerDown(e, n) : undefined}
282
- onPointerMove={canDrag ? onBoardPointerMove : undefined}
283
- onPointerUp={canDrag ? (e) => onBoardPointerUp(e, n) : undefined}
284
- onPointerCancel={canDrag ? () => resetPointer() : undefined}
285
- onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
286
- {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
287
- <span>{boardLabel(n)}</span>
288
- </button>
289
- )
290
- })}
291
- </>
292
- )
293
- }
294
-
295
54
  /** Selection toolbar: screen-space overlay above the selected frame - constant size at any
296
55
  * zoom. Position derives from --sh-s/tx/ty (written per transform frame in Canvas), so
297
56
  * pan/zoom tracking is pure CSS with zero React re-renders. */
@@ -323,6 +82,17 @@ function SelectionBar() {
323
82
  const t = setTimeout(() => setCopied(false), 1400)
324
83
  return () => clearTimeout(t)
325
84
  }, [pathPulse])
85
+ // copy-as-image: same flash contract (imagePulse bumps only on a real clipboard write),
86
+ // plus a busy state for the 1-4s headless render - the icon dims and the button locks
87
+ const imagePulse = useStore((s) => s.imagePulse)
88
+ const imageBusy = useStore((s) => s.imageBusy)
89
+ const [copiedImage, setCopiedImage] = useState(false)
90
+ useEffect(() => {
91
+ if (!imagePulse) return
92
+ setCopiedImage(true)
93
+ const t = setTimeout(() => setCopiedImage(false), 1400)
94
+ return () => clearTimeout(t)
95
+ }, [imagePulse])
326
96
  if (!node || !frame || node.missing) return null
327
97
  // anchor: centered over the bounding box of ALL selected frames, above the topmost
328
98
  const selNodes = nodes.filter((n) => selection.includes(n.key))
@@ -401,6 +171,17 @@ function SelectionBar() {
401
171
  () => toast('copy blocked - click the canvas first'))
402
172
  }}>{copied ? <CheckIcon size={15} /> : <SignpostIcon size={15} />}</button>
403
173
  </Tip>
174
+ {/* copy as image: dev only (the renderer is the dev server's headless Chrome) and one
175
+ frame at a time (a clipboard holds one image). I = 2x, Shift+I = 4x. */}
176
+ {!PUBLISHED && (
177
+ <Tip label={multi ? 'Select one frame to copy as image' : <><b>Copy as image</b><span className="k">⇧i</span></>}>
178
+ <button className={`icon${imageBusy ? ' busy' : ''}`} disabled={multi || imageBusy} aria-label="Copy as image"
179
+ data-state={copiedImage ? 'copied' : imageBusy ? 'busy' : 'idle'}
180
+ onClick={(e) => useStore.getState().copyFrameImage(e.shiftKey ? 4 : 2)}>
181
+ {copiedImage ? <CheckIcon size={15} /> : <ImagesSquareIcon size={15} />}
182
+ </button>
183
+ </Tip>
184
+ )}
404
185
  </div>
405
186
  )
406
187
  }
@@ -835,7 +616,11 @@ export function App() {
835
616
  // keyboard: t tidy · d theme · ⌘\ panel · Escape exit · shift+0/1/2 zoom
836
617
  useEffect(() => {
837
618
  const onKey = (e: KeyboardEvent) => {
838
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
619
+ // anything that edits text owns its keys: inputs, textareas, selects, contenteditable (the
620
+ // comment composer), and an IME mid-composition
621
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return
622
+ if (e.target instanceof HTMLElement && e.target.isContentEditable) return
623
+ if (e.isComposing) return
839
624
  const s = useStore.getState()
840
625
  if (s.play) return // play mode owns the keyboard (Play.tsx)
841
626
  if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); togglePanel(); return }
@@ -878,6 +663,8 @@ export function App() {
878
663
  c.setShowAnchor(!c.showAnchor)
879
664
  toast(c.showAnchor ? 'laser comment off' : 'laser comment on')
880
665
  }
666
+ // I = copy the selected frame as a 2x PNG · Shift+I = 4x (dev only; one frame at a time)
667
+ if ((e.key === 'i' || e.key === 'I') && !PUBLISHED && !e.altKey && !e.repeat) { s.copyFrameImage(e.shiftKey ? 4 : 2); return }
881
668
  if (e.key === 'P' && e.shiftKey && s.selection.length) {
882
669
  const paths = s.selection
883
670
  .map((k) => { const n = s.nodes.find((x) => x.key === k); const f = n && s.frameFor(n); return f ? framePath(s.board, f) : undefined })
@@ -989,7 +776,6 @@ export function App() {
989
776
  <Tip side="bottom" label={<><b>Collapse panel</b><span>⌘\</span></>}><button className="sh-ibtn" onClick={togglePanel} tabIndex={panelOpen ? 0 : -1}><PanelFilledIcon size={17} /></button></Tip>
990
777
  </div>
991
778
  <div className="sh-panel-scroll">
992
- <div className="hd">Boards</div>
993
779
  <BoardList onMenu={cm.open} />
994
780
  <div className="hd" style={{ marginTop: 10 }}>Scenes</div>
995
781
  {scenes.map((sc) => (