@marver-design/marver 0.16.1 → 0.18.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 (43) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +3 -2
  3. package/dist/bake-jr38C_pX.mjs +747 -0
  4. package/dist/{build-B4yPgFNF.mjs → build-ER_7T6Cw.mjs} +179 -10
  5. package/dist/cli.mjs +21 -13
  6. package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
  7. package/dist/{daemon-Bbh_jmui.mjs → daemon-CKhg0zuT.mjs} +1 -1
  8. package/dist/{dev-DH2W7Ffw.mjs → dev-BvLbY98O.mjs} +208 -9
  9. package/dist/{init-B7YhcN2o.mjs → init-BWbHqVng.mjs} +2 -2
  10. package/dist/{manifest-CaslQIAO.mjs → manifest-DAnEL8_a.mjs} +1 -1
  11. package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
  12. package/dist/{plugin-BeBGu3gH.mjs → plugin-Cai5C1WV.mjs} +244 -47
  13. package/dist/{poster-CoyobbGW.mjs → poster-CIuz_PwH.mjs} +13 -5
  14. package/dist/publish-bakes-D0LhbQQ3.mjs +216 -0
  15. package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
  16. package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
  17. package/dist/shot-BFEuYbaz.mjs +86 -0
  18. package/dist/shot-iicees2e.mjs +933 -0
  19. package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
  20. package/docs/live-jam.md +20 -8
  21. package/docs/publish.md +27 -5
  22. package/package.json +1 -1
  23. package/src/client/frame-host/bridge.js +4 -9
  24. package/src/client/frame-host/main.tsx +20 -6
  25. package/src/client/shell/App.tsx +7 -0
  26. package/src/client/shell/Comments.tsx +2 -2
  27. package/src/client/shell/canvas/Canvas.tsx +2 -2
  28. package/src/client/shell/canvas/FrameNode.tsx +82 -89
  29. package/src/client/shell/canvas/admission.ts +70 -0
  30. package/src/client/shell/canvas/sleep.ts +194 -0
  31. package/src/client/shell/store.ts +14 -0
  32. package/src/client/shell/styles.css +9 -25
  33. package/src/shared/sleep-rule.ts +41 -0
  34. package/templates/AGENTS-embedded.md +4 -1
  35. package/templates/AGENTS-studio.md +4 -1
  36. package/templates/instructions/craft.md +9 -0
  37. package/templates/instructions/jam.md +15 -3
  38. package/templates/instructions/publish.md +62 -9
  39. package/templates/instructions/shape.md +2 -1
  40. package/dist/shot-By1AItpD.mjs +0 -30
  41. package/dist/shot-z-d-zMzf.mjs +0 -528
  42. package/src/client/frame-host/serialize.ts +0 -195
  43. package/src/client/shell/canvas/snapshots.ts +0 -233
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Sleep - a frame at rest, in place (spec 16).
3
+ *
4
+ * A resting frame is its own LIVE document. Nothing is copied, pictured or swapped, so the moment
5
+ * the human interacts, comments or lasers, they are already looking at the thing itself: no line
6
+ * break, no spacing, no pixel can move. Sleep changes PAINT only:
7
+ *
8
+ * - CSS animations pause (`animation-play-state: paused`);
9
+ * - every `backdrop-filter` element - the one effect that reads back what is behind it on every
10
+ * composited frame, and the reason a glass design checkerboards at scale - gets the compositor's
11
+ * own filtered backdrop as a static texture under its own background layers, computed and
12
+ * certified by the dev server in headless Chrome (src/server/bake.ts); the rule itself is
13
+ * src/shared/sleep-rule.ts, the same one the compiler certified.
14
+ *
15
+ * A frame with no such element - markdown, images, slides, lo-fi - never talks to the server:
16
+ * its sleep is the animation pause. Frames are asked in one batch per tick; the server answers from
17
+ * its disk cache (keyed by frame, theme, size and source generation) or compiles. Textures are
18
+ * decoded BEFORE the override is installed, so sleep is one paint. Without textures - no compiler
19
+ * (a published canvas), a compile that failed, a texture that does not decode - the frame sleeps
20
+ * with the pause alone and its glass stays live: never an effect layer without its texture.
21
+ *
22
+ * Safety: the override is all or nothing - every target's selector must resolve to an element whose
23
+ * border box is the one the server measured (half a pixel) and whose filter is still the one baked, or
24
+ * the frame stays live. Wake restores the live effects under `transition: none` (an authored
25
+ * transition on backdrop-filter, filter or background must not animate out of the sleep, nor into
26
+ * it), then removes the <style> and the attributes.
27
+ */
28
+ import { ROUTE } from '../../const.ts'
29
+ import { BAKES, PUBLISHED } from '../store.ts'
30
+ import { readOwn, sleepRule } from '../../../shared/sleep-rule.ts'
31
+
32
+ export interface SleepKey { frame: string; theme: string; w: number; h: number }
33
+ interface Target { sel: string; rect: { x: number; y: number; w: number; h: number }; filter: string; level: number; texture: string; verified: boolean }
34
+ type Answer = { ok: true; targets: Target[] } | { ok: false; error: string }
35
+
36
+ const STYLE_ID = 'mv-sleep'
37
+ /** How far (CSS px) any EDGE of an element's border box may sit from the one the compiler measured.
38
+ * Headless and headed Chrome shape text a few hundredths of a pixel apart (a 784 px pill measures
39
+ * 784.09 in a window, 784.125 in the compiler), which is invisible under a texture stretched to
40
+ * the box; a different wrap, size or place is a whole line or more and still refuses the frame. */
41
+ const TOL = 0.5
42
+ /** `?awake=1` keeps every frame live - the diagnostic switch the identity probes compare against. */
43
+ const AWAKE = new URLSearchParams(location.search).get('awake') === '1'
44
+ const PAUSE = `*,*::before,*::after{animation-play-state:paused!important}`
45
+ const csrf = () => document.cookie.match(/(?:^|; )mv_c=([^;]+)/)?.[1] ?? ''
46
+ const keyOf = (k: SleepKey) => `${k.frame}|${k.theme}|${Math.round(k.w)}|${Math.round(k.h)}`
47
+
48
+ /** What a node's DOCUMENT is asleep under, published only once its override is INSTALLED. */
49
+ const asleep = new Map<string, { key: string; doc: Document }>()
50
+ /** The node's current request, so a stale answer (a newer sleep, a wake in between) is dropped. */
51
+ const pending = new Map<string, number>()
52
+ let seq = 0
53
+ /** Nodes with one retry of a failed compile in flight. */
54
+ const retried = new Set<string>()
55
+ const RETRY_MS = 4000
56
+
57
+ /** Does this document have anything to compile? Cheap: one computed style per element. */
58
+ export function hasEffects(doc: Document): boolean {
59
+ for (const el of doc.querySelectorAll('*')) {
60
+ const cs = doc.defaultView!.getComputedStyle(el)
61
+ const bf = cs.backdropFilter || (cs as unknown as { webkitBackdropFilter?: string }).webkitBackdropFilter
62
+ if (bf && bf !== 'none') return true
63
+ }
64
+ return false
65
+ }
66
+
67
+ /** A published canvas has no compiler: its textures were compiled at build time against the very
68
+ * document it serves, and ship as one static index (publish-bakes.ts), read once. */
69
+ let published: Promise<Record<string, Answer>> | undefined
70
+ const staticAnswers = () => (published ??= (BAKES ? fetch(`${ROUTE}/bakes/${BAKES}/index.json`).then((r) => (r.ok ? r.json() : {})) : Promise.resolve({}))
71
+ .then((j: { gen?: number; answers?: Record<string, Answer> }) => (j.gen === BAKES && j.answers) || {}).catch((): Record<string, Answer> => ({})))
72
+
73
+ // ---- one batch per tick: every frame that decides to sleep in the same moment shares a browser
74
+ const queue: { key: SleepKey; resolve: (a: Answer) => void }[] = []
75
+ let flush: ReturnType<typeof setTimeout> | undefined
76
+ function ask(key: SleepKey): Promise<Answer> {
77
+ return new Promise((resolve) => {
78
+ queue.push({ key, resolve })
79
+ clearTimeout(flush)
80
+ flush = setTimeout(async () => {
81
+ const batch = queue.splice(0)
82
+ // one ask per key; every waiter for that key gets the same answer
83
+ const byKey = new Map<string, { key: SleepKey; waiters: ((a: Answer) => void)[] }>()
84
+ for (const q of batch) { const kk = keyOf(q.key); const e = byKey.get(kk); if (e) e.waiters.push(q.resolve); else byKey.set(kk, { key: q.key, waiters: [q.resolve] }) }
85
+ const asks = [...byKey.values()].map((e) => ({ frame: e.key.frame, theme: e.key.theme, w: Math.round(e.key.w), h: Math.round(e.key.h) }))
86
+ let data: { answers?: (Answer & SleepKey)[] } | null = null
87
+ const index = PUBLISHED ? await staticAnswers() : null
88
+ if (!index) {
89
+ try {
90
+ const r = await fetch(`${ROUTE}/api/bakes`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-mv-c': csrf() }, body: JSON.stringify({ asks }) })
91
+ data = r.ok ? await r.json() : null
92
+ } catch { data = null }
93
+ }
94
+ for (const e of byKey.values()) {
95
+ const a = index ? index[keyOf(e.key)] : data?.answers?.find((x) => keyOf(x) === keyOf(e.key))
96
+ const answer: Answer = a ? (a.ok ? { ok: true, targets: a.targets } : { ok: false, error: a.error }) : { ok: false, error: 'no answer' }
97
+ for (const w of e.waiters) w(answer)
98
+ }
99
+ }, 40)
100
+ })
101
+ }
102
+
103
+ /** Put a node's live document to sleep under `key`. Resolves once the override is installed (or
104
+ * the document was found to have no effects, or the compile failed and the frame stays live).
105
+ * A wake or a newer sleep in the meantime supersedes it. */
106
+ export async function sleep(nodeKey: string, iframe: HTMLIFrameElement, key: SleepKey): Promise<'asleep' | 'live'> {
107
+ if (AWAKE) return 'live'
108
+ const k = keyOf(key)
109
+ const doc = iframe.contentDocument
110
+ if (!doc?.body) return 'live'
111
+ const have = asleep.get(nodeKey)
112
+ if (have && have.key === k && have.doc === doc && doc.getElementById(STYLE_ID)) return 'asleep'
113
+ const mine = ++seq
114
+ pending.set(nodeKey, mine)
115
+ const current = () => pending.get(nodeKey) === mine && iframe.contentDocument === doc
116
+ // the compiler measured after the frame's fonts; so does the gate below
117
+ if (doc.fonts) { await doc.fonts.ready; if (!current()) return 'live' }
118
+ if (!hasEffects(doc)) {
119
+ // nothing to compile: the pause alone is this frame's sleep
120
+ if (!current()) return 'live'
121
+ install(doc, [])
122
+ asleep.set(nodeKey, { key: k, doc })
123
+ return 'asleep'
124
+ }
125
+ const answer = await ask(key)
126
+ if (!current()) return 'live'
127
+ // no compiler (a published canvas, a compile that failed): the pause alone, the glass live -
128
+ // never an effect layer without its texture
129
+ let targets = answer.ok ? answer.targets.filter((t) => t.verified && t.texture) : []
130
+ // decode every texture BEFORE the paint that installs them - one commit, no pop-in; one that
131
+ // fails to decode (pruned, missing, corrupt) leaves the glass live
132
+ const decoded = await Promise.all(targets.map((t) => { const im = new Image(); im.src = t.texture; return im.decode().then(() => true, () => false) }))
133
+ if (!current()) return 'live'
134
+ if (decoded.some((ok) => !ok)) targets = []
135
+ // a published document names the generation it was built with: textures dress that build only
136
+ if (PUBLISHED && targets.length && doc.querySelector('meta[name="mv-bakes"]')?.getAttribute('content') !== String(BAKES)) targets = []
137
+ if (!install(doc, targets)) return 'live'
138
+ // a certified sleep is remembered; the pause-only fallback is not, so the next lifecycle event
139
+ // asks again (a compile the source outran, a server hiccup), and one retry is scheduled now
140
+ if (targets.length) asleep.set(nodeKey, { key: k, doc })
141
+ else if (!PUBLISHED && !retried.has(nodeKey)) { retried.add(nodeKey); setTimeout(() => { retried.delete(nodeKey); if (current()) void sleep(nodeKey, iframe, key) }, RETRY_MS) } // a static miss is final
142
+ return 'asleep'
143
+ }
144
+
145
+ /** Wake: paint goes back to the live effects. The document is otherwise untouched. */
146
+ export function wake(nodeKey: string, iframe: HTMLIFrameElement | null): void {
147
+ pending.delete(nodeKey)
148
+ asleep.delete(nodeKey)
149
+ const doc = iframe?.contentDocument
150
+ const st = doc?.getElementById(STYLE_ID)
151
+ if (!doc || !st) return
152
+ const els = [...doc.querySelectorAll<HTMLElement>('[data-mv-sleep]')]
153
+ still(doc, els, () => st.remove())
154
+ for (const el of els) el.removeAttribute('data-mv-sleep')
155
+ }
156
+
157
+ /** Run `change` with no transition able to start on `els`: an INLINE important
158
+ * `transition-property: none` (it outranks an authored important transition), the change, one
159
+ * forced style recalc - a style change event with nothing to animate - then the authored longhand
160
+ * back. Only the longhand is touched: an inline `transition-duration` alone does not serialize
161
+ * through the shorthand, and a shorthand round trip would have deleted it. */
162
+ function still(doc: Document, els: HTMLElement[], change: () => void): void {
163
+ const authored = els.map((el) => [el.style.getPropertyValue('transition-property'), el.style.getPropertyPriority('transition-property')] as const)
164
+ for (const el of els) el.style.setProperty('transition-property', 'none', 'important')
165
+ change()
166
+ void doc.documentElement.offsetWidth
167
+ els.forEach((el, i) => { const [v, p] = authored[i]; if (v) el.style.setProperty('transition-property', v, p); else el.style.removeProperty('transition-property') })
168
+ }
169
+
170
+ /** Install the override for the targets. All or nothing: a target whose element is not exactly the
171
+ * one the server measured leaves the whole document live. Returns whether it was installed. */
172
+ function install(doc: Document, targets: Target[]): boolean {
173
+ doc.querySelectorAll('[data-mv-sleep]').forEach((el) => el.removeAttribute('data-mv-sleep'))
174
+ const rules: string[] = [PAUSE]
175
+ const els: HTMLElement[] = []
176
+ for (const [i, t] of targets.entries()) {
177
+ let el: HTMLElement | null = null
178
+ try { el = doc.querySelector<HTMLElement>(t.sel) } catch { /* a selector from another document shape */ }
179
+ if (!el) return false
180
+ const r = el.getBoundingClientRect()
181
+ if (Math.abs(r.x - t.rect.x) > TOL || Math.abs(r.y - t.rect.y) > TOL || Math.abs(r.right - t.rect.x - t.rect.w) > TOL || Math.abs(r.bottom - t.rect.y - t.rect.h) > TOL) return false
182
+ const cs = doc.defaultView!.getComputedStyle(el)
183
+ if ((cs.backdropFilter || (cs as unknown as { webkitBackdropFilter?: string }).webkitBackdropFilter || 'none') !== t.filter) return false
184
+ rules.push(sleepRule(`[data-mv-sleep="${i}"]`, readOwn(cs), t.texture))
185
+ els.push(el)
186
+ }
187
+ let st = doc.getElementById(STYLE_ID)
188
+ if (!st) { st = doc.createElement('style'); st.id = STYLE_ID; (doc.head ?? doc.documentElement).appendChild(st) }
189
+ const style = st
190
+ // one paint, and no authored transition (`transition: all` is common on a pill) may animate the
191
+ // effect out or the texture in
192
+ still(doc, els, () => { els.forEach((el, i) => el.setAttribute('data-mv-sleep', String(i))); style.textContent = rules.join('\n') })
193
+ return true
194
+ }
@@ -18,8 +18,13 @@ const DATA: {
18
18
  titles?: Record<string, string>
19
19
  /** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
20
20
  policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
21
+ /** the generation of the glass textures this build shipped (publish-bakes.ts); absent = none */
22
+ bakes?: number
21
23
  } | null = shData
22
24
 
25
+ /** The published textures' generation, or 0: the static index this build shipped is at /__mv/bakes/<gen>/index.json. */
26
+ export const BAKES = DATA?.bakes ?? 0
27
+
23
28
  /** Every published board is locked to a stage mode - the canvas shell is never
24
29
  * offered on this bundle (01-sharing §5.1's all-boards rule). */
25
30
  export const LOCKED_SHELL = DATA?.policy?.lockedShell === true
@@ -54,6 +59,11 @@ export interface Node {
54
59
  /** Renavigation nonce: bumped when the shell wants this iframe on a FRESH URL
55
60
  * (errored frame whose file is back in the manifest). Never persisted. */
56
61
  nav?: number
62
+ /** Source revision: bumped when the frame reports an HMR update (its modules changed without a
63
+ * navigation). Never persisted. */
64
+ rev?: number
65
+ /** The theme the frame reports as APPLIED (sh:theme-applied) - sleep waits for it to match. */
66
+ themeOn?: string
57
67
  /** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
58
68
  * The only theme value that persists into the board file. */
59
69
  themeUser?: string
@@ -302,6 +312,8 @@ interface State {
302
312
  resizeNode(key: string, w: number, h: number): void
303
313
  measureNode(key: string, frameId: string, ownWidth: number, measuredWidth: number, height: number): void
304
314
  setStatus(key: string, status: Node['status'], error?: string): void
315
+ bumpRev(key: string): void
316
+ setThemeOn(key: string, theme: string): void
305
317
  reloadFrame(key: string, automatic?: boolean): void
306
318
  removeNode(key: string): void
307
319
  select(key: string | null, additive?: boolean): void
@@ -985,6 +997,8 @@ export const useStore = create<State>((set, get) => {
985
997
  if (name) get().runTidy() // restore must NOT tidy - it would destroy positions
986
998
  else scheduleSave()
987
999
  },
1000
+ bumpRev(key) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, rev: (n.rev ?? 0) + 1 } : n)) })) },
1001
+ setThemeOn(key, theme) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, themeOn: theme } : n)) })) },
988
1002
  setStatus(key, status, error) {
989
1003
  // any real ready/error resets the one-shot retry allowance, so a later manual reload or a
990
1004
  // fresh manifest earns its own auto-retry again
@@ -150,21 +150,17 @@ button svg { pointer-events: none } /* event targets stay on the button - pan
150
150
  opacity: var(--grid-alpha, 1) }
151
151
  .sh-content { will-change: auto }
152
152
  #sh-world { position: relative; width: 1px; height: 1px }
153
- #sh-world.sh-gesturing .sh-live { pointer-events: none } /* law G-4 (live iframe only; lean is always pe:none) */
154
- .sh-gesturing .sh-content { will-change: transform } /* law G-3: gesture-scoped only */
153
+ #sh-world.sh-gesturing .sh-live { pointer-events: none } /* law G-4 */
154
+ /* no gesture promotion (a will-change layer on the world was measured harmful: 18-29 dropped frames
155
+ per pan on a 32-frame board against 0-3 without, spec 16) */
155
156
  /* preset transitions: armed by animateLayout() around device/tidy mutations - nodes ease
156
157
  to their new frame on the same 320ms ease-out the camera fit animates with */
157
158
  #sh-world.sh-preset .sh-node { transition: transform .32s cubic-bezier(.25,.46,.45,.94),
158
159
  width .32s cubic-bezier(.25,.46,.45,.94), height .32s cubic-bezier(.25,.46,.45,.94) }
