@marver-design/marver 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +44 -20
  3. package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
  4. package/dist/cli.mjs +21 -7
  5. package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
  6. package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
  7. package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
  8. package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
  9. package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
  10. package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
  11. package/dist/poster-CbpzSzJu.mjs +143 -0
  12. package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
  13. package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
  14. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  15. package/docs/live-jam.md +177 -0
  16. package/docs/publish.md +270 -0
  17. package/docs/sharing.md +333 -0
  18. package/docs/slides.md +140 -0
  19. package/package.json +3 -1
  20. package/src/client/const.ts +13 -0
  21. package/src/client/content/chart-engine.ts +33 -0
  22. package/src/client/content/chart.tsx +138 -0
  23. package/src/client/content/index.tsx +30 -6
  24. package/src/client/content/slide.tsx +238 -0
  25. package/src/client/content/video.tsx +223 -0
  26. package/src/client/frame-host/bridge.js +6 -1
  27. package/src/client/shell/App.tsx +59 -13
  28. package/src/client/shell/LockedApp.tsx +7 -2
  29. package/src/client/shell/Play.tsx +138 -24
  30. package/src/client/shell/Toolbar.tsx +12 -3
  31. package/src/client/shell/canvas/FrameNode.tsx +5 -3
  32. package/src/client/shell/hash.ts +3 -1
  33. package/src/client/shell/icons.tsx +3 -0
  34. package/src/client/shell/play-order.ts +22 -0
  35. package/src/client/shell/store.ts +80 -9
  36. package/src/client/shell/styles.css +23 -27
  37. package/src/client/stage/main.tsx +54 -3
  38. package/src/shared/utm.ts +3 -2
  39. package/templates/AGENTS-embedded.md +20 -4
  40. package/templates/AGENTS-studio.md +20 -4
  41. package/templates/instructions/boards.md +47 -5
  42. package/templates/instructions/craft.md +17 -0
  43. package/templates/instructions/iterate.md +109 -14
  44. package/templates/instructions/jam.md +18 -2
  45. package/templates/instructions/publish.md +7 -0
  46. package/templates/instructions/reference/deck-layouts.md +230 -0
  47. package/templates/instructions/reference/deck-story.md +110 -0
  48. package/templates/instructions/shape.md +15 -1
  49. package/templates/instructions/slides.md +402 -0
