@marver-design/marver 0.5.0 → 0.6.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 CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
4
4
 
5
+ ## 0.6.0 - 2026-08-17
6
+
7
+ Image-heavy boards, done right. A board full of high-resolution screenshots now zooms fast and stays
8
+ crisp, images render in FULL instead of cropped, content frames size themselves to their content, and
9
+ the switcher opens on a tight landing board instead of loading every frame at once.
10
+
11
+ ### Added
12
+
13
+ - **Client-side image level-of-detail (LOD).** A board of 150+ high-res screenshots used to jank hard
14
+ on zoom - the real cost was decoded memory, not file size (a 2708x1610 PNG is ~17MB decoded, so 174 of
15
+ them held ~3GB of bitmaps the browser resampled every frame). Each `Img` now decodes STRAIGHT to its
16
+ on-screen size via `createImageBitmap` and paints on a canvas; bitmaps freeze during a pan/zoom and
17
+ re-pick resolution only when the gesture settles (the tldraw pattern). Result on a 174-image board:
18
+ ~26MB decoded at overview vs ~3GB before (~100x less), lag-free zoom, crisp detail when you stop.
19
+ Falls back to a plain `img` where `createImageBitmap` is unavailable.
20
+ - **Board ranking and a fast landing board.** Boards carry an `"order"` field; the switcher ranks curated
21
+ boards by it and always sinks the auto `all-scenes` everything-board to the BOTTOM. A fresh open now
22
+ lands on the FIRST curated board - a tight, fast board and a good first impression - instead of
23
+ rendering every frame at once. Rank boards deliberately; the first is what people see first. `order`
24
+ survives the shell's autosaves.
25
+
26
+ ### Changed
27
+
28
+ - **Reference images show in FULL.** `Img` no longer cover-crops to a fixed height (that sliced the
29
+ sides off every screenshot). Each image fills its column at its natural aspect ratio - never cropped,
30
+ never letterboxed - so same-aspect screenshots line up on their own and the frame auto-heights to fit.
31
+ Size an image by how many share its `Row` (fewer = bigger), not by a fixed height; `h` is accepted for
32
+ back-compat but no longer constrains size. A clean inset hairline (grayscale, light + dark) sits on the
33
+ image's own edge, overriding a screenshot's ragged or baked-in border instead of framing it twice.
34
+ - **Content frames fit their content when you resize.** A manual WIDTH resize now keeps the HEIGHT auto:
35
+ the frame reflows and refits to show everything, instead of freezing at a stale height (only an explicit
36
+ device viewport locks it). The content-frame height cap was raised so a long reference doc renders in
37
+ full rather than clipping, and zoom now reaches 500% for inspecting screenshot detail.
38
+ - **Authoring doctrine updated to match.** The scaffolded instructions now teach sizing images by row
39
+ grouping instead of cropping, and ranking boards with `order` (first = landing, `all-scenes` is heavy
40
+ and auto-last).
41
+
42
+ ### Fixed
43
+
44
+ - **No jiggle on zoom.** An image's display aspect-ratio is pinned on first decode, so an LOD resolution
45
+ switch changes only the pixels, never the layout box - frames no longer drift as you zoom.
46
+ - **Fast zoom no longer stalls frames.** The LOD re-decode is debounced past the gesture and drops queued
47
+ work when a new gesture starts, so oscillating zoom-in/out never stacks decode waves and times frames
48
+ out to a ready-timeout.
49
+
5
50
  ## 0.5.0 - 2026-08-15
6
51
 
7
52
  The performance & fidelity release (SPEC-M5): the canvas stops jiggling. Moving around a board no
@@ -1,6 +1,6 @@
1
1
  import { i as ROUTE, n as NAME } from "./cli.mjs";
2
2
  import { o as loadConfig, r as scanFrames, s as detectHost } from "./manifest-C8FODq2S.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BtSGAm2h.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-wMY9lNf3.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -144,7 +144,11 @@ async function buildSite(root, boardsFlag, allBoardsFlag) {
144
144
  const manifest = scanFrames(root);
145
145
  const allBoards = readBoards(root);
146
146
  const rights = resolvePublish(root, allBoards, boardsFlag, allBoardsFlag);
147
- const publishedNames = Object.keys(rights);
147
+ const boardOrder = (n) => {
148
+ const o = allBoards[n]?.order;
149
+ return typeof o === "number" && Number.isFinite(o) ? o : Infinity;
150
+ };
151
+ const publishedNames = Object.keys(rights).sort((a, b) => a === "all-scenes" ? 1 : b === "all-scenes" ? -1 : boardOrder(a) - boardOrder(b) || a.localeCompare(b));
148
152
  const includeAll = publishedNames.includes("all-scenes");