159
- #sh-world.sh-preset .sh-node-body,
160
- #sh-world.sh-preset .sh-node .sh-live { transition: width .32s cubic-bezier(.25,.46,.45,.94),
160
+ #sh-world.sh-preset .sh-node-body { transition: width .32s cubic-bezier(.25,.46,.45,.94),
161
161
  height .32s cubic-bezier(.25,.46,.45,.94) }
162
- /* Device-sweep: a frame WITH a ready lean cover jumps its LIVE iframe straight to the final
163
- width (ONE reflow, hidden under the cover) while the lean cover - being width:100% of the animating
164
- body - reflows smoothly every frame (real CSS, the device-sweep fix). A frame WITHOUT a usable
165
- cover keeps the live width animation above. */
166
- body:not(.sh-laser):not(.sh-commenting) #sh-world.sh-preset .sh-node:has(.sh-lean[data-ready]):not(.interact) .sh-live { transition: none }
167
-
162
+ /* the live iframe takes its new size at once (ONE reflow per sweep) while the body's box eases to it;
163
+ the frame sleeps again under the new size once it settles */
168
164
  /* cursor conventions (Figma): arrow everywhere; grab only while space is held */
169
165
  body.sh-space .sh-canvas { cursor: grab }
170
166
  body.sh-space.sh-panning .sh-canvas { cursor: grabbing }