package/docs/slides.md ADDED
@@ -0,0 +1,140 @@
1
+ # Slides - decks on the canvas
2
+
3
+ A slide is an ordinary frame with `slide: true`:
4
+
5
+ ```tsx
6
+ import { Slide } from '@marver-design/marver/content'
7
+ export const meta = { title: 'Cover', slide: true }
8
+ export default () => (
9
+ <Slide>
10
+ <h1 className="sl-assertion">Churn halved after onboarding v2</h1>
11
+ </Slide>
12
+ )
13
+ ```
14
+
15
+ It renders 1280×720 on the canvas, wears the slide badge, and everything
16
+ you know - comments, lasers, variants, promotion, Live Jam - keeps working.
17
+ **The stage fits every screen**: you author at exactly 1280×720, and the
18
+ slide scales and centers itself to whatever viewport plays it - fill window,
19
+ a laptop, a viewer's phone - author px, Tailwind classes, and charts all
20
+ scale together, so the composition you approved is the composition everyone
21
+ sees. One scene = one deck; numbered files
22
+ (`01-cover.tsx`) are the authoring order; **the board's reading order is the
23
+ played order** - drag slides around the canvas to reorder the deck.
24
+
25
+ ## Why it stays light for the agent
26
+
27
+ There is no slide component library to learn. `Slide` is the ONE primitive:
28
+ it owns the 1280×720 stage, the asymmetric margins, six fixed type roles
29
+ (`sl-display` 160 · `sl-stat` 88 · `sl-assertion` 56 · `sl-support` 30 ·
30
+ `sl-body` 24 · `sl-caption` 18), your theme's tokens, and the motion
31
+ contract. Everything inside it is your project's own markup, classes, and
32
+ components - the same ones the app ships - so a slide is built the way a
33
+ screen is built, and an approved slide can be promoted like one.
34
+
35
+ Looking good at every size costs the agent nothing extra: the fit is pure
36
+ CSS on the root (a resized canvas node, a phone, a projector all get the
37
+ same composition, scaled), so the doctrine forbids `vw`/`vh` and media
38
+ queries inside a slide and asks for flex/grid in the stage's own
39
+ proportions. A dev-only overflow marker outlines a slide whose content escapes the
40
+ stage, or whose flex/grid child outgrows its parent - the agent sees the
41
+ defect on the canvas, and the rule is always "cut or split, never shrink the type".
42
+
43
+ The craft lives in prose, not code. `marver init` ships
44
+ `design/instructions/slides.md` - the doctrine: assertion-first argument,
45
+ the type roles, **the space IS the design** (three bands, the 85% rule, one
46
+ px spacing scale), **seven silhouettes chosen before any recipe** (statement
47
+ / hero / split / grid / stream / field / bookend) with a storyboard step
48
+ and pacing rules so a deck never reads as one repeated shape, 19 core
49
+ recipes with budgets and morph anchors, the choreography rules, and a
50
+ review gate that squints the contact sheet. Two depth references sit
51
+ beside it: `instructions/reference/deck-story.md` (intake, answer-first
52
+ structure, the evidence check, audience calibration, the words) and
53
+ `instructions/reference/deck-layouts.md` (the full layout atlas by job, the
54
+ grid, content budgets, rebuilding an existing deck, chart craft). Your own
55
+ **deck look** (tokens, type, the mark, colour meaning, numbers, voice - a
56
+ fill-in template the agent drafts on the first deck), layouts, and house
57
+ rules live in `design/slides.md`, which overrides the doctrine and which
58
+ marver never overwrites.
59
+
60
+ ## Playing and publishing a deck
61
+
62
+ Press `p` on a board whose publish row says slides and you get slides mode:
63
+ the 16:9 stage with the standard prototype toolbars (with `chrome: full`,
64
+ the default) - arrows / Space / click to advance, `d` cycles the theme,
65
+ devices including a 1280×720 Slide preset and fill window.
66
+ Publish it with:
67
+
68
+ ```json
69
+ { "boards": { "pitch": { "max": "comment", "type": "slides",
70
+ "open": "slides", "transition": "fade" } } }
71
+ ```
72
+
73
+ - `transition`: `fade` (default) or `none`.
74
+ - `chrome`: `full` (default - the standard prototype chrome: the top-right
75
+ toolbar with comment, laser, theme, and devices including fill, plus the
76
+ bottom-left walker; a locked deck-only share also carries the brand pill),
77
+ `minimal` (a slim progress strip, comments, the canvas door when the
78
+ board is not locked, and a pending-update control), or `none` (bare
79
+ stage).
80
+ - Add `"lock": true` to freeze visitors in the deck - no way out to the
81
+ canvas. When every published board is locked to present, focus, or
82
+ slides, the canvas shell is left out of the bundle entirely.
83
+
84
+ Viewers land straight in the deck; the URL survives refresh and back.
85
+
86
+ ## Motion - the diff is the animation
87
+
88
+ A resting slide is STILL - that is a contract, not a hope: charts render
89
+ final-state SVG, videos are posters (no `<video>` element exists), and the
90
+ `Slide` root suspends every CSS animation and transition under it at rest.
91
+ (Your own `<canvas>`, `<video>`, or JS-driven motion is outside the contract
92
+ and stays live, as in any frame.) Motion happens in slides
93
+ mode, one-shot:
94
+
95
+ - **Morphs**: give the same `view-transition-name` to an element on two
96
+ adjacent slides and it travels/grows between them. This is the house move.
97
+ - **Build steps**: progressive disclosure is sibling frames (`03a-`, `03b-`)
98
+ sharing morph names - every step visible and commentable on the board.
99
+ - **Entrances**: `data-animate="fade-up | fade | scale-in"` +
100
+ `data-animate-delay="0-3"`, run once after the transition settles. Never
101
+ on an element that carries a morph name.
102
+
103
+ `prefers-reduced-motion` flattens marver's own motion - the morphs between
104
+ slides and the entrance presets.
105
+
106
+ ## Charts and video
107
+
108
+ - `<Chart option={...} h={420} />` - an Apache ECharts option, on a
109
+ fixed supported surface: series bar, line, pie, scatter, radar, gauge, heatmap, funnel, treemap, sunburst, sankey, boxplot; components grid, polar, radar, tooltip, legend, title, dataset (+ transform), markLine, markPoint, markArea, visualMap, dataZoom. Anything outside
110
+ it is dropped by ECharts without an error, so stay inside. marver
111
+ supplies the house theme (colours, type, tooltip) from your
112
+ `design/theme.css` tokens, strips animation at rest, and lets any
113
+ styling you pass override the theme - so pass data and structure only.
114
+ SVG-rendered, in a lazy chunk chart-free canvases never download.
115
+ - `<Video src="intro.mp4" poster="intro.jpg" />` - the poster is the frame
116
+ at rest. Omit it on a local clip and marver renders one from the clip's own
117
+ first moments (`intro.mp4.poster.png` beside it - the dev server on first
118
+ sight, `marver build` before publishing; needs Chrome, like `shot`). An
119
+ authored poster always wins. In slides mode the glass strip mounts
120
+ on its own (play/pause, seek, mute, fullscreen); in any other live frame -
121
+ interact mode, play, a published prototype - the poster is the play button.
122
+ `ratio="9 / 16"` for a vertical clip; `autoplay` for a muted ambient loop
123
+ (that frame then stays live on the canvas). Remote https direct files work
124
+ too.
125
+
126
+ ## Theme tokens
127
+
128
+ The `Slide` root reads `--marver-slide-ground / -ink / -muted` (each with a
129
+ `-dark` variant), `--marver-slide-accent` (one value, both themes),
130
+ `--marver-slide-font`, and `--marver-slide-tempo` (one duration that times
131
+ both the entrances and the morphs between slides) from your theme and falls
132
+ back to the house palette. The stage is 1280×720 (`SLIDE_W` / `SLIDE_H`, exported from `/content`)
133
+ with asymmetric margins - 88px sides, 44px top and bottom, overridable in
134
+ px via `--marver-slide-pad-x` / `--marver-slide-pad-y` - leaving a 1104×632
135
+ content box. Morphs between slides are progressive enhancement: where
136
+ `document.startViewTransition` is missing, slides crossfade at the tempo.
137
+ Type roles, fixed: `sl-display` (160px, the one
138
+ oversize - a hero number, a section numeral), `sl-stat` (88px, a row of
139
+ figures), `sl-assertion` (56px),
140
+ `sl-support` (30px), `sl-body` (24px), `sl-caption` (18px).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.13.0",
3
+ "version": "0.15.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 - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -27,6 +27,7 @@
27
27
  "src/client",