149
153
  const boards = {};
150
154
  for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
@@ -164,7 +168,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag) {
164
168
  },
165
169
  boards,
166
170
  names: publishedNames,
167
- default: publishedNames[0],
171
+ default: publishedNames.find((n) => n !== "all-scenes") ?? publishedNames[0],
168
172
  rights
169
173
  };
170
174
  const registryFile = posix(join(clientDir, "frame-host", "registry.ts"));
package/dist/cli.mjs CHANGED
@@ -46,7 +46,7 @@ cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "st
46
46
  });
47
47
  });
48
48
  cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
49
- const { dev } = await import("./dev-DdeU-Jst.mjs");
49
+ const { dev } = await import("./dev-DaPQ9xA5.mjs");
50
50
  let port;
51
51
  if (opts.port !== void 0) {
52
52
  const n = Number(opts.port);
@@ -56,7 +56,7 @@ cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root",
56
56
  await dev(resolve(opts.root), port);
57
57
  });
58
58
  cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
59
- const { buildSite } = await import("./build-BrCl9hJS.mjs");
59
+ const { buildSite } = await import("./build-BZaPa2DS.mjs");
60
60
  try {
61
61
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
62
62
  await buildSite(resolve(opts.root), boards, opts.allBoards === true);
@@ -1,6 +1,6 @@
1
1
  import { n as NAME, r as PKG } from "./cli.mjs";
2
2
  import { o as loadConfig, s as detectHost } from "./manifest-C8FODq2S.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BtSGAm2h.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-wMY9lNf3.mjs";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
@@ -79,9 +79,15 @@ function apiMiddleware(root) {
79
79
  mkdirSync(boardsDir, { recursive: true });
80
80
  return json(res, 200, readdirSync(boardsDir).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp")).map((f) => {
81
81
  const content = readFileSync(join(boardsDir, f), "utf8");
82
+ let order;
83
+ try {
84
+ const o = JSON.parse(content)?.order;
85
+ if (typeof o === "number" && Number.isFinite(o)) order = o;
86
+ } catch {}
82
87
  return {
83
88
  name: f.replace(/\.json$/, ""),
84
- sha256: hash(content)
89
+ sha256: hash(content),
90
+ order
85
91
  };
86
92
  }));
87
93
  }
@@ -127,6 +133,11 @@ function apiMiddleware(root) {
127
133
  });
128
134
  }
129
135
  mkdirSync(boardsDir, { recursive: true });
136
+ const incoming = body.board;
137
+ if (incoming && typeof incoming === "object" && incoming.order === void 0 && current) try {
138
+ const o = JSON.parse(current).order;
139
+ if (typeof o === "number" && Number.isFinite(o)) incoming.order = o;
140
+ } catch {}
130
141
  const next2 = JSON.stringify(body.board, null, 2) + "\n";
131
142
  atomicWrite(p, next2);
