@marver-design/marver 0.19.2 → 0.21.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 (39) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +8 -10
  3. package/dist/{bake-kaf5kGZ7.mjs → bake-BID6mo-N.mjs} +1 -1
  4. package/dist/{boards-BmxcT3Lc.mjs → boards-BwiDAmPf.mjs} +95 -48
  5. package/dist/{boards-PuVzw5Wp.mjs → boards-DnLewfj8.mjs} +20 -11
  6. package/dist/{build-7ed5H2vT.mjs → build-C7MqQ7hq.mjs} +18 -8
  7. package/dist/cli.mjs +19 -13
  8. package/dist/{daemon-DbHvLQUL.mjs → daemon-CRZFpl6K.mjs} +1 -1
  9. package/dist/{dev-D3mP2x27.mjs → dev-BNZF4Mup.mjs} +5 -5
  10. package/dist/{init-BQYCS3EU.mjs → init-C34BY3R4.mjs} +34 -33
  11. package/dist/{manifest-B01PSyDc.mjs → manifest-mMfUhPtL.mjs} +8 -5
  12. package/dist/{plugin-DI-7NAnx.mjs → plugin-omHLCn91.mjs} +34 -26
  13. package/dist/{poster-DNh6N27C.mjs → poster-BvxiAzy1.mjs} +1 -1
  14. package/dist/{publish-bakes-Dp-ZFk3d.mjs → publish-bakes-BqzAAa3w.mjs} +8 -2
  15. package/dist/{shot-DMDvDbeP.mjs → shot-DswS4iRK.mjs} +7 -7
  16. package/docs/live-jam.md +1 -1
  17. package/docs/slides.md +89 -89
  18. package/docs/sticky-notes.md +9 -0
  19. package/package.json +1 -1
  20. package/src/client/const.ts +27 -9
  21. package/src/client/content/chart.tsx +8 -8
  22. package/src/client/content/index.tsx +5 -4
  23. package/src/client/content/slide.tsx +26 -201
  24. package/src/client/frame-host/main.tsx +10 -0
  25. package/src/client/shell/BoardList.tsx +111 -52
  26. package/src/client/shell/Comments.tsx +12 -4
  27. package/src/client/shell/Play.tsx +26 -14
  28. package/src/client/shell/Toolbar.tsx +7 -5
  29. package/src/client/shell/canvas/FrameNode.tsx +16 -2
  30. package/src/client/shell/store.ts +8 -8
  31. package/src/client/shell/styles.css +10 -9
  32. package/src/client/stage/main.tsx +56 -7
  33. package/src/shared/board-tree.ts +271 -140
  34. package/templates/AGENTS-embedded.md +3 -3
  35. package/templates/AGENTS-studio.md +3 -3
  36. package/templates/instructions/boards.md +47 -22
  37. package/templates/instructions/reference/deck-layouts.md +153 -199
  38. package/templates/instructions/reference/deck-story.md +6 -6
  39. package/templates/instructions/slides.md +275 -383
@@ -9,15 +9,33 @@ export const ROUTE = '/__mv'
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
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. */
12
+ /** The default slide stage: 16:9 at 1280×720, deliberately NOT a config viewport - no
13
+ * migration for existing projects, no deck device in sweeps. Dependency-neutral so
14
+ * server (shot) and shell (store, play) share it. */
15
15
  export const SLIDE_INTRINSIC = { width: 1280, height: 720 }
