@marver-design/marver 0.19.1 → 0.20.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 (35) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +9 -11
  3. package/dist/{bake-kaf5kGZ7.mjs → bake-tAb0D6Rc.mjs} +1 -1
  4. package/dist/{build-7ed5H2vT.mjs → build-DwpNPl6Z.mjs} +3 -3
  5. package/dist/cli.mjs +18 -12
  6. package/dist/{dev-D3mP2x27.mjs → dev-BjdDP69b.mjs} +3 -3
  7. package/dist/{init-BQYCS3EU.mjs → init-DKxuRxBr.mjs} +33 -32
  8. package/dist/{plugin-DI-7NAnx.mjs → plugin-y4Ch7o_A.mjs} +7 -7
  9. package/dist/{poster-DNh6N27C.mjs → poster-FCv_nXyP.mjs} +1 -1
  10. package/dist/{publish-bakes-Dp-ZFk3d.mjs → publish-bakes-XH6Bac58.mjs} +8 -2
  11. package/dist/{shot-DMDvDbeP.mjs → shot-iw3SEcpn.mjs} +7 -7
  12. package/docs/live-jam.md +1 -1
  13. package/docs/slides.md +89 -89
  14. package/docs/sticky-notes.md +14 -4
  15. package/package.json +1 -1
  16. package/src/client/const.ts +27 -9
  17. package/src/client/content/chart.tsx +8 -8
  18. package/src/client/content/index.tsx +5 -4
  19. package/src/client/content/slide.tsx +26 -201
  20. package/src/client/frame-host/main.tsx +10 -0
  21. package/src/client/shell/Comments.tsx +12 -4
  22. package/src/client/shell/Play.tsx +26 -14
  23. package/src/client/shell/Toolbar.tsx +7 -5
  24. package/src/client/shell/canvas/FrameNode.tsx +16 -2
  25. package/src/client/shell/canvas/Sticky.tsx +16 -1
  26. package/src/client/shell/notes.ts +43 -9
  27. package/src/client/shell/store.ts +17 -8
  28. package/src/client/shell/tidy.ts +12 -5
  29. package/src/client/stage/main.tsx +56 -7
  30. package/templates/AGENTS-embedded.md +3 -2
  31. package/templates/AGENTS-studio.md +3 -2
  32. package/templates/instructions/reference/deck-layouts.md +153 -199
  33. package/templates/instructions/reference/deck-story.md +6 -6
  34. package/templates/instructions/shape.md +6 -4
  35. package/templates/instructions/slides.md +275 -383
@@ -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]
@@ -7,7 +7,7 @@
7
7
  import { useEffect, useMemo, useRef, useState } from 'react'
8
8
  import { createPortal } from 'react-dom'
9
9
  import { avatarFallback, useComments } from './comments-store.ts'
10
- import { useStore, type Node } from './store.ts'
10
+ import { CONFIG, useStore, type Node } from './store.ts'
11
11
  import { threadHostKey } from './keys.ts'
12
12
  import { isNoteAnchor, resolveNoteAnchor, useNotes } from './notes.ts'
13
13
  import { canvasCtl } from './canvas/ctl.ts'
@@ -15,7 +15,7 @@ import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
15
15
  import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
16
16
  import { Tip } from './Tip.tsx'
17
17
  import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts'
18
- import { ROUTE } from '../const.ts'
18
+ import { ROUTE, slideSize, stageFit } from '../const.ts'
19
19
  import type { AgentMeta, Thread } from '../../shared/events.ts'
20
20
 