28
28
  "src/shared",
29
29
  "templates",
30
+ "docs",
30
31
  "README.md",
31
32
  "LICENSE",
32
33
  "NOTICE",
@@ -47,6 +48,7 @@
47
48
  "@tailwindcss/vite": "^4.0.0",
48
49
  "@vitejs/plugin-react": "^6.0.0",
49
50
  "cac": "^7.0.0",
51
+ "echarts": "^6.1.0",
50
52
  "html-to-image": "^1.11.13",
51
53
  "marked": "^16.0.0",
52
54
  "mermaid": "^11.6.0",
@@ -8,3 +8,16 @@ export const ROUTE = '/__mv'
8
8
  * Shared by the Doc primitive (measurement messages) and the server-side
9
9
  * manifest scan (defaultSize for content frames) - one source, no drift. */
10
10
  export const CONTENT_WIDTH: Record<string, number> = { document: 760, wide: 1280 }
11
+
12
+ /** The slide stage (v1.5): a runtime-reserved intrinsic, deliberately NOT a
13
+ * config viewport - no migration for existing projects, no deck device in
14
+ * sweeps. Dependency-neutral so server (shot) and shell (store) share it. */
15
+ export const SLIDE_INTRINSIC = { width: 1280, height: 720 }
16
+
17
+ /** The one DEFAULT sizing rule for slide frames, shared by canvas and shot:
18
+ * `slide: true` sets the intrinsic 1280×720 stage, over any authored viewport.
19
+ * Board nodes stay resizable (the Slide root scales into whatever box it is
20
+ * given); this governs defaults, shots, and stage coordinates. */
21
+ export function slideSize(frame: { slide?: boolean }): { width: number; height: number } | null {
22
+ return frame.slide ? SLIDE_INTRINSIC : null
23
+ }
@@ -0,0 +1,33 @@
1
+ /** The lazily-loaded ECharts engine - STATIC named imports only, so the
2
+ * bundler tree-shakes to exactly the blessed set (whole-namespace imports
3
+ * drag the entire library into the chunk). chart.tsx dynamic-imports THIS
4
+ * file, which is what splits echarts into its own async chunk. */
5
+ import * as core from 'echarts/core'
6
+ import { SVGRenderer } from 'echarts/renderers'
7
+ import {
8
+ BarChart, LineChart, PieChart, ScatterChart, RadarChart, GaugeChart, HeatmapChart,
9
+ FunnelChart, TreemapChart, SunburstChart, SankeyChart, BoxplotChart,
10
+ } from 'echarts/charts'
11
+ import {
12
+ DatasetComponent, GridComponent, LegendComponent, MarkLineComponent, MarkPointComponent,
13
+ MarkAreaComponent, TitleComponent, TooltipComponent, PolarComponent, RadarComponent,
14
+ VisualMapComponent, DataZoomComponent, TransformComponent,
15
+ } from 'echarts/components'
16
+
17
+ /** THE SUPPORTED SURFACE - docs/slides.md and the doctrine list exactly this.
18
+ * Series: bar, line, pie, scatter, radar, gauge, heatmap, funnel, treemap,
19
+ * sunburst, sankey, boxplot. Components: grid, polar, radar, tooltip, legend,
20
+ * title, dataset (+ transform), markLine, markPoint, markArea, visualMap,
21
+ * dataZoom. Anything else in an option is silently dropped by ECharts - add
22
+ * it HERE and to the docs together, never one without the other. */
23
+ core.use([
24
+ SVGRenderer,
25
+ BarChart, LineChart, ScatterChart, PieChart, RadarChart, GaugeChart, HeatmapChart,
26
+ FunnelChart, TreemapChart, SunburstChart, SankeyChart, BoxplotChart,
27
+ GridComponent, PolarComponent, RadarComponent, TooltipComponent, LegendComponent,
28
+ TitleComponent, DatasetComponent, TransformComponent, MarkLineComponent,
29
+ MarkPointComponent, MarkAreaComponent, VisualMapComponent, DataZoomComponent,
30
+ ])
31
+
32
+ export const init = core.init
33
+ export type EChartsInstance = ReturnType<typeof core.init>
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Chart (v1.5) - Apache ECharts, the Diagram way: the author picks the FORM
3
+ * (the ECharts option surface, pointed at from instructions/slides.md);
4
+ * marver injects the house theme and strips author styling drift where it
5
+ * breaks the deck (animation at rest, above all).
6
+ *
7
+ * SVG renderer ONLY - a canvas-rendered chart would pin its frame live on
8
+ * the board (the lean-DOM serializer keeps <canvas> frames degraded). At
9
+ * rest the chart renders its final state (animation force-disabled); in
10
+ * slides mode (useSlidePlay) it plays its entrance once on mount.
11
+ *
12
+ * echarts is a real dependency loaded through a dynamic import, so it
13
+ * splits into its own lazy chunk: canvases without charts ship zero echarts
14
+ * bytes.
15
+ */
16
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
17
+ import { FONT_STACK } from './palette.ts'
18
+ import { useSlidePlay } from './slide.tsx'
19
+
20
+ type Engine = typeof import('./chart-engine.ts')
21
+
22
+ let enginePromise: Promise<Engine> | null = null
23
+ const loadEngine = (): Promise<Engine> => (enginePromise ??= import('./chart-engine.ts'))
24
+
25
+ /** Strip every way an option can keep moving at rest: top-level and
26
+ * per-series animation flags, and graphic keyframe animations. Pure and
27
+ * exported - the tests own it. */
28
+ export function sanitizeOption(option: Record<string, unknown>, animate: boolean): Record<string, unknown> {
29
+ const out: Record<string, unknown> = { ...option, animation: animate }
30
+ delete out.graphic // free-floating animated graphics have no place on a slide
31
+ const scrub = (s: unknown): unknown =>
32
+ s && typeof s === 'object'
33
+ ? {
34
+ ...Object.fromEntries(Object.entries(s as Record<string, unknown>).filter(([k]) => !/^animation/.test(k))),
35
+ animation: animate,
36
+ }
37
+ : s
38
+ if (Array.isArray(out.series)) out.series = out.series.map(scrub)
39
+ else if (out.series) out.series = scrub(out.series)
40
+ return out
41
+ }
42
+
43
+ /** The chart's palette, pure so the tests own it. `inSlide` picks the slide type scale
44
+ * (18px labels on a 1280-wide stage) over the document/UI scale (12px). */
45
+ export function chartTheme(t: { ink: string; font: string; accent: string; ground: string; grid: string; dark: boolean; inSlide: boolean }) {
46
+ const fs = t.inSlide ? 18 : 12
47
+ const muted = t.dark ? 'rgba(242,242,247,.5)' : 'rgba(28,28,30,.5)'
48
+ return {
49
+ color: [t.accent, '#7c5cff', '#00b8a9', '#f0883e', '#d6608c', '#5b8def'],
50
+ textStyle: { fontFamily: t.font, color: t.ink },
51
+ axisPointer: { lineStyle: { color: muted } },
52
+ categoryAxis: { axisLine: { lineStyle: { color: muted } }, axisLabel: { color: t.ink, fontSize: fs }, splitLine: { show: false } },
53
+ valueAxis: { axisLabel: { color: t.ink, fontSize: fs }, splitLine: { lineStyle: { color: t.grid } } },
54
+ legend: { textStyle: { color: t.ink, fontSize: fs } },
55
+ title: { textStyle: { color: t.ink, fontFamily: t.font }, subtextStyle: { color: muted, fontFamily: t.font } },
56
+ // series labels (pie/funnel/bar values): the frame's ink, no halo - echarts' default paints
57
+ // #333 with a white 2px text border, which reads as outlined glyphs on a dark ground
58
+ label: { color: t.ink, fontSize: fs, textBorderWidth: 0 },
59
+ tooltip: {
60
+ backgroundColor: t.ground, borderColor: 'rgba(127,127,127,.25)',
61
+ textStyle: { color: t.ink, fontFamily: t.font, fontSize: fs === 18 ? 16 : 12 },
62
+ extraCssText: 'border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.14);backdrop-filter:blur(8px)',
63
+ },
64
+ }
65
+ }
66
+
67
+ /** The house theme, read from the frame the chart sits in, at render time. Ink and font are
68
+ * the element's own COMPUTED color and font-family - so a chart inherits a UI screen's
69
+ * Tailwind text colour and typeface, a Doc's tokens, or a Slide's, with no per-context
70
+ * wiring. Accent and ground come from slide tokens, then Doc tokens, then the mode palette. */
71
+ function houseTheme(el: HTMLElement, dark: boolean) {
72
+ const css = getComputedStyle(el)
73
+ const v = (...names: string[]) => { for (const n of names) { const x = css.getPropertyValue(n).trim(); if (x) return x } return '' }
74
+ return chartTheme({
75
+ ink: css.color || (dark ? '#F2F2F7' : '#1C1C1E'),
76
+ font: v('--sl-font') || css.fontFamily || FONT_STACK,
77
+ accent: v('--sl-accent', '--mv-accent') || (dark ? '#0091FF' : '#0088FF'),
78
+ ground: v('--sl-ground', '--mv-surface', '--mv-bg') || (dark ? '#1C1C1E' : '#FFFFFF'),
79
+ grid: v('--sl-grid') || (dark ? 'rgba(242,242,247,.12)' : 'rgba(28,28,30,.1)'),
80
+ dark,
81
+ inSlide: !!el.closest('.sl-root'),
82
+ })
83
+ }
84
+
85
+ /** The frame's visual theme (light/dark), observed the same way the play
86
+ * flag is - the stage flips documentElement class/data-theme on sh:set-theme
87
+ * and a themed chart must follow, not stay stale. */
88
+ const subscribeTheme = (cb: () => void) => {
89
+ if (typeof document === 'undefined') return () => {}
90
+ const mo = new MutationObserver(cb)
91
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
92
+ return () => mo.disconnect()
93
+ }
94
+ const readTheme = () => (typeof document !== 'undefined' && (document.documentElement.classList.contains('dark') || document.documentElement.dataset.theme === 'dark') ? 'dark' : 'light')
95
+ const useFrameTheme = (): string => useSyncExternalStore(subscribeTheme, readTheme, () => 'light')
96
+
97
+ export function Chart({ option, h = 420 }: { option: Record<string, unknown>; h?: number }) {
98
+ const ref = useRef<HTMLDivElement>(null)
99
+ const play = useSlidePlay()
100
+ const theme = useFrameTheme()
101
+ const [failed, setFailed] = useState(false)
102
+ // The instance lives in a ref: init/dispose follows the THEME and the play flip (a theme
103
+ // object per init, the entrance on play); the option rides a separate effect that calls
104
+ // setOption on the live instance - so a parent re-render, or an HMR edit to a formatter
105
+ // function, never disposes and re-inits the chart.
106
+ const chartRef = useRef<import('./chart-engine.ts').EChartsInstance | null>(null)
107
+ const optionRef = useRef(option)
108
+ optionRef.current = option
109
+ const playRef = useRef(play)
110
+ playRef.current = play
111
+ useEffect(() => {
112
+ const el = ref.current
113
+ if (!el) return
114
+ let disposed = false
115
+ let ro: ResizeObserver | null = null
116
+ void loadEngine().then((engine) => {
117
+ if (disposed || !ref.current) return
118
+ const chart = engine.init(ref.current, houseTheme(ref.current, theme === 'dark'), { renderer: 'svg' })
119
+ chartRef.current = chart
120
+ chart.setOption(sanitizeOption(optionRef.current, playRef.current))
121
+ // a slide is a fixed stage, but a UI screen or a Doc reflows (device pills, responsive
122
+ // layouts): follow the box, or the SVG keeps its mount-time size
123
+ if (typeof ResizeObserver !== 'undefined') {
124
+ ro = new ResizeObserver(() => { if (!disposed) chart.resize() })
125
+ ro.observe(ref.current)
126
+ }
127
+ }).catch(() => setFailed(true))
128
+ return () => { disposed = true; ro?.disconnect(); chartRef.current?.dispose(); chartRef.current = null }
129
+ }, [play, theme])
130
+ // option changes (content OR a function inside it) reach the live instance: a normal merge
131
+ // that REPLACES the series list (a removed series disappears) while a viewer's dataZoom,
132
+ // legend selection and the like survive - notMerge would reset them on every parent render
133
+ useEffect(() => { chartRef.current?.setOption(sanitizeOption(option, play), { replaceMerge: ['series'] }) }, [option, play])
134
+ if (failed) return <div className="mv-block mv-imgerr"><b>chart unavailable</b><span>echarts failed to load</span></div>
135
+ // contain: inline-size - echarts sizes its inner box in px, which would otherwise pin the
136
+ // author's flex/grid column at that width (min-content) and defeat the ResizeObserver above
137
+ return <div ref={ref} className="mv-block mv-chart" style={{ width: '100%', height: h, minWidth: 0, contain: 'inline-size' }} />
138
+ }
@@ -8,6 +8,7 @@
8
8
  * can give the frame a natural size (the shell alone owns node dimensions).
