@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.
- package/CHANGELOG.md +89 -0
- package/README.md +5 -3
- package/dist/{build-ByYafIhj.mjs → build-DfuTQZlY.mjs} +12 -4
- package/dist/cli.mjs +5 -5
- package/dist/{daemon-Bfucyf1o.mjs → daemon-DalgvoA9.mjs} +1 -1
- package/dist/{dev-yZMyQeUj.mjs → dev-BxCmeU_H.mjs} +14 -4
- package/dist/{init-Dvaso7YO.mjs → init-QKNi9gvF.mjs} +1 -1
- package/dist/{manifest-DvOmglFp.mjs → manifest-BzxSMoDB.mjs} +17 -6
- package/dist/{plugin-BsmG5i2X.mjs → plugin-DJyjmQeh.mjs} +42 -10
- package/dist/poster-CbpzSzJu.mjs +143 -0
- package/dist/{shot-kbR_xzJH.mjs → shot-BWhoz6cU.mjs} +197 -56
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/docs/live-jam.md +6 -2
- package/docs/slides.md +9 -3
- package/package.json +1 -1
- package/src/client/content/chart.tsx +62 -24
- package/src/client/content/index.tsx +5 -2
- package/src/client/content/video.tsx +132 -35
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +30 -2
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +51 -1
- package/src/client/shell/styles.css +6 -0
- package/templates/AGENTS-embedded.md +19 -4
- package/templates/AGENTS-studio.md +19 -4
- package/templates/instructions/boards.md +47 -5
- package/templates/instructions/craft.md +17 -0
- package/templates/instructions/iterate.md +109 -14
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/shape.md +15 -1
- package/templates/instructions/slides.md +5 -1
|
@@ -1,31 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Video
|
|
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
|
|
6
|
-
* pin the frame live).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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:
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
<svg width=
|
|
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
|
|
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
|
|
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' :
|
|
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
|
-
|
|
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
|
-
{
|
|
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={
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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') {
|
|
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) {
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
152
|
-
|
|
153
|
-
|
|
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).
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
39
|
-
explorations
|
|
40
|
-
|
|
41
|
-
the
|
|
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.
|