21
21
  /** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
@@ -132,6 +132,13 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
132
132
  const selected = useStore((s) => s.selection.includes(node.key))
133
133
  const { setActive } = useComments.getState()
134
134
  const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
135
+ // a slide renders at its stage and the node scales it (FrameNode): rects the frame reports are
136
+ // in stage px, so they map through the same fit into the node's coordinates
137
+ const frameEntry = useStore((s) => s.frameFor(node))
138
+ const stage = frameEntry ? slideSize(frameEntry, CONFIG.viewports) : null
139
+ const fit = stage ? stageFit(stage, { w: node.w, h: node.h }) : null
140
+ const inNode = <R extends { x: number; y: number; w: number; h: number }>(r: R): R =>
141
+ fit ? { ...r, x: fit.ox + r.x * fit.k, y: fit.oy + r.y * fit.k, w: r.w * fit.k, h: r.h * fit.k } : r
135
142
 
136
143
  const open = threads.filter((t) => !t.resolved)
137
144
  const anchored = open.filter((t) => (t.anchor as any)?.el)
@@ -249,7 +256,8 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
249
256
 
250
257
  const pinPos = (t: Thread) => {
251
258
  const a = t.anchor as any
252
- const r = isNoteAnchor(a) ? noteRects[t.id] : rects[t.id]
259
+ const fr = rects[t.id]
260
+ const r = isNoteAnchor(a) ? noteRects[t.id] : fr && inNode(fr)
253
261
  if (a?.el && r) return { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h, orphan: false }
254
262
  if (a?.el && r === null) return { x: node.w - 16, y: 16, orphan: true } // orphan parks top-right
255
263
  const p = a?.pos // frame-level: stored fraction of the frame
@@ -310,7 +318,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
310
318
  )}
311
319
  {draft?.nodeKey === node.key && (() => {
312
320
  const a = draft.anchor as any
313
- const r = (draftOnNote && noteRects.draft) || a?.rect
321
+ const r = (draftOnNote && noteRects.draft) || (a?.rect && inNode(a.rect))
314
322
  return <DraftComposer at={{ x: (r?.x ?? 0) + (a?.pos?.fx ?? 0.5) * (r?.w ?? 0), y: (r?.y ?? 0) + (a?.pos?.fy ?? 0.5) * (r?.h ?? 0) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />
315
323
  })()}
316
324
  </>
@@ -15,7 +15,7 @@ import { useStore, BOARD_POLICY, BRANDING, CONFIG, PUBLISHED, SOURCE_REVEALED, b
15
15
  import { deckOrder } from './play-order.ts'
16
16
  import { useComments } from './comments-store.ts'
17
17
  import { isNoteAnchor } from './notes.ts'
18
- import { ROUTE } from '../const.ts'
18
+ import { ROUTE, slideSize } from '../const.ts'
19
19
  import { poweredByUrl } from '../../shared/utm.ts'
20
20
  import { canvasCtl } from './canvas/ctl.ts'
21
21
  import { Tip } from './Tip.tsx'
@@ -70,9 +70,9 @@ export function enterSlides(over?: { at?: string; device?: string; theme?: strin
70
70
  const at = over?.at && deck.includes(over.at) ? over.at : deck[0]
71
71
  const frame = s.manifest?.frames.find((f) => f.id === at)
72
72
  const theme = (over?.theme && CONFIG.themes.includes(over.theme) ? over.theme : undefined) ?? frame?.theme ?? s.viewTheme
73
- // the deck's own stage is the default device; a restored link may carry a
74
- // picked viewport or fill - the standard prototype picker works in slides
75
- const device = over?.device && (CONFIG.viewports[over.device] || over.device === 'fill' || over.device === 'slide') ? over.device : 'slide'
73
+ // a slide is a fixed stage: the deck shows it fit to the window ('slide') or edge to edge
74
+ // ('fill'); a viewport a restored link carries is not a slide view, so it lands on the stage
75
+ const device = over?.device === 'fill' ? 'fill' : 'slide'
76
76
  s.setPlay({ at, device, theme, slides: true })
77
77
  }
78
78
 
@@ -358,7 +358,11 @@ function PlayInner() {
358
358
 
359
359
  const setDevice = (name: string) => {
360
360
  const p = useStore.getState().play
361
- if (p && (CONFIG.viewports[name] || name === 'fill' || (name === 'slide' && p.slides))) useStore.getState().setPlay({ ...p, device: name })
361
+ if (!p) return
362
+ // slides: the stage fit to the window or edge to edge - a viewport preset has no meaning
363
+ // for a fixed stage, so it lands on the slide view
364
+ if (p.slides) { useStore.getState().setPlay({ ...p, device: name === 'fill' ? 'fill' : 'slide' }); return }
365
+ if (CONFIG.viewports[name] || name === 'fill') useStore.getState().setPlay({ ...p, device: name })
362
366
  }
363
367
  const setTheme = (t: string) => {
364
368
  const p = useStore.getState().play
@@ -556,26 +560,34 @@ function PlayInner() {
556
560
 
557
561
  // doc preset: reading width, natural document scrolling - the iframe takes the
558
562
  // window's height at min(width, 860) CSS pixels, unscaled, and scrolls itself
559
- const slideDev = slides && play.device === 'slide'
560
- const vp = docPreset ? { width: Math.min(win.w, 860), height: win.h }
563
+ const frameEntry = useStore.getState().manifest?.frames.find((f) => f.id === play.at)
564
+ // a slide - in slides mode, or a slide frame met in present or focus - is a fixed stage:
565
+ // the iframe is always its own stage (its declared viewport, else 1280×720), scaled to the
566
+ // window UP as well as down, so a projector shows it at full size and the frame never scales
567
+ // itself. 'fill' fits it edge to edge; otherwise it leaves room for the chrome. A slide never
568
+ // reflows - a device preset has nothing to change on a fixed stage.
569
+ const stage = (frameEntry && slideSize(frameEntry, CONFIG.viewports)) ?? (slides ? SLIDE_INTRINSIC : null)
570
+ const vp = stage ? { width: stage.width, height: stage.height }
571
+ : docPreset ? { width: Math.min(win.w, 860), height: win.h }
561
572
  : fill ? { width: win.w, height: win.h }
562
- : slideDev ? { width: SLIDE_INTRINSIC.width, height: SLIDE_INTRINSIC.height }
563
- : CONFIG.viewports[play.device] ?? (slides ? { width: SLIDE_INTRINSIC.width, height: SLIDE_INTRINSIC.height } : Object.values(CONFIG.viewports)[0])
564
- const scale = fill || docPreset ? 1
565
- : slideDev ? Math.min(1, (win.w - 48) / vp.width, (win.h - (deckChrome === 'none' ? 48 : 88)) / vp.height)
573
+ : CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
574
+ const scale = stage ? Math.max(0.05, fill ? Math.min(win.w / vp.width, win.h / vp.height)
575
+ : slides ? Math.min((win.w - 48) / vp.width, (win.h - (deckChrome === 'none' ? 48 : 88)) / vp.height)
576
+ : Math.min((win.w - 96) / vp.width, (win.h - 128) / vp.height))
577
+ : docPreset || fill ? 1
566
578
  : Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
567
579
  const names = Object.keys(CONFIG.viewports)
568
580
  const list = focus ? [play.at] : slides ? currentDeck() : playList()
569
581
  const pos = list.indexOf(play.at)
570
582
  const variants = variantList()
571
- const frameEntry = useStore.getState().manifest?.frames.find((f) => f.id === play.at)
572
583
  const title = focus ? (frameEntry?.title ?? (play.at.split('/').pop() ?? play.at).replace(/-/g, ' ')) : boardLabel(board)
573
584
 
574
585
  // whole-pixel wrapper + per-axis scale so the iframe lands exactly on its edges -
575
586
  // fractional sizes left subpixel seams glowing at the corners on dark frames
576
587
  const dw = Math.round(vp.width * scale)
577
588
  const dh = Math.round(vp.height * scale)
578
- const deviceHint = fill ? 'Fill window' : `${vp.width} × ${vp.height} · keys 1-${names.length + 1}`
589
+ const deviceHint = stage ? `${vp.width} × ${vp.height} stage · ${fill ? 'edge to edge' : 'fit to the window'}`
590
+ : fill ? 'Fill window' : `${vp.width} × ${vp.height} · keys 1-${names.length + 1}`
579
591
 
580
592
  return (
581
593
  <div className={`sh-play${fill || docPreset ? ' fill' : ''}${docPreset ? ` doc t-${play.theme}` : ''}`}>
@@ -650,7 +662,7 @@ function PlayInner() {
650
662
  {!trimmed && <>
651
663
  <LaserButton />
652
664
  <i className="sep" />
653
- {!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill includeSlide={slides} hint={deviceHint} dark />}
665
+ {!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill includeSlide={slides} stageOnly={slides} hint={deviceHint} dark />}
654
666
  <ThemePicker value={play.theme} onSelect={setTheme} hint="D" dark />
655
667
  </>}
656
668
  {playUpdateRevision && <>
@@ -153,18 +153,20 @@ export function HideUIButton() {
153
153
  /** Controlled device dropdown. `value` is the active device name ('fill' allowed when
154
154
  * includeFill), null = default/mixed. The trigger + menu chrome is shared; the adapter
155
155
  * owns what select does. */