9
9
  */
10
10
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
11
+ import { useInSlide } from './slide.tsx'
11
12
  import { CONTENT_WIDTH } from '../const.ts'
12
13
  import { assetUrl, renderMarkdown, FAMILIES } from './md.ts'
13
14
  import { lodSupported, registerLodImage } from './img-lod.ts'
@@ -16,7 +17,18 @@ import { lodSupported, registerLodImage } from './img-lod.ts'
16
17
  const FAMILY_CSS = Object.entries(FAMILIES).map(([f, c]) =>
17
18
  `.mv-md .mv-c-${f}{color:${c.light}}.dark .mv-md .mv-c-${f},[data-theme="dark"] .mv-md .mv-c-${f}{color:${c.dark}}`).join('\n')
18
19
 
19
- export { Diagram } from './diagram.tsx'
20
+ import { Diagram as DiagramRoot } from './diagram.tsx'
21
+ export function Diagram(props: Parameters<typeof DiagramRoot>[0]) { ensureStyles(); return <DiagramRoot {...props} /> }
22
+ import { Slide as SlideRoot } from './slide.tsx'
23
+ import { Chart as ChartRoot } from './chart.tsx'
24
+ import { Video as VideoRoot } from './video.tsx'
25
+ export { SLIDE_W, SLIDE_H } from './slide.tsx'
26
+ // the shared stylesheet used to ride in with Doc alone; a slide composes Img,
27
+ // Chart, and Video straight inside <Slide> with no Doc, so every public
28
+ // primitive installs it - once per document, idempotent
29
+ export function Slide(props: Parameters<typeof SlideRoot>[0]) { ensureStyles(); return <SlideRoot {...props} /> }
30
+ export function Chart(props: Parameters<typeof ChartRoot>[0]) { ensureStyles(); return <ChartRoot {...props} /> }
31
+ export function Video(props: Parameters<typeof VideoRoot>[0]) { ensureStyles(); return <VideoRoot {...props} /> }
20
32
 
