@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 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-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.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
- {/* 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