156
- export function DevicePicker({ value, onSelect, includeDefault, includeFill, includeSlide, hint, dark }: {
156
+ export function DevicePicker({ value, onSelect, includeDefault, includeFill, includeSlide, stageOnly, hint, dark }: {
157
157
  value: string | null
158
158
  onSelect: (name: string | null) => void
159
159
  includeDefault?: boolean
160
160
  includeFill?: boolean
161
- /** slides mode: the deck's own 1280×720 stage as a first-class device */
161
+ /** slides mode: the slide's own stage, fit to the window, as a first-class device */
162
162
  includeSlide?: boolean
163
+ /** slides mode: a slide is a fixed stage - only the stage views (slide, fill), no viewports */
164
+ stageOnly?: boolean
163
165
  hint?: ReactNode
164
166
  dark?: boolean
165
167
  }) {
166
168
  const pop = usePopover()
167
- const entries = Object.entries(CONFIG.viewports)
169
+ const entries = stageOnly ? [] : Object.entries(CONFIG.viewports)
168
170
  const pick = (name: string | null) => { onSelect(name); pop.setOpen(false) }
169
171
  const triggerIcon = value === 'fill' ? <FrameCornersIcon size={16} /> : value === 'slide' ? <SlideFrameIcon size={16} /> : deviceIcon(value, 16)
170
172
  return (
@@ -177,7 +179,7 @@ export function DevicePicker({ value, onSelect, includeDefault, includeFill, inc
177
179
  </Tip>
178
180
  <Popover pop={pop} dark={dark}>
179
181
  {includeSlide && <>
180
- <button onClick={() => pick('slide')} title="1280 × 720">
182
+ <button onClick={() => pick('slide')} title="the slide's own size, fit to the window">
181
183
  <SlideFrameIcon size={15} /><span>Slide</span>
182
184
  {value === 'slide' && <CheckIcon size={13} className="chk" />}
183
185
  </button>
@@ -197,7 +199,7 @@ export function DevicePicker({ value, onSelect, includeDefault, includeFill, inc
197
199
  </button>
198
200
  ))}
199
201
  {includeFill && <button onClick={() => pick('fill')}>
200
- <FrameCornersIcon size={15} /><span>Fill window</span><kbd>{entries.length + 1}</kbd>
202
+ <FrameCornersIcon size={15} /><span>Fill window</span><kbd>{Object.keys(CONFIG.viewports).length + 1}</kbd>
201
203
  {value === 'fill' && <CheckIcon size={13} className="chk" />}
202
204
  </button>}
203
205
  </Popover>
@@ -11,10 +11,21 @@ import { sleep, wake } from './sleep.ts'
11
11
  import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
12
12
  import { Stickies, type NoteSpec } from './Sticky.tsx'
13
13
  import { noteId, sceneNoteHost } from '../notes.ts'
14
+ import { slideSize, stageFit } from '../../const.ts'
14
15
 
15
16
  export const HEADER = 28
16
17
  const SNAP = 12
17
18
 
19
+ /** The iframe's box in its node: a frame fills the node; a slide renders at its stage and is
20
+ * scaled uniformly into the node and centred - resizing a slide node never reflows it. The
21
+ * identity case carries no transform (no layer, no containing block). */
22
+ function iframeBox(stage: { width: number; height: number } | null, node: { w: number; h: number }) {
23
+ if (!stage) return { width: node.w, height: node.h }
24
+ const { k, ox, oy } = stageFit(stage, node)
25
+ const identity = Math.abs(k - 1) < 1e-6 && Math.abs(ox) < 0.5 && Math.abs(oy) < 0.5
26
+ return { width: stage.width, height: stage.height, ...(identity ? {} : { transform: `translate(${ox}px, ${oy}px) scale(${k})`, transformOrigin: '0 0' }) }
27
+ }
28
+
18
29
  /** Live Jam working shimmer: a slim 2x6 strip of tiny marver marks on the frame's left
19
30
  * flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
20
31
  * --mv-w0 (set on the node) so parallel frames never pulse in sync. */
@@ -172,7 +183,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
172
183
  const resizing = useRef(false)
173
184
  const [resizeTick, setResizeTick] = useState(0)
174
185
  useEffect(() => { dirty.current = false }, [node.nav]) // a fresh document is pristine again
175
- const w = Math.round(node.w), h = Math.round(node.h)
186
+ // a slide's document is its stage, whatever the node's size (the node only scales it): the
187
+ // sleep key - the size the compiler renders to certify textures - is the document's size
188
+ const stage = frame ? slideSize(frame, CONFIG.viewports) : null
189
+ const w = Math.round(stage?.width ?? node.w), h = Math.round(stage?.height ?? node.h)
176
190
  // a layout effect: the wake lands BEFORE the first paint of the new state (a stretched texture
177
191
  // must never be painted at a new size)
178
192
  useLayoutEffect(() => {
@@ -418,7 +432,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
418
432
  src={src}
419
433
  title={frame.id}
420
434
  onLoad={registerWin}
421
- style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
435
+ style={{ ...iframeBox(stage, node), display: node.missing || node.status === 'error' ? 'none' : 'block' }}
422
436
  />
423
437
  {/* the overlay eats mouse events for drag-by-body; laser and comment mode both
424
438
  need the mouse INSIDE the frame for hover highlights, so it steps aside
@@ -19,6 +19,7 @@ import { cleanSource, guardDiagramSource, sanitizeSvg } from '../../content/diag
19
19
  import { useComments } from '../comments-store.ts'
20
20
  import { goTo } from '../goto.ts'
21
21
  import { NOTE_W, SCENE_NOTE_W, noteAnchor, noteVisible, useNotes, type NoteKind } from '../notes.ts'
22
+ import { useStore } from '../store.ts'
22
23
 
23
24
  export interface NoteSpec { kind: NoteKind; id: string; text: string }
24
25
 
@@ -266,10 +267,24 @@ function StickyBody({ text, kind, nodeKey, frameId }: { text: string; kind: Note
266
267
  export const Stickies = memo(function Stickies({ nodeKey, frameId, notes, underBadge }: { nodeKey: string; frameId: string; notes: NoteSpec[]; underBadge: boolean }) {
267
268
  const ids = notes.map((n) => n.id)
268
269
  const on = useNotes((s) => noteVisible(s, ids))
270
+ const col = useRef<HTMLDivElement>(null)
271
+ // the column's extent is measured, never computed: markdown, fonts and diagrams decide it. The
272
+ // layout gets it (and its changes) so a note longer than its frame has room below; a folded
273
+ // column keeps its height (the fold is a transform), so folding never reflows. Unmounting
274
+ // (notes gone, board switched) clears it.
275
+ useEffect(() => {
276
+ const el = col.current
277
+ if (!el) return
278
+ const report = () => useStore.getState().noteMeasured(nodeKey, el.offsetTop + el.offsetHeight)
279
+ const ro = new ResizeObserver(report)
280
+ ro.observe(el)
281
+ report()
282
+ return () => { ro.disconnect(); useStore.getState().noteMeasured(nodeKey, 0) }
283
+ }, [nodeKey, underBadge, notes.length])
269
284
  if (!notes.length) return null
270
285
  const width = Math.max(...notes.map((n) => (n.kind === 'scene' ? SCENE_NOTE_W : NOTE_W)))
271
286
  return (
272
- <div className={`sh-notes${on ? '' : ' off'}${underBadge ? ' below-vbadge' : ''}`} data-node-notes={nodeKey} style={{ width }}>
287
+ <div ref={col} className={`sh-notes${on ? '' : ' off'}${underBadge ? ' below-vbadge' : ''}`} data-node-notes={nodeKey} style={{ width }}>
273
288
  <button className="sh-notes-fold sh-no-pan" type="button" aria-label={on ? 'hide notes' : 'show notes'} title={on ? 'hide notes (N: all)' : 'show notes (N: all)'}
274
289
  onPointerDown={(e) => e.stopPropagation()}
275
290
  onClick={(e) => { e.stopPropagation(); useNotes.getState().toggle(ids) }} />
@@ -44,14 +44,36 @@ export function sceneNoteHost(
44
44
  * `h + NODE_HEADER` tall, and that is the height a note beside it has to clear. */
45
45
  export const NODE_HEADER = 28
46
46
 
47
- /** True when a note has no room: another node's card stands inside the reserve in front of a
48
- * noted node (its own note, or the scene's note it hosts). Room is the layout's job, never the
49
- * author's - a board the shell composes re-applies its layout when this is true, so a note file
50
- * can land on a saved board and the frames make way. Missing nodes (a deleted frame's card, still
51
- * drawn full size) block room but never host a note. */
47
+ /** The measured extent of a node's note column: world px from the node's top to the bottom of
48
+ * its last sticky (0 = no column, or not rendered yet). A note's height is its content's, known
49
+ * only once it is drawn - so, like a content frame's height, it is measured, transient (never
50
+ * serialized, a reload remeasures), and fed back to the layout when it lands. */
51
+ const noteHeights = new Map<string, number>()
52
+ export const noteHeight = (key: string): number => noteHeights.get(key) ?? 0
53
+ /** Record a column's extent; true when it changed (the caller asks the layout for room then). */
54
+ export function setNoteHeight(key: string, h: number): boolean {
55
+ const H = Number.isFinite(h) && h > 0 ? Math.round(h) : 0
56
+ if ((noteHeights.get(key) ?? 0) === H) return false
57
+ if (H) noteHeights.set(key, H)
58
+ else noteHeights.delete(key)
59
+ return true
60
+ }
61
+ /** A board load starts from no heights: node keys are per board file, and a key another board
62
+ * also uses must not bring its column's height along (the old board unmounts after the load). */
63
+ export const clearNoteHeights = (): void => { noteHeights.clear() }
64
+
65
+ /** True when a note has no room: another node stands inside the space in front of a noted node
66
+ * (its own note, or the scene's note it hosts) - beside its card, or below it where a note taller
67
+ * than its frame runs on. Room is the layout's job, never the author's - a board the shell
68
+ * composes re-applies its layout when this is true, so a note file can land on a saved board and
69
+ * the frames make way. An obstacle is another node's card, or its own note column (two columns
70
+ * running into each other cramp too) - the two rectangles, never their bounding box: the empty
71
+ * canvas under a card beside a long column is free. Missing nodes (a deleted frame's card, still
72
+ * drawn full size) block room but never host a note. `noteH` is the measured column extent. */
52
73
  export function notesCramped(
53
74
  nodes: readonly { key: string; frame: string; x: number; y: number; w: number; h: number; missing?: boolean }[],
54
75
  manifest: { frames: { id: string; scene: string; note?: string }[]; scenes: { name: string; note?: string }[] } | null,
76
+ noteH: (key: string) => number = noteHeight,
55
77
  ): boolean {
56
78
  if (!manifest) return false
57
79
  const entry = (id: string) => manifest.frames.find((f) => f.id === id)
@@ -62,11 +84,23 @@ export function notesCramped(
62
84
  const h = sceneNoteHost(live, (id) => entry(id)?.scene, s.name)
63
85
  if (h) hosts.add(h)
64
86
  }
87
+ type N = (typeof nodes)[number]
88
+ type R = { x0: number; x1: number; y0: number; y1: number }
89
+ const hit = (a: R, b: R) => a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0
90
+ const card = (n: N): R => ({ x0: n.x, x1: n.x + n.w, y0: n.y, y1: n.y + n.h + NODE_HEADER })
91
+ // the room a note takes: the reserve beside the card, as tall as the card or the column, whichever runs further
92
+ const room = (n: N): R | null => {
93
+ const r = n.missing ? 0 : noteReserve(!!entry(n.frame)?.note, hosts.has(n.key))
94
+ return r ? { x0: n.x - r, x1: n.x, y0: n.y, y1: n.y + Math.max(n.h + NODE_HEADER, noteH(n.key)) } : null
95
+ }
65
96
  return live.some((n) => {
66
- const r = noteReserve(!!entry(n.frame)?.note, hosts.has(n.key))
67
- if (!r) return false
68
- const x0 = n.x - r, y1 = n.y + n.h + NODE_HEADER
69
- return nodes.some((o) => o !== n && o.x < n.x && o.x + o.w > x0 && o.y < y1 && o.y + o.h + NODE_HEADER > n.y)
97
+ const mine = room(n)
98
+ if (!mine) return false
99
+ return nodes.some((o) => {
100
+ if (o === n) return false
101
+ const theirs = room(o)
102
+ return hit(mine, card(o)) || (!!theirs && hit(mine, theirs))
103
+ })
70
104
  })
71
105
  }
72
106