@marver-design/marver 0.5.0 → 0.7.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 +94 -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/frame-host/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +346 -0
- package/src/client/shell/App.tsx +39 -115
- package/src/client/shell/Comments.tsx +56 -11
- package/src/client/shell/Play.tsx +214 -155
- package/src/client/shell/Toolbar.tsx +189 -0
- package/src/client/shell/canvas/Canvas.tsx +4 -2
- package/src/client/shell/canvas/FrameNode.tsx +32 -2
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +32 -16
- package/src/client/shell/styles.css +63 -10
- package/src/client/stage/main.tsx +35 -11
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/shape.md +21 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,100 @@
|
|
|
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.7.0 - 2026-08-17
|
|
6
|
+
|
|
7
|
+
Prototype mode becomes a first-class review surface. The prototype (Play) now carries the same toolbar
|
|
8
|
+
as the canvas, laser and comments work INSIDE it, and every comment wears the colour of the element it
|
|
9
|
+
points at - the groundwork for tagging a coding agent from anywhere you review.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **The prototype is a review surface, not just a viewer.** Play's top-right toolbar is now the SAME
|
|
14
|
+
controls as the canvas - device switch, theme, laser, comment - built from one shared set of
|
|
15
|
+
components so the two can never drift. Laser and comment work inside the running prototype: the stage
|
|
16
|
+
runs the same element inspector the canvas frames do, so you can point at, highlight, and comment on
|
|
17
|
+
the live app while you walk a flow. The bottom-left navigator (restart / prev / i-of-N / next) stays.
|
|
18
|
+
- **Hide UI (H).** One binary toggle hides all chrome for a clean point-and-shoot frame, shared by the
|
|
19
|
+
canvas and the prototype. No auto-fade, no hover magic; a page refresh always brings the chrome back
|
|
20
|
+
(the safety net for a forgotten shortcut). It replaces the prototype's old three-state auto-hiding bar.
|
|
21
|
+
- **Comments wear their element's colour.** Comment mode reuses laser's per-element depth hue: the
|
|
22
|
+
element you hover, the composer, the pin, the thread card, and the active-element highlight all take
|
|
23
|
+
that specific element's colour - a comment on a blue heading reads blue, on a green button green. The
|
|
24
|
+
avatar keeps the commenter's own colour; filled buttons use a hue-aware shade so the glyph stays legible.
|
|
25
|
+
- **The commented element lights up.** Picking an element locks a persistent outline on it (the highlight
|
|
26
|
+
stops chasing the mouse while you compose), and opening a thread re-lights its anchored element - so
|
|
27
|
+
everyone sees exactly which element a comment is about. It clears on close.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **Prototype chrome is discreet and reliable.** The prototype's floating toolbar wears the same quiet
|
|
32
|
+
dark skin as the bottom-left navigator, so it recedes into the stage instead of reading as a bright
|
|
33
|
+
slab; and it no longer auto-hides or sticks under the cursor - it behaves exactly like the canvas pill
|
|
34
|
+
(explicit collapse, Hide-UI, no surprises).
|
|
35
|
+
- **Prototype is an action, not a menu item.** The Prototype-view button moved into the canvas pill's
|
|
36
|
+
action group (beside comment, laser, tidy), leaving the far right purely for UI management (Hide UI,
|
|
37
|
+
collapse).
|
|
38
|
+
- **The element label reads clearer.** The laser/comment element tooltip now sits below-left of the
|
|
39
|
+
cursor with a gap - clear of the element you are pointing at - and slides in from the edge instead of
|
|
40
|
+
being clipped.
|
|
41
|
+
- **Escape exits laser mode**, matching comment mode.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **Canvas comment highlight is now reliable.** A frame hosting an open thread or a draft shows its LIVE
|
|
46
|
+
app rather than the frozen lean snapshot, so the active-element highlight appears, updates, and clears
|
|
47
|
+
in real time - it used to be intermittently missing, or frozen after close, depending on snapshot
|
|
48
|
+
timing. A clean snapshot rebuilds once the thread closes.
|
|
49
|
+
- **Highlights only show while a thread is active.** Closing a thread or hiding pins (Shift+C) clears the
|
|
50
|
+
element highlight; a remotely-resolved thread no longer strands one.
|
|
51
|
+
- **Prototype comment overlay positions faster** after a walk and never renders behind the frame or off
|
|
52
|
+
screen. Element-hue values, message origins, and stage-swap timing are all validated.
|
|
53
|
+
|
|
54
|
+
## 0.6.0 - 2026-08-17
|
|
55
|
+
|
|
56
|
+
Image-heavy boards, done right. A board full of high-resolution screenshots now zooms fast and stays
|
|
57
|
+
crisp, images render in FULL instead of cropped, content frames size themselves to their content, and
|
|
58
|
+
the switcher opens on a tight landing board instead of loading every frame at once.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **Client-side image level-of-detail (LOD).** A board of 150+ high-res screenshots used to jank hard
|
|
63
|
+
on zoom - the real cost was decoded memory, not file size (a 2708x1610 PNG is ~17MB decoded, so 174 of
|
|
64
|
+
them held ~3GB of bitmaps the browser resampled every frame). Each `Img` now decodes STRAIGHT to its
|
|
65
|
+
on-screen size via `createImageBitmap` and paints on a canvas; bitmaps freeze during a pan/zoom and
|
|
66
|
+
re-pick resolution only when the gesture settles (the tldraw pattern). Result on a 174-image board:
|
|
67
|
+
~26MB decoded at overview vs ~3GB before (~100x less), lag-free zoom, crisp detail when you stop.
|
|
68
|
+
Falls back to a plain `img` where `createImageBitmap` is unavailable.
|
|
69
|
+
- **Board ranking and a fast landing board.** Boards carry an `"order"` field; the switcher ranks curated
|
|
70
|
+
boards by it and always sinks the auto `all-scenes` everything-board to the BOTTOM. A fresh open now
|
|
71
|
+
lands on the FIRST curated board - a tight, fast board and a good first impression - instead of
|
|
72
|
+
rendering every frame at once. Rank boards deliberately; the first is what people see first. `order`
|
|
73
|
+
survives the shell's autosaves.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **Reference images show in FULL.** `Img` no longer cover-crops to a fixed height (that sliced the
|
|
78
|
+
sides off every screenshot). Each image fills its column at its natural aspect ratio - never cropped,
|
|
79
|
+
never letterboxed - so same-aspect screenshots line up on their own and the frame auto-heights to fit.
|
|
80
|
+
Size an image by how many share its `Row` (fewer = bigger), not by a fixed height; `h` is accepted for
|
|
81
|
+
back-compat but no longer constrains size. A clean inset hairline (grayscale, light + dark) sits on the
|
|
82
|
+
image's own edge, overriding a screenshot's ragged or baked-in border instead of framing it twice.
|
|
83
|
+
- **Content frames fit their content when you resize.** A manual WIDTH resize now keeps the HEIGHT auto:
|
|
84
|
+
the frame reflows and refits to show everything, instead of freezing at a stale height (only an explicit
|
|
85
|
+
device viewport locks it). The content-frame height cap was raised so a long reference doc renders in
|
|
86
|
+
full rather than clipping, and zoom now reaches 500% for inspecting screenshot detail.
|
|
87
|
+
- **Authoring doctrine updated to match.** The scaffolded instructions now teach sizing images by row
|
|
88
|
+
grouping instead of cropping, and ranking boards with `order` (first = landing, `all-scenes` is heavy
|
|
89
|
+
and auto-last).
|
|
90
|
+
|
|
91
|
+
### Fixed
|
|
92
|
+
|
|
93
|
+
- **No jiggle on zoom.** An image's display aspect-ratio is pinned on first decode, so an LOD resolution
|
|
94
|
+
switch changes only the pixels, never the layout box - frames no longer drift as you zoom.
|
|
95
|
+
- **Fast zoom no longer stalls frames.** The LOD re-decode is debounced past the gesture and drops queued
|
|
96
|
+
work when a new gesture starts, so oscillating zoom-in/out never stacks decode waves and times frames
|
|
97
|
+
out to a ready-timeout.
|
|
98
|
+
|
|
5
99
|
## 0.5.0 - 2026-08-15
|
|
6
100
|
|
|
7
101
|
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.7.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
|
|