@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.
- package/CHANGELOG.md +162 -0
- package/README.md +44 -20
- package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
- package/dist/cli.mjs +21 -7
- package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
- package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
- package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
- package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
- package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
- package/dist/poster-CbpzSzJu.mjs +143 -0
- package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
- package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/docs/live-jam.md +177 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +140 -0
- package/package.json +3 -1
- package/src/client/const.ts +13 -0
- package/src/client/content/chart-engine.ts +33 -0
- package/src/client/content/chart.tsx +138 -0
- package/src/client/content/index.tsx +30 -6
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +223 -0
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +59 -13
- package/src/client/shell/LockedApp.tsx +7 -2
- package/src/client/shell/Play.tsx +138 -24
- package/src/client/shell/Toolbar.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +5 -3
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +3 -0
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/store.ts +80 -9
- package/src/client/shell/styles.css +23 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +20 -4
- package/templates/AGENTS-studio.md +20 -4
- package/templates/instructions/boards.md +47 -5
- package/templates/instructions/craft.md +17 -0
- package/templates/instructions/iterate.md +109 -14
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/publish.md +7 -0
- package/templates/instructions/reference/deck-layouts.md +230 -0
- package/templates/instructions/reference/deck-story.md +110 -0
- package/templates/instructions/shape.md +15 -1
- 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.
|
|
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",
|
package/src/client/const.ts
CHANGED
|
@@ -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
|
-
|
|
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 || !
|
|
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
|
-
{
|
|
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
|
-
|
|
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,
|