16
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
17
+ /** The one sizing rule for slide frames, shared by canvas, shot and slides mode: a
18
+ * `slide: true` frame's stage is its declared viewport when the project defines one
19
+ * (a 16:10 deck authored at `laptop` stays 1280×800), else the 1280×720 default. A
20
+ * slide is an ordinary frame at that size - slides mode scales the whole stage to the
21
+ * screen, so the frame never has to. */
22
+ export function slideSize(
23
+ frame: { slide?: boolean; viewport?: string },
24
+ viewports: Record<string, { width: number; height: number }> = {},
25
+ ): { width: number; height: number } | null {
26
+ if (!frame.slide) return null
27
+ const vp = frame.viewport ? viewports[frame.viewport] : undefined
28
+ return vp ? { width: vp.width, height: vp.height } : SLIDE_INTRINSIC
29
+ }
30
+
31
+ /** The baseline every slide document shares, in the frame host and the stage alike: the
32
+ * document IS the stage, so the browser's body margin never frames it - whatever the theme
33
+ * or the content primitives a previous slide injected. */
34
+ export const SLIDE_DOC_CSS = 'html[data-mv-slide] body { margin: 0 }'
35
+
36
+ /** How a slide's stage sits in a box of any other size: scaled uniformly to fit and centred.
37
+ * A slide never reflows - the canvas node, the player and a shot all show the same stage. */
38
+ export function stageFit(stage: { width: number; height: number }, box: { w: number; h: number }): { k: number; ox: number; oy: number } {
39
+ const k = Math.max(0.01, Math.min(box.w / stage.width, box.h / stage.height))
40
+ return { k, ox: (box.w - stage.width * k) / 2, oy: (box.h - stage.height * k) / 2 }
23
41
  }
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * Chart (v1.5) - Apache ECharts, the Diagram way: the author picks the FORM
3
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).
4
+ * marver injects the house theme and keeps the chart still at rest.
6
5
  *
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
6
+ * SVG renderer ONLY - crisp at any canvas zoom and any stage scale. At
9
7
  * rest the chart renders its final state (animation force-disabled); in
10
8
  * slides mode (useSlidePlay) it plays its entrance once on mount.
11
9
  *