21
33
  const UNIT = 16 // one gap unit, px - plain adjacency on boards is one gutter; same feel here
22
34
 
@@ -56,20 +68,24 @@ export function Doc({ layout = 'document', children }: { layout?: 'document' | '
56
68
  /* ----------------------------- layout blocks ------------------------------ */
57
69
 
58
70
  export function Row({ space = 1, children }: { space?: number; children?: ReactNode }) {
71
+ ensureStyles()
59
72
  return <div className="mv-row" style={{ gap: space * UNIT }}>{children}</div>
60
73
  }
61
74
 
62
75
  export function Col({ space = 1, children }: { space?: number; children?: ReactNode }) {
76
+ ensureStyles()
63
77
  return <div className="mv-col" style={{ gap: space * UNIT }}>{children}</div>
64
78
  }
65
79
 
66
80
  export function Space({ n = 1 }: { n?: number }) {
81
+ ensureStyles()
67
82
  return <div aria-hidden className="mv-space" style={{ flex: `0 0 ${n * UNIT}px`, minWidth: n * UNIT, minHeight: n * UNIT }} />
68
83
  }
69
84
 
70
85
  /* ---------------------------------- Md ------------------------------------ */
71
86
 
72
87
  export function Md({ children }: { children?: ReactNode }) {
88
+ ensureStyles()
73
89
  const src = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? '')
74
90
  const html = useMemo(() => renderMarkdown(src), [src])
75
91
  return <div className="mv-md" dangerouslySetInnerHTML={{ __html: html }} />
@@ -78,16 +94,21 @@ export function Md({ children }: { children?: ReactNode }) {
78
94
  /* ---------------------------------- Img ----------------------------------- */
79
95
 
80
96
  export function Img({ src, caption, alt, h }: { src: string; caption?: string; alt?: string; h?: number }) {
97
+ ensureStyles()
81
98
  const url = assetUrl(src)
82
99
  const [err, setErr] = useState(false)
83
100
  const canvasRef = useRef<HTMLCanvasElement>(null)
101
+ // inside a <Slide>, the LOD canvas is OFF: a resting slide must serialize to
102
+ // the lean-DOM path, and a <canvas> element pins its frame live (v1.5 §7)
103
+ const inSlide = useInSlide()
84
104
  // LOD: paint the image on a <canvas> decoded to its on-screen size (never the full 17MB bitmap), and
85
105
  // re-pick resolution only when the canvas settles after a zoom. See img-lod.ts. Falls back to a plain
86
106
  // <img> where createImageBitmap/bitmaprenderer isn't available (correctness over the optimization).
107
+ const lodOn = lodSupported && !inSlide
87
108
  useEffect(() => {
88
- if (!url || err || !lodSupported || !canvasRef.current) return
109
+ if (!url || err || !lodOn || !canvasRef.current) return
89
110
  return registerLodImage(canvasRef.current, url)
90
- }, [url, err])
111
+ }, [url, err, lodOn])
91
112
  if (!url || err) {
92
113
  return (
93
114
  <div className="mv-block mv-imgerr">
@@ -106,7 +127,7 @@ export function Img({ src, caption, alt, h }: { src: string; caption?: string; a
106
127
  const style = undefined
107
128
  return (
108
129
  <figure className="mv-block mv-img">
109
- {lodSupported
130
+ {lodOn
110
131
  ? <canvas ref={canvasRef} className="mv-img-el" role="img" aria-label={alt ?? caption ?? ''} style={style} />
111
132
  : <img className="mv-img-el" src={url} alt={alt ?? caption ?? ''} loading="lazy" style={style} onError={() => setErr(true)} />}
112
133
  {caption && <figcaption>{caption}</figcaption>}
@@ -155,8 +176,11 @@ body { margin: 0; }
155
176
  .mv-col { display: flex; flex-direction: column; min-width: 0; }
156
177
 
157
178
  /* the rubber: diagram + image blocks own their breathing room. The surface is
158
- a whisper, not a card - the content pops, the block only frames it */
159
- .mv-block { margin: 0; padding: ${UNIT}px; border: 1px solid var(--mv-block-line);
179
+ a whisper, not a card - the content pops, the block only frames it. The card is a
180
+ DOCUMENT treatment: inside a Slide or a UI screen a block is bare (the author's own
181
+ card or the slide's grid frames it), so it takes no invisible ${UNIT}px of padding there. */
182
+ .mv-block { margin: 0; }
183
+ .mv-doc .mv-block { padding: ${UNIT}px; border: 1px solid var(--mv-block-line);
160
184
  border-radius: 10px; background: var(--mv-block-bg); }
161
185
  /* an image block is NOT a card: the screenshot IS the content. Drop the surface + border so we don't
162
186
  frame a frame; give the image itself a hairline edge and a whisper of shadow so it reads as a clean,