@@ -199,8 +195,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
199
195
  font-size: 11px; color: var(--head-dim);
200
196
  border-bottom: 1px solid var(--node-brd); user-select: none;
201
197
  border-radius: var(--r-node) var(--r-node) 0 0;
202
- background-color: var(--head-bg); background-image: var(--head-sheen);
203
- backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
198
+ background-color: var(--head-bg); background-image: var(--head-sheen) } /* no backdrop-filter: one render surface less per frame */
204
199
  .sh-node-head .id { font-weight: 600; color: var(--head-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
205
200
  /* frame icons: every sidebar row leads with one */
206
201
  .sh-node-head .iicon { flex: none; color: var(--head-dim) }
@@ -210,20 +205,9 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
210
205
  .sh-panel .sub.vrow { padding-left: 51px }
211
206
  .sh-node-head .dim { margin-left: auto; color: var(--head-dim); flex: none; font-variant-numeric: tabular-nums }
212
207
  .sh-node-body { position: relative; background: var(--node-bg); border-radius: 0 0 var(--r-node) var(--r-node); overflow: hidden }
208
+ /* no content-visibility on the body: it flips frames between skipped and rendered as they cross the
209
+ viewport during a zoom, and the raster storm that follows left the page unfinished 2.5 s later */
213
210
  .sh-node iframe { border: 0; display: block }
214
- /* LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
215
- passive frame - at rest AND during pan/zoom/resize. There is NO per-gesture swap between the lean
216
- and the live iframe (the swap shifted text ~1-2px = "jiggle", and flashed mermaid/theme colors,
217
- because two documents never render pixel-identically). The live app (.sh-live) sits underneath and
218
- shows ONLY when: the frame is interacted (.interact), laser/comment mode is on, or the lean is not
219
- yet built (no data-ready = live-fallback). Hard cut, never a crossfade - two ~1px-offset text docs
220
- would ghost into double text. */
221
- .sh-lean { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; z-index: 1;
222
- opacity: 0; pointer-events: none; transition: none; background: var(--node-bg) }
223
- .sh-lean[data-ready] { opacity: 1 }
224
- .sh-node.interact .sh-lean,
225
- body.sh-laser .sh-lean,
226
- body.sh-commenting .sh-lean { opacity: 0 }
227
211
  .sh-overlay { position: absolute; inset: 0; z-index: 2 } /* above the lean (z1) so drag-by-body works; inherits the app arrow cursor */
228
212
  .sh-node.interact { border-color: var(--interact);
229
213
  outline: calc(2px * var(--sh-inv, 1)) solid var(--interact);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The paint override a sleeping backdrop-filter element wears (spec 16) - ONE definition, used by
3
+ * the compiler inside the frame it certifies (src/server/bake.ts embeds these functions' source in
4
+ * a page script) and by the shell inside the frame it puts to sleep (src/client/shell/canvas/sleep.ts).
5
+ * Plain ES2020 with no imports or closures, so `Function.prototype.toString` carries it whole.
6
+ *
7
+ * The composition, bottom to top: the certified texture (the element's filtered backdrop, clipped to
8
+ * its border box the way the effect is), the element's own colour under the clip the author gave it,
9
+ * the element's own images, then its content.
10
+ *
11
+ * `backdrop-filter: none`: the element must stop being an effect layer. An effect layer is its own
12
+ * compositor layer with its own tiles, and thirty of them per frame are what a headed Chrome cannot
13
+ * re-raster fast enough under a pan or a zoom - the presented frames show the frame body missing
14
+ * while the pills draw (research/hifi/glitch.ts). `blur(0px)` measured better only in headless
15
+ * Chrome, whose forced screenshots never show a missing tile. A static `filter` keeps what the
16
+ * backdrop-filter gave layout - the containing block of fixed descendants (a hidden checkbox input
17
+ * is one) and the stacking context - without a direct compositing reason (a video, a canvas or a
18
+ * will-change inside still promotes that one element): `opacity(1)` when the author set none, the
19
+ * author's own filter otherwise.
20
+ */
21
+ export interface OwnBackground { img: string; color: string; size: string; pos: string; rep: string; org: string; clip: string; filter: string }
22
+
23
+ /** The element's own background and filter, read once BEFORE any override touches it. */
24
+ export function readOwn(cs: CSSStyleDeclaration): OwnBackground {
25
+ return { img: cs.backgroundImage, color: cs.backgroundColor, size: cs.backgroundSize, pos: cs.backgroundPosition, rep: cs.backgroundRepeat, org: cs.backgroundOrigin, clip: cs.backgroundClip, filter: cs.filter }
26
+ }
27
+
28
+ export function sleepRule(selector: string, o: OwnBackground, texture: string): string {
29
+ const img = o.img === 'none' ? '' : o.img + ','
30
+ // the authored colour paints under the LAST layer's clip (CSS Backgrounds 3)
31
+ const colorClip = o.clip.split(',').pop()!.trim() || 'border-box'
32
+ return selector + '{backdrop-filter:none!important;-webkit-backdrop-filter:none!important;' +
33
+ (o.filter === 'none' ? 'filter:opacity(1)!important;' : '') +
34
+ 'background-color:transparent!important;' +
35
+ 'background-image:' + img + 'linear-gradient(' + o.color + ',' + o.color + '),url("' + texture + '")!important;' +
36
+ 'background-size:' + (img ? o.size + ',' : '') + 'auto,100% 100%!important;' +
37
+ 'background-position:' + (img ? o.pos + ',' : '') + '0 0,0 0!important;' +
38
+ 'background-repeat:' + (img ? o.rep + ',' : '') + 'no-repeat,no-repeat!important;' +
39
+ 'background-origin:' + (img ? o.org + ',' : '') + 'border-box,border-box!important;' +
40
+ 'background-clip:' + (img ? o.clip + ',' : '') + colorClip + ',border-box!important}'
41
+ }
@@ -81,7 +81,10 @@ planning. The human should see the request land on the canvas within the first m
81
81
  lit frame, never before one.
82
82
  3. Build. Independent frames can go in parallel - one subagent per frame, each marking
83
83
  its own; frames that depend on one another go in order.
84
- 4. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
84
+ 4. **Look before you say done**: `npx marver shot --scene <scene>` (or `<scene/frame ...>`,
85
+ `--all`) renders the frames headless in one go - one PNG path per line - and you READ
86
+ the PNGs. No shell? instructions/jam.md has the file-drop way (`{"scene":"..."}`).
87
+ 5. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
85
88
  self-expire (default 10 min; `--ttl <min>` up to 30) - re-run `start` on long jobs,
86
89
  and never lean on expiry instead of `done`.
87
90
 
@@ -81,7 +81,10 @@ planning. The human should see the request land on the canvas within the first m
81
81
  lit frame, never before one.
82
82
  3. Build. Independent frames can go in parallel - one subagent per frame, each marking
83
83
  its own; frames that depend on one another go in order.
84
- 4. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
84
+ 4. **Look before you say done**: `npx marver shot --scene <scene>` (or `<scene/frame ...>`,
85
+ `--all`) renders the frames headless in one go - one PNG path per line - and you READ
86
+ the PNGs. No shell? instructions/jam.md has the file-drop way (`{"scene":"..."}`).
87
+ 5. **Clear as you finish**: `npx marver work done <scene/frame ...>` (or `--all`). Marks
85
88
  self-expire (default 10 min; `--ttl <min>` up to 30) - re-run `start` on long jobs,
86
89
  and never lean on expiry instead of `done`.
87
90
 
@@ -158,3 +158,12 @@ app, and the human attributes the fault to your frame, not to a library.
158
158
  WILL sweep devices with keys 1-5.
159
159
  - Live fully inside the settled visual world. A direction executed at full commitment
160
160
  can be judged and improved; a hedged one can only be redone.
161
+
162
+ ## Glass on the canvas
163
+
164
+ While a frame rests on the canvas, marver compiles every `backdrop-filter` element into a still
165
+ texture of its own filtered backdrop, certified pixel by pixel, so a board of hi-fi glass pans
166
+ like a board of statics; the frame wakes the moment it is interacted with. Three things cannot
167
+ be compiled and stay live: glass inside glass, a glass element with a `mix-blend-mode`, and a
168
+ frame whose paint at rest is not a function of its URL (random data at boot, a clock, a
169
+ count-up). Use them on the one frame that needs them, not as a house style.
@@ -98,6 +98,15 @@ file - the dev server renders it and writes the PNG back:
98
98
  3. **Read the PNG** at that `path` and check it with your own eyes: content present, both
99
99
  themes if you touched theming, nothing clipped. Fix and re-shoot; files overwrite in place.
100
100
 
101
+ **Several frames? Ask for them in ONE request** - a whole scene renders in one browser,
102
+ several frames at a time, for about the cost of one shot. Write any `<name>.request.json`
103
+ with `{"scene":"checkout"}`, or `{"frames":["checkout/cart","checkout/summary"]}`, or
104
+ `{"all":true}` (plus `"theme"`, `"scale"` as you like). The result is
105
+ `{"ok":true,"results":[{"frame":"checkout/cart","ok":true,"path":"..."},...]}`, one entry
106
+ per frame in the order asked; a frame that failed carries its own `"ok":false,"error"` and
107
+ the others still ship. A frame that ran out of time to settle (a slow image, a heavy chart)
108
+ comes back with `"unsettled":true` and a `note` - shoot that one alone before you judge it.
109
+
101
110
  The `result.json` is the universal signal - it works even when you cannot see images.
102
111
  `"ok":false` means the frame did not render: the `error` carries the reason (a runtime
103
112
  throw shows the frame's own exception, "the frame rendered an error - ..."; an unreachable
@@ -108,9 +117,12 @@ shot at its natural width and its FULL height, so a wide layout or a long spec r
108
117
  full, not cropped. A frame tall enough to hit the capture cap comes back with
109
118
  `"truncated":true` and a `note` - split it or shorten it and re-shoot.
110
119
 
111
- (If you DO have a shell - `npx marver shot <scene/frame> [--theme dark] [--scale 4]` is the
112
- same thing in one line, printing the PNG path. `--scale 4` is for a print-quality still - the
113
- human's "copy as image" on the canvas uses this same renderer, so you both see one picture.)
120
+ (If you DO have a shell - `npx marver shot <scene/frame ...>`, `npx marver shot --scene
121
+ <name>` or `--all` (`[--theme dark] [--scale 4] [--json]`) is the same thing in one line,
122
+ printing one PNG path per line; a failed frame goes to stderr and the exit code is 1. After
123
+ writing a scene, shoot the scene, not the frames. `--scale 4` is for a print-quality still -
124
+ the human's "copy as image" on the canvas uses this same renderer, so you both see one
125
+ picture.)
114
126
 
115
127
  Verification is best-effort, not a gate. If your model cannot read images, or the result
116
128
  reports no Chrome on the machine, still act on `ok`/`error` - and say plainly in your reply
@@ -77,9 +77,39 @@ The host does two things, and the deploy config names both. **`design/.dist` is
77
77
  gitignored - it is built ON THE HOST at deploy time, never committed.**
78
78
 
79
79
  - **build command**: `<install> && npx marver build` (respects `publish.json`,
80
- seeds comment logs into the bundle)
80
+ seeds comment logs into the bundle, compiles the glass textures - see below)
81
81
  - **start command**: `npx marver serve` (reads `PORT` + the env vars above)
82
82
  - a **persistent volume** mounted at some path, named by `MARVER_DATA_DIR`
83
+ - **Chrome or Chromium on the build machine**, for the glass textures. Without one
84
+ the build still succeeds and says `textures: none - no Chrome on this machine`;
85
+ the canvas ships and every feature works, but hi-fi frames with `backdrop-filter`
86
+ rest with their glass live and pan the way they did before 0.18.0.
87
+
88
+ ## Glass textures at build (0.18.0)
89
+
90
+ A hi-fi frame at rest sleeps under certified textures of its blurred backdrops
91
+ (the dev canvas compiles them on the fly). A published canvas has no compiler,
92
+ so `marver build` compiles them once, against the exact site it just built -
93
+ every published node, at its size on its board, in every theme - and ships them
94
+ under `design/.dist/__mv/bakes/`. The build log tells you what happened:
95
+
96
+ ```
97
+ textures: 8 frame views asleep under certified glass, 0 with live glass, 34 without effects (42 asked: every published node x 2 themes; 806 KB, 35 s)
98
+ ```
99
+
100
+ - `asleep under certified glass` is the number you want to see for hi-fi boards.
101
+ `with live glass` counts views the compiler refused (glass inside glass, blend
102
+ modes, paint that is not a function of the URL): they show exactly as before.
103
+ - Budget: about 1-3 s per hi-fi frame and theme, under a second per frame without
104
+ glass; a 4-frame hi-fi board plus a 128-frame lo-fi board took 35 s.
105
+ - **Bundle your fonts** (`@fontsource-*`, or files under `public/`). The textures
106
+ are certified as the build machine renders the frame; a font that only exists on
107
+ the designer's laptop renders differently in the build container and the
108
+ visitor's browser then refuses those textures (the frame rests live, nothing
109
+ breaks).
110
+ - Skip it on purpose with `npx marver build --no-textures`, or `MARVER_NO_TEXTURES=1`
111
+ in a CI that has no Chrome and should stay quiet about it.
112
+ - `MARVER_CHROME=/path/to/chrome` names a browser marver does not find on its own.
83
113
 
84
114
  The `@marver-design/marver` dependency must resolve from the registry (a local
85
115
  `link:`/`file:` dep cannot ride to a remote host) - a normal registry version (`npm i -D @marver-design/marver@latest`) in
@@ -87,15 +117,36 @@ The `@marver-design/marver` dependency must resolve from the registry (a local
87
117
 
88
118
  ## Railway quickstart
89
119
 
90
- Commit a `railway.json` so `railway up` knows how to build and serve:
120
+ Commit a `Dockerfile` at the upload root - Railway detects it, and it is the one
121
+ path that gives the build a browser for the glass textures:
122
+
123
+ ```dockerfile
124
+ FROM node:22-slim
125
+ # Chromium for `marver build` (the glass textures); fonts-liberation so system-ui text
126
+ # has a face in the container. marver finds /usr/bin/chromium on its own and runs it
127
+ # with the container flags a root build needs.
128
+ RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
129
+ && rm -rf /var/lib/apt/lists/*
130
+ WORKDIR /app
131
+ COPY package.json ./
132
+ RUN npm install
133
+ COPY . .
134
+ RUN npx marver build
135
+ ENV PORT=8080
136
+ CMD ["npx", "marver", "serve"]
137
+ ```
91
138
 
92
- ```json
93
- {
94
- "$schema": "https://railway.com/railway.schema.json",
95
- "build": { "builder": "NIXPACKS", "buildCommand": "pnpm install && npx marver build" },
96
- "deploy": { "startCommand": "npx marver serve" }
97
- }
98
139
  ```
140
+ # .dockerignore
141
+ node_modules
142
+ design/.dist
143
+ design/.local
144
+ ```
145
+
146
+ (A `railway.json` with the NIXPACKS builder - `"buildCommand": "pnpm install && npx marver build"`,
147
+ `"startCommand": "npx marver serve"` - works too, but a Nixpacks image has no browser: the build
148
+ prints `textures: none` and ships the hi-fi frames with live glass. Add chromium to it, or use the
149
+ Dockerfile.)
99
150
 
100
151
  Then, once per service:
101
152
 
@@ -117,7 +168,9 @@ railway logs # with a PASSWORD gate, the owner claim link p
117
168
 
118
169
  Republishing is just `railway up` again: the server unions the seeded logs on
119
170
  boot, so collected feedback is NEVER clobbered by a new build. Run ONE instance -
120
- the event log is single-writer by design.
171
+ the event log is single-writer by design. A republish mints new glass textures;
172
+ a tab that was already open keeps asking for the old ones and rests its glass
173
+ live until it reloads - by design, so an old shell never dresses new frames.
121
174
 
122
175
  ## What the deployed gate offers (so you know what you're wiring)
123
176
 
@@ -183,7 +183,8 @@ of described imagery every time (the full asset rules: instructions/craft.md,
183
183
  never set a frame height - the frame auto-heights to fit everything the canvas
184
184
  measures. Marver renders images crisp and zooms fast, so fine detail is one zoom away.
185
185
  - Judge on the RENDER, not the props: after composing, look at the actual frame
186
- (screenshot it if you can) and adjust the per-row count until it reads well. "The code
186
+ (`npx marver shot --scene <scene>` shoots the whole scene in one go; instructions/jam.md
187
+ has the shell-less way) and adjust the per-row count until it reads well. "The code
187
188
  says they're the same width" proves nothing.
188
189
 
189
190
  ## When Shape ends
@@ -1,30 +0,0 @@
1
- import { n as NAME } from "./cli.mjs";
2
- import { readDevInfo } from "./work-CLrmY-vQ.mjs";
3
- //#region src/cli/shot.ts
4
- /**
5
- * `marver shot <scene/frame>` - render one frame headless and print the PNG path.
6
- *
7
- * The shell-ful agents' door into the same verify loop jam teaches: build, shoot, LOOK.
8
- * Thin wrapper over the dev server's /api/shot (shot.ts has the capture story).
9
- */
10
- async function shotCommand(root, frame, opts) {
11
- if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>] [--scale 1-4]`);
12
- const info = readDevInfo(root);
13
- if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
14
- const qs = new URLSearchParams({
15
- frame,
16
- ...opts.theme ? { theme: opts.theme } : {},
17
- ...opts.scale != null ? { scale: String(opts.scale) } : {}
18
- });
19
- let res;
20
- try {
21
- res = await fetch(`http://localhost:${info.port}/__mv/api/shot?${qs}`, { headers: { "x-mv-work": info.token } });
22
- } catch {
23
- throw new Error(`could not reach \`${NAME} dev\` on port ${info.port} - is it still running?`);
24
- }
25
- const data = await res.json().catch(() => ({}));
26
- if (!res.ok || !data.path) throw new Error(data.error ?? `shot failed (${res.status})`);
27
- console.log(data.path);
28
- }
29
- //#endregion
30
- export { shotCommand };