@@ -66,19 +64,21 @@ export function chartTheme(t: { ink: string; font: string; accent: string; groun
66
64
 
67
65
  /** The house theme, read from the frame the chart sits in, at render time. Ink and font are
68
66
  * 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. */
67
+ * Tailwind text colour and typeface, a Doc's tokens, or a slide's own type, with no
68
+ * per-context wiring. Accent and ground come from slide tokens, then Doc tokens, then the
69
+ * mode palette. A slide frame (meta `slide: true`, stamped on <html> as data-mv-slide) or a
70
+ * <Slide> wrapper takes the stage label scale. */
71
71
  function houseTheme(el: HTMLElement, dark: boolean) {
72
72
  const css = getComputedStyle(el)
73
73
  const v = (...names: string[]) => { for (const n of names) { const x = css.getPropertyValue(n).trim(); if (x) return x } return '' }
74
74
  return chartTheme({
75
75
  ink: css.color || (dark ? '#F2F2F7' : '#1C1C1E'),
76
76
  font: v('--sl-font') || css.fontFamily || FONT_STACK,
77
- accent: v('--sl-accent', '--mv-accent') || (dark ? '#0091FF' : '#0088FF'),
77
+ accent: v('--sl-accent', '--marver-slide-accent', '--mv-accent') || (dark ? '#0091FF' : '#0088FF'),
78
78
  ground: v('--sl-ground', '--mv-surface', '--mv-bg') || (dark ? '#1C1C1E' : '#FFFFFF'),
79
79
  grid: v('--sl-grid') || (dark ? 'rgba(242,242,247,.12)' : 'rgba(28,28,30,.1)'),
80
80
  dark,
81
- inSlide: !!el.closest('.sl-root'),
81
+ inSlide: !!el.closest('.sl-root') || document.documentElement.hasAttribute('data-mv-slide'),
82
82
  })
83
83
  }
84
84
 
@@ -22,7 +22,7 @@ export function Diagram(props: Parameters<typeof DiagramRoot>[0]) { ensureStyles
22
22
  import { Slide as SlideRoot } from './slide.tsx'
23
23
  import { Chart as ChartRoot } from './chart.tsx'
24
24
  import { Video as VideoRoot } from './video.tsx'
25
- export { SLIDE_W, SLIDE_H } from './slide.tsx'
25
+ export { SLIDE_W, SLIDE_H, useSlidePlay } from './slide.tsx'
26
26
  // the shared stylesheet used to ride in with Doc alone; a slide composes Img,
27
27
  // Chart, and Video straight inside <Slide> with no Doc, so every public
28
28
  // primitive installs it - once per document, idempotent
@@ -98,9 +98,10 @@ export function Img({ src, caption, alt, h }: { src: string; caption?: string; a
98
98
  const url = assetUrl(src)
99
99
  const [err, setErr] = useState(false)
100
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()
101
+ // on a slide the LOD canvas is OFF: slides mode scales the whole stage up to the screen,
102
+ // and a bitmap decoded for the frame's own box would blur there - the plain <img> keeps
103
+ // its full resolution. A slide frame is marked on <html> (data-mv-slide) before it renders.
104
+ const inSlide = useInSlide() || (typeof document !== 'undefined' && document.documentElement.hasAttribute('data-mv-slide'))
104
105
  // LOD: paint the image on a <canvas> decoded to its on-screen size (never the full 17MB bitmap), and
105
106
  // re-pick resolution only when the canvas settles after a zoom. See img-lod.ts. Falls back to a plain
106
107
  // <img> where createImageBitmap/bitmaprenderer isn't available (correctness over the optimization).
@@ -1,39 +1,32 @@
1
1
  /**
2
- * The Slide root (v1.5) - the ONE slide primitive. Owns the 1280×720 stage,
3
- * the tokens, the type roles, and the rest-state motion reset. Recipes are
4
- * prose in instructions/slides.md; markup composes INSIDE <Slide> with the
5
- * project's own classes - nothing else is an API.
2
+ * Slide - an OPTIONAL wrapper, nothing more. A slide is any frame with `slide: true` in its
3
+ * meta: it renders at its stage size (its declared viewport, else 1280×720) and slides mode
4
+ * scales the whole stage to the screen, so the frame never scales itself. Everything inside
5
+ * is the author's own code - layout, type, colour, imagery, motion.
6
6
  *
7
- * Tokens read documented host variables (--marver-slide-*) from the frame's
8
- * theme when the host defines them, and fall back to the palette - an
9
- * unthemed repo still gets a coherent deck.
7
+ * The wrapper fills the frame and tells the content primitives they sit on a stage (Img,
8
+ * Chart); decks written against the earlier Slide root import it, so it stays.
10
9
  *
11
- * Motion law: at rest (canvas), everything inside a slide is STILL - CSS
12
- * animations and transitions are suspended. Slides mode lifts the reset by
13
- * setting `data-sl-play` on <html> (the stage owns that flag), which also
14
- * arms the entrance presets (`data-animate`, run once after the swap
15
- * settles - the stage adds `data-sl-entered`).
10
+ * The playback contract lives with the player (src/client/stage/main.tsx): while a deck
11
+ * plays, <html> carries `data-sl-play`, plus `data-sl-entered` once each slide has arrived -
12
+ * authors key their own CSS and JS motion off them. useSlidePlay() is the same flag for React.
16
13
  */
17
- import { createContext, useContext, useEffect, useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
18
- import { FONT_STACK } from './palette.ts'
14
+ import { createContext, useContext, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
15
+ import { SLIDE_INTRINSIC } from '../const.ts'
19
16
 
20
- export const SLIDE_W = 1280
21
- export const SLIDE_H = 720
22
- /** Stage margins: wide at the sides, tight top and bottom (content 1104x632). */
23
- const PAD_X = 88
24
- const PAD_Y = 44
17
+ /** The default stage. A deck may declare another size through its frames' viewport. */
18
+ export const SLIDE_W = SLIDE_INTRINSIC.width
19
+ export const SLIDE_H = SLIDE_INTRINSIC.height
25
20
 
26
- /** Img (and anything else that cares) asks: am I inside a slide? */
21
+ /** Img (and anything else that cares) asks: am I inside a <Slide>? */
27
22
  export const SlideCtx = createContext(false)
28
23
  export const useInSlide = () => useContext(SlideCtx)
29
24
 
30
25
  /**
31
- * Is slides mode live? The STAGE owns the answer: it stamps `data-sl-play`
32
- * on <html> (boot: the `slides` URL param; thereafter its own messages).
33
- * CSS reacts to the attribute natively; React components (Chart's entrance,
34
- * Video's player mount) subscribe here - a MutationObserver over the
35
- * documentElement attribute, so the contract is one attribute, one owner,
36
- * observable by anyone.
26
+ * Is a deck playing? The STAGE owns the answer: it stamps `data-sl-play` on <html> (boot:
27
+ * the `slides` URL param). CSS reacts to the attribute natively; React components (Chart's
28
+ * entrance, Video's player mount) subscribe here - a MutationObserver over the
29
+ * documentElement attribute, so the contract is one attribute, one owner, observable by anyone.
37
30
  */
38
31
  const subscribePlay = (cb: () => void) => {
39
32
  if (typeof document === 'undefined') return () => {}
@@ -44,195 +37,27 @@ const subscribePlay = (cb: () => void) => {
44
37
  const readPlay = () => typeof document !== 'undefined' && document.documentElement.hasAttribute('data-sl-play')
45
38
  export const useSlidePlay = (): boolean => useSyncExternalStore(subscribePlay, readPlay, () => false)
46
39
 
40
+ /* the frame document must not pad the stage: a stage-wide root in a margined body overflows */
47
41
  const SLIDE_CSS = `
48
- /* the frame document must not pad the stage: a 1280px root in a margined
49
- body overflows the intrinsic by 16px - reset where a slide lives. The body
50
- also paints the slide ground, so the letterbox around a scaled stage is
51
- seamless instead of a default-white halo. */
52
- body:has(.sl-root) { margin: 0; background: var(--marver-slide-ground, #ffffff) }
53
- .dark body:has(.sl-root), [data-theme="dark"] body:has(.sl-root) { background: var(--marver-slide-ground-dark, #101014) }
54
- .sl-root, .sl-root * { box-sizing: border-box }
55
- .sl-root {
56
- --sl-ink: var(--marver-slide-ink, #18181b);
57
- --sl-ground: var(--marver-slide-ground, #ffffff);
58
- --sl-accent: var(--marver-slide-accent, #0088ff);
59
- --sl-muted: var(--marver-slide-muted, rgba(24, 24, 27, .55));
60
- --sl-tempo: var(--marver-slide-tempo, 350ms);
61
- --sl-font: var(--marver-slide-font, ${FONT_STACK});
62
- /* THE STAGE MARGINS, in px and ASYMMETRIC - the shape every well-made deck
63
- uses. Generous at the sides, tighter top and bottom, so the title sits
64
- high, the footnote sits low, and the middle band is the tallest thing on
65
- the slide. Percentages are wrong here twice over: they would resolve
66
- against this absolutely positioned box's containing block (the viewport,
67
- not the stage), and one value for all four sides squeezes the middle. */
68
- --sl-pad-x: var(--marver-slide-pad-x, ${PAD_X}px);
69
- --sl-pad-y: var(--marver-slide-pad-y, ${PAD_Y}px);
70
- --sl-margin: var(--sl-pad-x); /* the side margin, for author math */
71
- /* THE FIT: authored at exactly ${SLIDE_W}x${SLIDE_H}, then scaled and
72
- centered to the largest box the viewport gives it - fill window, any
73
- device, a canvas node resized to a phone, any published viewer's screen.
74
- One coordinate system, so the author's px, Tailwind classes, and charts
75
- all scale together. Pure CSS (tan(atan2(a, b)) is the unitless ratio
76
- a / b), so a lean cover - which runs no JS - reflows to the right scale
77
- the moment its node is resized. 1 wherever the viewport IS the stage.
78
- Declared under @supports below: an engine without CSS trig would keep
79
- the invalid tokens, defeat the var() fallback, and invalidate the whole
80
- transform - unscaled AND uncentered. */
81
- --sl-fit: 1;
82
- /* translate-center, not inset+margin:auto - an overconstrained absolute
83
- box (1280px stage in a 390px viewport) resolves margins to 0 and the
84
- scaled slide drifts off-center; translate(-50%,-50%) centers at ANY size */
85
- position: absolute; left: 50%; top: 50%;
86
- width: ${SLIDE_W}px; height: ${SLIDE_H}px; overflow: hidden;
87
- transform: translate(-50%, -50%) scale(var(--sl-fit, 1)); transform-origin: center center;
88
- background: var(--sl-ground); color: var(--sl-ink);
89
- font-family: var(--sl-font);
90
- padding: var(--sl-pad-y) var(--sl-pad-x); box-sizing: border-box;
91
- display: flex; flex-direction: column; justify-content: center; gap: 28px;
92
- }
93
- @supports (width: calc(1px * tan(atan2(1px, 1px)))) {
94
- .sl-root { --sl-fit: min(tan(atan2(100vw, ${SLIDE_W}px)), tan(atan2(100vh, ${SLIDE_H}px))) }
95
- }
96
- .dark .sl-root, [data-theme="dark"] .sl-root {
97
- --sl-ink: var(--marver-slide-ink-dark, #f5f5f7);
98
- --sl-ground: var(--marver-slide-ground-dark, #101014);
99
- --sl-muted: var(--marver-slide-muted-dark, rgba(245, 245, 247, .55));
100
- }
101
- /* type roles - fixed values, one coordinate system with the 1280x720 stage.
102
- sl-display is the ONE sanctioned oversize: the big-number stat, a section
103
- numeral, the manifesto line - never running text. */
104
- .sl-display { font-size: 160px; line-height: 1; font-weight: 800; letter-spacing: -.03em; margin: 0; font-variant-numeric: tabular-nums }
105
- /* sl-stat is the ROW size: three or four figures side by side, where one
106
- sl-display would not fit and sl-assertion would not read as a number. */
107
- .sl-stat { font-size: 88px; line-height: 1.02; font-weight: 700; letter-spacing: -.03em; margin: 0; font-variant-numeric: tabular-nums }
108
- .sl-assertion { font-size: 56px; line-height: 1.08; font-weight: 750; letter-spacing: -.02em; margin: 0 }
109
- .sl-support { font-size: 30px; line-height: 1.25; font-weight: 500; margin: 0 }
110
- .sl-body { font-size: 24px; line-height: 1.45; margin: 0 }
111
- .sl-caption { font-size: 18px; line-height: 1.4; color: var(--sl-muted); margin: 0 }
112
-
113
- /* THE MOTION RESET - at rest, a slide is still: animation NONE (not paused -
114
- none is deterministic; paused can freeze mid-keyframe) and no transitions.
115
- Slides mode (the stage sets data-sl-play on <html>) lifts it. This governs
116
- EVERY descendant - spinners and loaders included - and that is the Slide
117
- contract, documented in instructions/slides.md. */
118
- :root:not([data-sl-play]) .sl-root, :root:not([data-sl-play]) .sl-root *,
119
- :root:not([data-sl-play]) .sl-root *::before, :root:not([data-sl-play]) .sl-root *::after {
120
- animation: none !important;
121
- transition: none !important;
122
- }
123
-
124
- /* Entrance presets: inert until the stage marks the swap settled. An element
125
- carrying a morph name must not carry data-animate (one transform owner) -
126
- the doctrine says so; the selector below cannot check it, the review gate does. */
127
- [data-sl-play] .sl-root [data-animate] { opacity: 0 }
128
- /* ONE tempo per deck: the token times the entrances AND the view-transition
129
- morphs between slides (the stage document is this document). */
130
- ::view-transition-group(*), ::view-transition-old(root), ::view-transition-new(root) {
131
- animation-duration: var(--marver-slide-tempo, 350ms);
132
- }
133
- [data-sl-play][data-sl-entered] .sl-root [data-animate] {
134
- opacity: 1; animation-duration: var(--sl-tempo); animation-timing-function: cubic-bezier(.2, .7, .2, 1);
135
- animation-fill-mode: both;
136
- }
137
- [data-sl-play][data-sl-entered] .sl-root [data-animate="fade-up"] { animation-name: sl-fade-up }
138
- [data-sl-play][data-sl-entered] .sl-root [data-animate="fade"] { animation-name: sl-fade }
139
- [data-sl-play][data-sl-entered] .sl-root [data-animate="scale-in"] { animation-name: sl-scale-in }
140
- [data-sl-play][data-sl-entered] .sl-root [data-animate-delay="1"] { animation-delay: 80ms }
141
- [data-sl-play][data-sl-entered] .sl-root [data-animate-delay="2"] { animation-delay: 160ms }
142
- [data-sl-play][data-sl-entered] .sl-root [data-animate-delay="3"] { animation-delay: 240ms }
143
- @keyframes sl-fade-up { from { opacity: 0; transform: translateY(18px) } to { opacity: 1; transform: none } }
144
- @keyframes sl-fade { from { opacity: 0 } to { opacity: 1 } }
145
- @keyframes sl-scale-in { from { opacity: 0; transform: scale(.94) } to { opacity: 1; transform: none } }
146
- @media (prefers-reduced-motion: reduce) {
147
- [data-sl-play] .sl-root [data-animate] { opacity: 1; animation: none !important }
148
- }
42
+ body:has(.sl-root) { margin: 0 }
43
+ .sl-root { position: relative; box-sizing: border-box; width: 100%; min-height: 100vh }
149
44
  `
150
45
 
151
46
  function ensureSlideStyles() {
152
47
  // keyed by the DOCUMENT, not a module boolean - HMR reloads and multiple
153
48
  // roots must not double- or under-inject
154
- if (typeof document === 'undefined' || document.querySelector('style[data-mv-slide]')) return
49
+ if (typeof document === 'undefined' || document.querySelector('style[data-mv-slide-css]')) return
155
50
  const el = document.createElement('style')
156
- el.setAttribute('data-mv-slide', '')
51
+ el.setAttribute('data-mv-slide-css', '')
157
52
  el.textContent = SLIDE_CSS
158
53
  document.head.appendChild(el)
159
54
  }
160
55
 
161
- export function Slide({ children, style }: { children?: ReactNode; style?: CSSProperties }) {
56
+ export function Slide({ children, className, style }: { children?: ReactNode; className?: string; style?: CSSProperties }) {
162
57
  ensureSlideStyles()
163
- const ref = useRef<HTMLDivElement>(null)
164
- // DEV overflow marker: a slide that outgrows its stage is a VISIBLE defect
165
- // (outline + console + data-sl-over), never a silent clip decision left to
166
- // chance. Development only - a published deck paints no diagnostics.
167
- // Two tests, both in the stage's LAYOUT space (rects are post-transform,
168
- // the fit scales the stage, so rect offsets are divided by the scale):
169
- // 1. escape - a box leaving the 1280x720 stage. Measured against the
170
- // STAGE, not the padded content box: full-bleed images and clipped
171
- // photos break the margins on purpose.
172
- // 2. collision - a flex/grid child outgrowing its parent's content box.
173
- // Inside a flex column a body at flex:1 keeps its own box while ITS
174
- // children spill over the neighbouring bands, all still inside the
175
- // stage, which test 1 never sees.
176
- useEffect(() => {
177
- if (!import.meta.env.DEV) return
178
- const el = ref.current
179
- if (!el || typeof ResizeObserver === 'undefined') return
180
- let last: boolean | null = null
181
- let raf = 0
182
- const check = () => {
183
- raf = 0
184
- const r = el.getBoundingClientRect()
185
- const k = el.offsetWidth ? r.width / el.offsetWidth : 1
186
- const pad = (v: string) => parseFloat(v) || 0
187
- const W = el.offsetWidth, H = el.offsetHeight
188
- let over = el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1
189
- let culprit: Element | null = null
190
- const all = el.querySelectorAll('*')
191
- const n = Math.min(all.length, 600)
192
- for (let i = 0; !over && i < n; i++) {
193
- const c = all[i]
194
- const b = c.getBoundingClientRect()
195
- if (!b.width && !b.height) continue
196
- const y0 = (b.top - r.top) / k, y1 = (b.bottom - r.top) / k
197
- const x0 = (b.left - r.left) / k, x1 = (b.right - r.left) / k
198
- if (y1 > H + 1.5 || y0 < -1.5 || x1 > W + 1.5 || x0 < -1.5) { over = true; culprit = c; break }
199
- const par = c.parentElement
200
- if (!par || !par.clientHeight) continue
201
- const ps = getComputedStyle(par)
202
- if (!/flex|grid/.test(ps.display) || ps.overflowY !== 'visible' || ps.overflowX !== 'visible') continue
203
- if (/absolute|fixed/.test(getComputedStyle(c).position)) continue
204
- const pr = par.getBoundingClientRect()
205
- const lo = (pr.top - b.top) / k + pad(ps.paddingTop)
206
- const hi = (b.bottom - pr.bottom) / k + pad(ps.paddingBottom)
207
- if (lo > 2 || hi > 2) { over = true; culprit = c; break }
208
- }
209
- if (over === last) return // idempotent: never write the same state twice
210
- last = over
211
- el.style.outline = over ? '3px solid #ff4d4f' : ''
212
- el.dataset.slOver = over ? '1' : ''
213
- if (over) console.warn('[marver slide] content overflows the 1280×720 stage - split the slide, never shrink the type', culprit ?? el)
214
- }
215
- const schedule = () => { if (!raf) raf = requestAnimationFrame(check) }
216
- // observe every descendant (bounded): a flex child keeps its box while its
217
- // content grows, and a root-only observer never hears about that. Nodes
218
- // added later are picked up by the childList observer - childList ONLY,
219
- // so our own outline/data writes (attribute mutations) cannot re-trigger.
220
- const ro = new ResizeObserver(schedule)
221
- const seen = new WeakSet<Element>()
222
- const observeAll = () => {
223
- const all = el.querySelectorAll('*')
224
- for (let i = 0; i < all.length && i < 600; i++) { if (!seen.has(all[i])) { seen.add(all[i]); ro.observe(all[i]) } }
225
- }
226
- ro.observe(el)
227
- observeAll()
228
- const mo = new MutationObserver(() => { observeAll(); schedule() })
229
- mo.observe(el, { childList: true, subtree: true })
230
- schedule()
231
- return () => { cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect() }
232
- }, [])
233
58
  return (
234
59
  <SlideCtx.Provider value={true}>
235
- <div ref={ref} className="sl-root" style={style}>{children}</div>
60
+ <div className={className ? `sl-root ${className}` : 'sl-root'} style={style}>{children}</div>
236
61
  </SlideCtx.Provider>
237
62
  )
238
63
  }
@@ -8,6 +8,7 @@ import { createRoot } from 'react-dom/client'
8
8
 
9
9
  import './bridge.js'
10
10
  import { frameFile, frames, layoutChain, layouts, providers } from './registry.ts'
11
+ import { SLIDE_DOC_CSS } from '../const.ts'
11
12
 
12
13
  const params = new URLSearchParams(location.search)
13
14
  const id = params.get('id') ?? ''
@@ -77,6 +78,15 @@ async function boot() {
77
78
  // No typeof gate: memo()/forwardRef() components are objects, not functions.
78
79
  // React + the ErrorBoundary validate the element type better than we can.
79
80
  if (Frame == null) return fail(`${fileKey} has no default export`)
81
+ // a slide frame says so on its document - content primitives size for a stage by it, and
82
+ // the slide baseline (SLIDE_DOC_CSS) applies, the same one the stage installs
83
+ if (frameMod.meta?.slide === true) {
84
+ document.documentElement.setAttribute('data-mv-slide', '')
85
+ const base = document.createElement('style')
86
+ base.setAttribute('data-mv-slide-doc', '')
87
+ base.textContent = SLIDE_DOC_CSS
88
+ document.head.appendChild(base)
89
+ }
80
90
 
81
91
  const wrappers: any[] = []
82
92
  const providerKey = Object.keys(providers)[0]