@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 +45 -0
- package/dist/{build-BrCl9hJS.mjs → build-BZaPa2DS.mjs} +7 -3
- package/dist/cli.mjs +2 -2
- package/dist/{dev-DdeU-Jst.mjs → dev-DaPQ9xA5.mjs} +1 -1
- package/dist/{plugin-BtSGAm2h.mjs → plugin-wMY9lNf3.mjs} +12 -1
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +107 -0
- package/src/client/content/index.tsx +31 -7
- package/src/client/shell/App.tsx +14 -5
- package/src/client/shell/canvas/Canvas.tsx +4 -2
- package/src/client/shell/canvas/FrameNode.tsx +6 -0
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- package/src/client/shell/store.ts +32 -16
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/shape.md +21 -19
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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -403,12 +403,21 @@ export function App() {
|
|
|
403
403
|
useEffect(() => {
|
|
404
404
|
if (booted) return
|
|
405
405
|
booted = true
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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)
|
|
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
|
|
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={
|
|
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:
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
const
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
-
|
|
22
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
-
|
|
160
|
-
|
|
161
|
-
a row
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|