132
143
  return json(res, 200, { sha256: hash(next2) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components. The tool ships no AI - your coding agent is the designer.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,107 @@
1
+ // Client-side image Level-Of-Detail, running INSIDE a content frame's iframe.
2
+ //
3
+ // THE PROBLEM: a 2708x1610 screenshot is ~1.5MB as a PNG but ~17MB once decoded to RGBA. A board with
4
+ // 150 of them holds ~2.6GB of decoded bitmaps, and the browser resamples every one each zoom frame =
5
+ // jank + memory pressure. File size is a red herring; DECODED size is the killer.
6
+ //
7
+ // THE FIX: decode each image STRAIGHT to its on-screen size with createImageBitmap(blob,{resizeWidth}) -
8
+ // the full-size decode is never retained - and paint it on a <canvas> via a zero-copy bitmaprenderer
9
+ // transfer. The bitmap is FROZEN while the canvas is being pan/zoomed (the shell posts sh:camera
10
+ // {moving:true}) and only re-picked when the gesture SETTLES (sh:camera {moving:false, scale}), so zoom
11
+ // never triggers a decode/resample storm. Crisp at rest, cheap in motion. This mirrors how tldraw does
12
+ // LOD (resolution by on-screen size, debounced so it never thrashes mid-zoom).
13
+
14
+ /** Feature probe: createImageBitmap with resize + a canvas bitmaprenderer that can receive it. */
15
+ export const lodSupported = (() => {
16
+ try {
17
+ if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return false
18
+ const ctx = document.createElement('canvas').getContext('bitmaprenderer')
19
+ return !!ctx && typeof ctx.transferFromImageBitmap === 'function'
20
+ } catch { return false }
21
+ })()
22
+
23
+ // Quantized resize widths (device px). A `want` above the top bucket decodes at NATIVE (bucket 0 = no
24
+ // resize), reached only when a frame is zoomed in past ~2048 on-screen device px.
25
+ const BUCKETS = [256, 640, 1280, 2048]
26
+ const DPR = (): number => Math.min(window.devicePixelRatio || 1, 2) // cap at 2; 3x buys nothing here
27
+
28
+ // Bounded decode pool: never run more than N createImageBitmap jobs at once (peak decode memory + CPU).
29
+ const MAX = 3
30
+ let active = 0
31
+ const q: Array<() => void> = []
32
+ const pump = (): void => { while (active < MAX && q.length) q.shift()!() }
33
+ const schedule = (job: () => Promise<void>): void => {
34
+ q.push(() => { active++; void job().finally(() => { active--; pump() }) })
35
+ pump()
36
+ }
37
+
38
+ interface Item { canvas: HTMLCanvasElement; src: string; bucket: number; token: number }
39
+ const items = new Set<Item>()
40
+ let scale = 0.2 // overview default until the shell primes the settled scale on frame-ready
41
+ let moving = false
42
+
43
+ function pick(devicePx: number): number {
44
+ for (const b of BUCKETS) if (b >= devicePx) return b
45
+ return 0 // native decode - only past the top bucket
46
+ }
47
+
48
+ async function decode(it: Item, bucket: number): Promise<void> {
49
+ const token = ++it.token
50
+ let bmp: ImageBitmap
51
+ try {
52
+ const blob = await (await fetch(it.src)).blob()
53
+ bmp = bucket
54
+ ? await createImageBitmap(blob, { resizeWidth: bucket, resizeQuality: 'high' })
55
+ : await createImageBitmap(blob)
56
+ } catch { return } // network / decode failure: keep the last frame
57
+ if (it.token !== token || !it.canvas.isConnected) { bmp.close(); return } // superseded or unmounted
58
+ const ctx = it.canvas.getContext('bitmaprenderer')
59
+ if (!ctx) { bmp.close(); return }
60
+ // PIN the display aspect-ratio on the first decode and never change it. Different buckets round to
61
+ // slightly different integer dims (256x153 = 1.673 vs 1280x761 = 1.682), so if each drove height:auto
62
+ // the layout box would shift a hair on every resolution switch - the doc reflows, the content frame
63
+ // auto-resizes, and the frame "jiggles" as you zoom (and the reflow storm helped starve frames to a
64
+ // ready-timeout). A fixed aspect-ratio makes the box identical across buckets: only pixels sharpen.
65
+ if (!it.canvas.style.aspectRatio) it.canvas.style.aspectRatio = `${bmp.width} / ${bmp.height}`
66
+ it.canvas.width = bmp.width; it.canvas.height = bmp.height
67
+ ctx.transferFromImageBitmap(bmp) // zero-copy; consumes + closes the bitmap
68
+ it.bucket = bucket
69
+ }
70
+
71
+ function refresh(it: Item): void {
72
+ const layoutW = it.canvas.getBoundingClientRect().width
73
+ if (!layoutW) return // not laid out yet (or display:none)
74
+ const want = pick(Math.ceil(layoutW * scale * DPR()))
75
+ if (want === it.bucket) return
76
+ // hysteresis: only DOWNgrade once we're comfortably below the current level, so a jittery zoom that
77
+ // hovers a threshold doesn't swap back and forth (upgrades are always taken - sharper is worth it).
78
+ if (it.bucket > 0 && want > 0 && want < it.bucket && Math.ceil(layoutW * scale * DPR()) > it.bucket * 0.6) return
79
+ schedule(() => decode(it, want))
80
+ }
81
+
82
+ /** A content <canvas> registers here; returns an unregister fn. Paints a cheap first frame immediately,
83
+ * then sharpens to the real zoom on the next prime/settle. */
84
+ export function registerLodImage(canvas: HTMLCanvasElement, src: string): () => void {
85
+ const it: Item = { canvas, src, bucket: -1, token: 0 }
86
+ items.add(it)
87
+ schedule(() => decode(it, BUCKETS[0])) // low-res first paint (fast, ~1MB); prime sharpens
88
+ return () => { items.delete(it); it.token++ } // token bump drops any in-flight decode
89
+ }
90
+
91
+ // The shell posts camera transitions ONCE per gesture. Freeze during motion; re-pick every image's
92
+ // resolution to the SETTLED zoom - but debounced, and cancelling any queued work from a prior settle, so
93
+ // a fast zoom in-out-in doesn't stack decode waves across all frames (which starved the main thread and
94
+ // timed frames out). The re-decode only fires once the camera has truly rested.
95
+ let settleTimer = 0
96
+ if (typeof window !== 'undefined' && lodSupported) {
97
+ window.addEventListener('message', (e) => {
98
+ if (e.origin !== location.origin) return
99
+ const m = (e as MessageEvent).data as { type?: string; moving?: boolean; scale?: number } | null
100
+ if (!m || m.type !== 'sh:camera') return
101
+ if (m.moving) { moving = true; clearTimeout(settleTimer); q.length = 0; return } // new gesture: drop pending decodes
102
+ moving = false
103
+ if (typeof m.scale === 'number' && m.scale > 0) scale = m.scale
104
+ clearTimeout(settleTimer)
105
+ settleTimer = window.setTimeout(() => { if (!moving) for (const it of items) refresh(it) }, 220)
106
+ })
107
+ }
@@ -10,6 +10,7 @@
10
10
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
11
11
  import { CONTENT_WIDTH } from '../const.ts'
12
12
  import { assetUrl, renderMarkdown, FAMILIES } from './md.ts'
13
+ import { lodSupported, registerLodImage } from './img-lod.ts'
13
14
 
14
15
  // D3: family color classes for inline Md (`:blue[...]`), theme-aware (frames carry .dark + [data-theme])
15
16
  const FAMILY_CSS = Object.entries(FAMILIES).map(([f, c]) =>
@@ -79,6 +80,14 @@ export function Md({ children }: { children?: ReactNode }) {
79
80
  export function Img({ src, caption, alt, h }: { src: string; caption?: string; alt?: string; h?: number }) {
80
81
  const url = assetUrl(src)
81
82
  const [err, setErr] = useState(false)
83
+ const canvasRef = useRef<HTMLCanvasElement>(null)
84
+ // LOD: paint the image on a <canvas> decoded to its on-screen size (never the full 17MB bitmap), and
85
+ // re-pick resolution only when the canvas settles after a zoom. See img-lod.ts. Falls back to a plain
86
+ // <img> where createImageBitmap/bitmaprenderer isn't available (correctness over the optimization).
87
+ useEffect(() => {
88
+ if (!url || err || !lodSupported || !canvasRef.current) return
89
+ return registerLodImage(canvasRef.current, url)
90
+ }, [url, err])
82
91
  if (!url || err) {
83
92
  return (
84
93
  <div className="mv-block mv-imgerr">
@@ -88,13 +97,18 @@ export function Img({ src, caption, alt, h }: { src: string; caption?: string; a
88
97
  </div>
89
98
  )
90
99
  }
100
+ // A reference image ALWAYS shows in full: it fills its column at its natural aspect ratio, never
101
+ // cropped and never letterboxed. Equal-aspect images (e.g. a row of screenshots) line up on their own,
102
+ // and the frame auto-heights to fit. `h` is accepted for back-compat but no longer constrains size -
103
+ // capping height below natural would force the image narrower than its column (whitespace) or slice it
104
+ // (the old object-fit:cover). Width:100% + height:auto come from the .mv-img-el rule.
105
+ void h
106
+ const style = undefined
91
107
  return (
92
108
  <figure className="mv-block mv-img">
93
- {/* h: shared rendered height for a row of mixed-aspect images - equal widths
94
- alone never make unequal images READ equal; cover-crop to one height does */}
95
- <img src={url} alt={alt ?? caption ?? ''} loading="lazy"
96
- style={h ? { height: h, width: '100%', objectFit: 'cover' } : undefined}
97
- onError={() => setErr(true)} />
109
+ {lodSupported
110
+ ? <canvas ref={canvasRef} className="mv-img-el" role="img" aria-label={alt ?? caption ?? ''} style={style} />
111
+ : <img className="mv-img-el" src={url} alt={alt ?? caption ?? ''} loading="lazy" style={style} onError={() => setErr(true)} />}
98
112
  {caption && <figcaption>{caption}</figcaption>}
99
113
  </figure>
100
114
  )
@@ -144,14 +158,24 @@ body { margin: 0; }
144
158
  a whisper, not a card - the content pops, the block only frames it */
145
159
  .mv-block { margin: 0; padding: ${UNIT}px; border: 1px solid var(--mv-block-line);
146
160
  border-radius: 10px; background: var(--mv-block-bg); }
147
- .mv-block figcaption, .mv-img figcaption { font-size: 12.5px; color: var(--mv-faint); padding-top: 8px; }
161
+ /* an image block is NOT a card: the screenshot IS the content. Drop the surface + border so we don't
162
+ frame a frame; give the image itself a hairline edge and a whisper of shadow so it reads as a clean,
163
+ distinct object on the page (no heavy double-border around browser mockups). */
164
+ .mv-block.mv-img { padding: 0; border: none; background: none; }
165
+ /* INSET hairline: the line sits ON the image's own rounded edge (outline-offset:-1px), overriding a
166
+ screenshot's ragged or baked-in dark border instead of drawing a second frame outside it. It shares
167
+ the image's exact border-radius (same element), so the two never mismatch. Grayscale token = clean in
168
+ both light and dark. A whisper of drop shadow lifts it off the page. */
169
+ .mv-img .mv-img-el { outline: 1px solid var(--mv-line); outline-offset: -1px;
170
+ box-shadow: 0 1px 4px rgba(20, 22, 28, 0.07); }
171
+ .mv-block figcaption, .mv-img figcaption { font-size: 12.5px; color: var(--mv-faint); padding-top: 10px; }
148
172
  .mv-diagram-svg { display: flex; justify-content: center; }
149
173
  .mv-diagram-svg svg { max-width: 100%; height: auto; }
150
174
  .mv-diagram-err { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted);
151
175
  font-family: ui-monospace, "SF Mono", Menlo, monospace; }
152
176
  .mv-diagram-err b { color: #E0402F; font-family: inherit; }
153
177
  .mv-diagram-err .dim, .mv-imgerr .dim { color: var(--mv-faint); font-size: 12px; }
154
- .mv-img img { display: block; max-width: 100%; border-radius: 6px; }
178
+ .mv-img .mv-img-el { display: block; width: 100%; height: auto; max-width: 100%; border-radius: 6px; }
155
179
  .mv-imgerr { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted); }
156
180
  .mv-imgerr b { color: #E0402F; }
157
181
 
@@ -403,12 +403,21 @@ export function App() {
403
403
  useEffect(() => {
404
404
  if (booted) return
405
405
  booted = true
406
- if (bootHash.board && bootHash.board !== useStore.getState().board)
407
- useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
408
- boot().then((ok) => {
406
+ const start = async () => {
407
+ if (bootHash.board) {
408
+ if (bootHash.board !== useStore.getState().board) // a deep link wins
409
+ useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
410
+ } else if (!PUBLISHED) {
411
+ // fresh open, no deep link: LAND on the first curated board (a tight, fast board = a good first
412
+ // impression) instead of the auto all-scenes everything-board, which renders every frame at once.
413
+ const first = (await fetchBoardNames().catch(() => [] as string[])).find((n) => n !== 'all-scenes')
414
+ if (first && first !== useStore.getState().board) useStore.setState({ board: first, boardAuto: false })
415
+ }
416
+ const ok = await boot()
409
417
  urlReady.current = true
410
- if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
411
- })
418
+ if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
419
+ }
420
+ void start()
412
421
  }, [])
413
422
 
414
423
  // the URL is a projection of state: design views replace in place; entering play and
@@ -4,6 +4,7 @@ import { CONFIG, useStore } from '../store.ts'
4
4
  import { bootHash } from '../hash.ts'
5
5
  import { startPerf } from '../perf.ts'
6
6
  import { FrameNode, HEADER } from './FrameNode.tsx'
7
+ import { startCameraBroadcast, setCameraScale } from './camera-broadcast.ts'
7
8
 
8
9
  /**
9
10
  * The world. rzpp owns pan/zoom; nodes are absolutely positioned children of #sh-world.
@@ -142,7 +143,7 @@ export function Canvas() {
142
143
  const ref = useRef<ReactZoomPanPinchContentRef>(null)
143
144
  const scaleTimer = useRef(0)
144
145
 
145
- useEffect(() => { startPerf() }, []) // B0.4: frame-time sampler (window.__mvPerf)
146
+ useEffect(() => { startPerf(); startCameraBroadcast() }, []) // B0.4: frame-time sampler + image-LOD camera signal
146
147
 
147
148
  useEffect(() => {
148
149
  const wrap = () => document.querySelector('.sh-canvas') as HTMLElement | null
@@ -331,7 +332,7 @@ export function Canvas() {
331
332
  <TransformWrapper
332
333
  ref={ref}
333
334
  minScale={0.05}
334
- maxScale={2}
335
+ maxScale={5}
335
336
  limitToBounds={false}
336
337
  doubleClick={{ disabled: true }}
337
338
  // wheelDisabled is load-bearing: rzpp's onWheelPanning is a no-op without it, and
@@ -353,6 +354,7 @@ export function Canvas() {
353
354
  // re-render happens per tick during a pan/zoom.
354
355
  onTransformed={(r) => {
355
356
  paintGrid(r.state.positionX, r.state.positionY, r.state.scale)
357
+ setCameraScale(r.state.scale) // keep the LOD's settle-scale current (cheap number write, no React)
356
358
  clearTimeout(scaleTimer.current)
357
359
  scaleTimer.current = window.setTimeout(() => setScale(r.state.scale), 120)
358
360
  }}
@@ -4,6 +4,7 @@ import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
4
4
  import { CommentLayer } from '../Comments.tsx'
5
5
  import { useComments } from '../comments-store.ts'
6
6
  import { registerFrame, unregisterFrame } from './frame-registry.ts'
7
+ import { primeCameraFor } from './camera-broadcast.ts'
7
8
  import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
8
9
 
9
10
  export const HEADER = 28
@@ -126,6 +127,11 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
126
127
  if (node.status === 'ready' || !interact)
127
128
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
128
129
  }, [interact, node.status])
130
+ // image-LOD: once the frame is ready its content listener is live, so send the settled zoom - a static
131
+ // board that never gets a gesture then still sharpens its images from the cheap low-res first paint.
132
+ useEffect(() => {
133
+ if (node.status === 'ready') primeCameraFor(iframeRef.current?.contentWindow)
134
+ }, [node.status])
129
135
 
130
136
  // a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
131
137
  useEffect(() => {
@@ -0,0 +1,44 @@
1
+ // Tells every content frame when the canvas camera starts and stops moving, so each frame's image-LOD
2
+ // (content/img-lod.ts) can freeze its bitmaps during motion and sharpen to the settled zoom afterwards.
3
+ //
4
+ // Fires ONCE per gesture, never per animation frame - it watches the single #sh-world.sh-camera class
5
+ // that EVERY camera path (rzpp zoom/pan, wheel, pinch, programmatic setTransform) toggles, so one hook
6
+ // covers them all. Posting a message to 150 frames every tick would be its own jank; two posts per
7
+ // gesture (start, settle) is free.
8
+
9
+ let lastScale = 1
10
+ /** Fed live from onTransformed so `settle` always broadcasts the final zoom. Cheap (a number write). */
11
+ export function setCameraScale(s: number): void { if (s > 0) lastScale = s }
12
+
13
+ function frameWindows(): Window[] {
14
+ const out: Window[] = []
15
+ for (const f of document.querySelectorAll<HTMLIFrameElement>('#sh-world iframe'))
16
+ if (f.contentWindow) out.push(f.contentWindow)
17
+ return out
18
+ }
19
+
20
+ function broadcast(moving: boolean): void {
21
+ const msg = moving ? { type: 'sh:camera', moving: true } : { type: 'sh:camera', moving: false, scale: lastScale }
22
+ for (const w of frameWindows()) { try { w.postMessage(msg, location.origin) } catch { /* cross-doc timing */ } }
23
+ }
24
+
25
+ let started = false
26
+ export function startCameraBroadcast(): void {
27
+ if (started) return
28
+ const world = document.getElementById('sh-world'); if (!world) return
29
+ started = true
30
+ let moving = false
31
+ new MutationObserver(() => {
32
+ const now = world.classList.contains('sh-camera')
33
+ if (now === moving) return
34
+ moving = now
35
+ broadcast(now) // +sh-camera -> freeze; -sh-camera -> sharpen to the settled scale
36
+ }).observe(world, { attributes: true, attributeFilter: ['class'] })
37
+ }
38
+
39
+ /** Prime ONE freshly-ready frame with the current settled scale, so a static board that is never zoomed
40
+ * still sharpens from its cheap low-res first paint to the right resolution. */
41
+ export function primeCameraFor(win: Window | null | undefined): void {
42
+ if (!win) return
43
+ try { win.postMessage({ type: 'sh:camera', moving: false, scale: lastScale }, location.origin) } catch { /* timing */ }
44
+ }
@@ -40,12 +40,17 @@ export const CONFIG: { viewports: Record<string, { width: number; height: number
40
40
  export { cap, humanize } from './labels.ts'
41
41
  import { cap, humanize } from './labels.ts'
42
42
 
43
- /** Board names for switchers: all-scenes first, the rest sorted. Throws on transport
44
- * failure - callers keep their last known list. */
43
+ /** Board names for switchers: the agent's curated boards FIRST (ranked by each board's `order`, then
44
+ * name), and the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
45
+ * Throws on transport failure - callers keep their last known list. */
45
46
  export async function fetchBoardNames(): Promise<string[]> {
46
47
  if (DATA) return DATA.names
47
- const list: { name: string }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
48
- return ['all-scenes', ...list.map((b) => b.name).filter((n) => n !== 'all-scenes').sort()]
48
+ const list: { name: string; order?: number }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
49
+ const curated = list
50
+ .filter((b) => b.name !== 'all-scenes')
51
+ .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name))
52
+ .map((b) => b.name)
53
+ return [...curated, 'all-scenes']
49
54
  }
50
55
  /** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
51
56
  export const boardLabel = (n: string) => humanize(n)
@@ -600,27 +605,38 @@ export const useStore = create<State>((set, get) => {
600
605
  measureNode(key, frameId, ownWidth, measuredWidth, height) {
601
606
  const s = get()
602
607
  const node = s.nodes.find((n) => n.key === key)
603
- if (!node || node.sizeMode !== 'auto') return // manual/device always win
608
+ // Only an explicit DEVICE viewport locks a content frame's height. 'auto' and 'manual' both
609
+ // auto-fit height: a content frame must always grow/shrink to show ALL its content, even after
610
+ // the human drags its WIDTH (SPEC-026 r4). 'manual' just means the human owns the WIDTH; the
611
+ // HEIGHT is still measured, so resizing width reflows and refits height (no clipped/empty frame).
612
+ if (!node || node.sizeMode === 'device') return
604
613
  if (node.frame !== frameId) return // generation guard: a reused node key
605
614
  // across a board switch never mis-attributes
606
615
  const f = s.manifest?.frames.find((x) => x.id === node.frame)
607
616
  if (!f?.contentWidth) return // not a content frame - spoof-proofing
608
617
  if (![ownWidth, measuredWidth, height].every((v) => Number.isFinite(v) && v > 0)) return
609
- const maxH = Math.round(2.5 * Math.max(844, ...Object.values(CONFIG.viewports).map((v) => v.height)))
610
- // declared meta.viewport WINS over the Doc layout width - the existing precedence
611
- const vpw = CONFIG.viewports[f.viewport ?? '']?.width
612
- const W = vpw ?? Math.min(1600, Math.max(320, Math.round(ownWidth)))
618
+ // Generous cap: a reference doc with many screenshots is legitimately very tall and must fit in
619
+ // FULL (this was 2.5x a viewport ~= 2700px, which clipped image-heavy docs). Still bounded so a
620
+ // broken measurement can't mint an infinite frame.
621
+ const maxH = 40000
613
622
  const H = Math.min(maxH, Math.max(80, Math.round(height)))
614
623
  const curW = Math.round(node.w)
615
624
  measuredHeights.set(`${node.frame}@${Math.round(measuredWidth)}`, H)
616
- if (Math.round(measuredWidth) !== curW) return // height not true at the applied width
617
- if (W !== curW) {
618
- // Doc layout changed (document<->wide): adopt the new own width first; the
619
- // iframe resizes, remeasures, and the height commits on the next message
620
- set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, w: W } : n)) }))
621
- scheduleReflow()
622
- return
625
+ // AUTO owns the width too - adopt the Doc's declared/own width. MANUAL keeps the human's width
626
+ // and only fits the height.
627
+ if (node.sizeMode !== 'manual') {
628
+ // declared meta.viewport WINS over the Doc layout width - the existing precedence
629
+ const vpw = CONFIG.viewports[f.viewport ?? '']?.width
630
+ const W = vpw ?? Math.min(1600, Math.max(320, Math.round(ownWidth)))
631
+ if (W !== curW) {
632
+ // Doc layout changed (document<->wide): adopt the new own width first; the
633
+ // iframe resizes, remeasures, and the height commits on the next message
634
+ set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, w: W } : n)) }))
635
+ scheduleReflow()
636
+ return
637
+ }
623
638
  }
639
+ if (Math.round(measuredWidth) !== curW) return // height only true at the width it was measured at
624
640
  if (Math.round(node.h) === H) return
625
641
  set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, h: H } : n)) }))
626
642
  scheduleReflow()
@@ -9,7 +9,7 @@ Minimal is enough - list the frames; the shell fills sizes from each frame's
9
9
  viewport and lays it out:
10
10
 
11
11
  ```json
12
- { "version": 1, "name": "checkout-compare", "auto": false,
12
+ { "version": 1, "name": "checkout-compare", "order": 1, "auto": false,
13
13
  "nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
14
14
  ```
15
15
 
@@ -18,8 +18,14 @@ viewport and lays it out:
18
18
  increasing `x`).
19
19
  - The human's tidy (`t`) and device views re-layout in frame-id order, so id
20
20
  ordering is the durable arrangement; explicit coordinates are one-off setups.
21
- - `auto: false` boards show exactly their list. `all-scenes` is auto-managed -
22
- never write it.
21
+ - **`"order": <n>` ranks the board in the switcher, and the LOWEST-ordered board is
22
+ the LANDING board the canvas opens on.** Rank them so the first is a tight, fast,
23
+ orienting board (an overview or the primary flow) - never a giant one. Boards
24
+ without an `order` sort after the ranked ones, by name. Set `order` deliberately on
25
+ every curated board; it is the first impression.
26
+ - `auto: false` boards show exactly their list. `all-scenes` is auto-managed (it holds
27
+ EVERY frame, so it is the heavy one) and always sinks to the BOTTOM of the switcher -
28
+ never the landing board, and never write its file.
23
29
  - Do not edit board files while the canvas is open unless asked; the shell owns
24
30
  their layout fields.
25
31
  - Use boards for comparisons: version A vs B vs C of a flow, side by side. Variant
@@ -92,10 +92,10 @@ export default () => (
92
92
  shows an in-frame card: fix the source, the frame heals live. Never hand-set
93
93
  colors or `%%{init}%%` themes - marver's palette is injected and source
94
94
  overrides are stripped.
95
- - `Img` shows `design/assets/<src>` with an optional caption; `h={n}` cover-crops
96
- to a fixed rendered height (the lever for optically aligning a mixed row - see
97
- "Images and mood boards" below). Blocks carry their own padding, border, and
98
- surface - never hand-manage spacing around them.
95
+ - `Img` shows `design/assets/<src>` with an optional caption, ALWAYS in full at its
96
+ natural aspect ratio - never cropped, never letterboxed. Size it by how many images
97
+ share its `Row` (fewer = bigger), not by a fixed height. Blocks carry their own
98
+ padding, border, and surface - never hand-manage spacing around them.
99
99
  - `intent` (`diagram` | `spec` | `moodboard` | `notes`) is the frame's PURPOSE,
100
100
  not its content mix - a frame with two diagrams and a paragraph is still the
101
101
  "diagram frame" if diagrams are why it exists. It drives the icon the human
@@ -154,21 +154,23 @@ screenshots, official brand logos, product visuals - download into
154
154
  of described imagery every time (the full asset rules: instructions/craft.md,
155
155
  "Real assets").
156
156
 
157
- **Size images to be SEEN, and make rows read as one set:**
158
-
159
- - An image that renders as a stamp is a defect. The image IS the content of a
160
- mood board - give the important one most of a row, let supporting shots share
161
- a row, and never pack so many into one `Row` that each collapses below
162
- legibility.
163
- - Equal component widths do NOT make unequal images look equal - aspect ratios
164
- and internal density differ, so a row of same-width `Img` blocks can still
165
- read ragged. Normalize a mixed row with a shared rendered height:
166
- `<Img src="..." h={240} />` cover-crops every image in the row to one height,
167
- which is what makes them READ aligned. Group similar aspects together when
168
- cropping would destroy the shot.
169
- - Alignment is judged on the RENDER, not the props: after composing, look at the
170
- actual frame (screenshot it if you can) and adjust until the rows sit
171
- optically consistent. "The code says they're the same width" proves nothing.
157
+ **Size images to be SEEN - by row grouping, never by cropping:**
158
+
159
+ - The image IS the content: it always renders in FULL at its natural aspect ratio, so
160
+ a screenshot stays fully legible and is never sliced. You size it by how many images
161
+ share a `Row` - fewer per row = larger. Give a detailed screenshot its own row or a
162
+ pair; let small supporting shots share a row of three or four. An image that renders
163
+ as a stamp is a defect - pull it into a shorter row.
164
+ - A row of same-aspect images (e.g. app screenshots, all the same window shape) lines
165
+ up as one set on its own: equal column width + equal aspect = equal height, with no
166
+ fixed-height prop. Only mixed aspects read ragged - split those into their own rows
167
+ by shape rather than forcing a height (forcing one would crop or letterbox the shot).
168
+ - Reach for `layout="wide"` on image-heavy reference frames so each shot has room, and
169
+ never set a frame height - the frame auto-heights to fit everything the canvas
170
+ measures. Marver renders images crisp and zooms fast, so fine detail is one zoom away.
171
+ - Judge on the RENDER, not the props: after composing, look at the actual frame
172
+ (screenshot it if you can) and adjust the per-row count until it reads well. "The code
173
+ says they're the same width" proves nothing.
172
174
 
173
175
  ## When Shape ends
174
176