@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,7 +1,7 @@
1
1
  import { create } from 'zustand'
2
2
  import { ROUTE, slideSize } from '../const.ts'
3
3
  import { tidy, parseLayout, type BoardLayout, type TidyNode } from './tidy.ts'
4
- import { noteReserve, notesCramped } from './notes.ts'
4
+ import { clearNoteHeights, noteHeight, noteReserve, notesCramped, setNoteHeight } from './notes.ts'
5
5
  import { stableNodeKey } from './keys.ts'
6
6
  // @ts-expect-error virtual module provided by the plugin
7
7
  import shConfig from 'virtual:sh-config'
@@ -208,9 +208,10 @@ export async function boardFrames(name: string): Promise<string[]> {
208
208
  }
209
209
 
210
210
  const HEADER = 28
211
- /** What tidy sees: nodes with their scene, variant run, header-inclusive height, and the sticky
211
+ /** What tidy sees: nodes with their scene, variant run, header-inclusive height, the sticky
212
212
  * note width to reserve in front (spec 18: the frame's note, and the scene's on every member -
213
- * tidy keeps the scene reserve on the first node it places). */
213
+ * tidy keeps the scene reserve on the first node it places), and the measured height of the
214
+ * column standing there, so a note longer than its frame gets its room below as well. */
214
215
  export function tidyInput(nodes: readonly Node[], manifest: Manifest | null): TidyNode[] {
215
216
  const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
216
217
  const sceneNote = (scene: string) => !!manifest?.scenes.find((s) => s.name === scene)?.note
@@ -219,7 +220,7 @@ export function tidyInput(nodes: readonly Node[], manifest: Manifest | null): Ti
219
220
  const scene = f?.scene ?? ''
220
221
  return {
221
222
  key: n.key, frame: n.frame, scene, group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER,
222
- noteW: noteReserve(!!f?.note, false), sceneNoteW: noteReserve(false, sceneNote(scene)),
223
+ noteW: noteReserve(!!f?.note, false), sceneNoteW: noteReserve(false, sceneNote(scene)), noteH: noteHeight(n.key),
223
224
  }
224
225
  })
225
226
  }
@@ -256,10 +257,10 @@ let liveScenes: Manifest['scenes'] | null = null // the last sh:
256
257
  const measuredHeights = new Map<string, number>()
257
258
 
258
259
  function defaultSize(frame: FrameEntry) {
259
- // the precedence chain (spec 09 slice 1): slide intrinsic → authored
260
+ // the precedence chain (spec 09 slice 1): slide stage → authored
260
261
  // viewport → content sizing → default (one helper, shared with shot) -
261
- // the Slide root renders fixed 1280×720, so nothing may size it smaller
262
- const sl = slideSize(frame)
262
+ // a slide's stage is its declared viewport, else 1280×720
263
+ const sl = slideSize(frame, CONFIG.viewports)
263
264
  if (sl) return { w: sl.width, h: sl.height }
264
265
  // content frames: own width from Doc layout; height from the latest
265
266
  // measurement at that width, or a placeholder until sh:measure lands.
@@ -327,6 +328,8 @@ interface State {
327
328
  moveNode(key: string, x: number, y: number): void
328
329
  resizeNode(key: string, w: number, h: number): void
329
330
  measureNode(key: string, frameId: string, ownWidth: number, measuredWidth: number, height: number): void
331
+ /** A node's sticky column was drawn (or grew, or went): its extent from the node's top, world px. */
332
+ noteMeasured(key: string, height: number): void
330
333
  setStatus(key: string, status: Node['status'], error?: string): void
331
334
  bumpRev(key: string): void
332
335
  setThemeOn(key: string, theme: string): void
@@ -483,6 +486,7 @@ export const useStore = create<State>((set, get) => {
483
486
  * null = failure (transport, malformed manifest, non-404 board error) - the caller
484
487
  * keeps whatever board is currently mounted. */
485
488
  const loadBoardState = async (boardName: string): Promise<Partial<State> | null> => {
489
+ clearNoteHeights() // heights are per column drawn; a key shared by two board files carries none across
486
490
  try {
487
491
  let raw: any
488
492
  if (DATA) raw = DATA.manifest
@@ -996,6 +1000,11 @@ export const useStore = create<State>((set, get) => {
996
1000
  set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, h: H } : n)) }))
997
1001
  scheduleReflow()
998
1002
  },
1003
+ noteMeasured(key, height) {
1004
+ // the column's height is its content's - the layout learns it here, and a composed board
1005
+ // whose next row now stands under a long note re-applies its recipe (0.19.2)
1006
+ if (setNoteHeight(key, height)) roomForNotes()
1007
+ },
999
1008
  setDeviceView(name) {
1000
1009
  const vp = name ? CONFIG.viewports[name] : null
1001
1010
  if (name && !vp) return
@@ -1026,7 +1035,7 @@ export const useStore = create<State>((set, get) => {
1026
1035
  return { deviceView: name, dirty: true, baseLayout, nodes }
1027
1036
  })
1028
1037
  if (name) get().runTidy() // restore must NOT tidy - it would destroy positions
1029
- else scheduleSave()
1038
+ else { scheduleSave(); roomForNotes() } // ...unless a note grew meanwhile and the restored rows stand under it
1030
1039
  },
1031
1040
  bumpRev(key) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, rev: (n.rev ?? 0) + 1 } : n)) })) },
1032
1041
  setThemeOn(key, theme) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, themeOn: theme } : n)) })) },
@@ -4,6 +4,10 @@ export interface TidyNode {
4
4
  * (its own note), and in front of the scene's first placed node (the scene note - carried on
5
5
  * every member, applied once). A column holds both, so the wider wins, never the sum. */
6
6
  noteW?: number; sceneNoteW?: number
7
+ /** The note column's measured extent (world px from the node's top), when it is known. A column
8
+ * taller than its card runs below it: the node takes that height in its lane, so the next
9
+ * lane starts under the note - the gutter itself is still sized from the card. */
10
+ noteH?: number
7
11
  }
8
12
  export interface Placed { key: string; x: number; y: number }
9
13
 
@@ -36,20 +40,23 @@ interface Box {
36
40
  charW: number; charH: number
37
41
  }
38
42
 
39
- const box = (id: string, parts: Array<{ key: string; dx: number; dy: number; w: number; h: number }>): Box => ({
43
+ /** `h` is the part's extent (card, or its note column when that runs further); `ch` the card's own
44
+ * height, the characteristic size gutters are measured from - a long note never inflates them. */
45
+ const box = (id: string, parts: Array<{ key: string; dx: number; dy: number; w: number; h: number; ch?: number }>): Box => ({
40
46
  id,
41
47
  parts: parts.map(({ key, dx, dy }) => ({ key, dx, dy })),
42
48
  w: Math.max(0, ...parts.map((p) => p.dx + p.w)),
43
49
  h: Math.max(0, ...parts.map((p) => p.dy + p.h)),
44
50
  charW: Math.max(0, ...parts.map((p) => p.w)),
45
- charH: Math.max(0, ...parts.map((p) => p.h)),
51
+ charH: Math.max(0, ...parts.map((p) => p.ch ?? p.h)),
46
52
  })
53
+ const extent = (n: TidyNode) => Math.max(n.h, n.noteH ?? 0)
47
54
 
48
55
  /** A run of nodes laid side by side (a frame's instances, or a variant run). */
49
56
  const runBox = (id: string, run: TidyNode[]): Box => {
50
- const parts: Array<{ key: string; dx: number; dy: number; w: number; h: number }> = []
57
+ const parts: Array<{ key: string; dx: number; dy: number; w: number; h: number; ch?: number }> = []
51
58
  let dx = 0
52
- for (const n of run) { dx += n.noteW ?? 0; parts.push({ key: n.key, dx, dy: 0, w: n.w, h: n.h }); dx += n.w + frameGapX(n.w) }
59
+ for (const n of run) { dx += n.noteW ?? 0; parts.push({ key: n.key, dx, dy: 0, w: n.w, h: extent(n), ch: n.h }); dx += n.w + frameGapX(n.w) }
53
60
  return box(id, parts)
54
61
  }
55
62
 
@@ -285,7 +292,7 @@ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () =>
285
292
  sceneMaps.set(scene, m)
286
293
  const parts = members
287
294
  .filter((n) => m.has(n.key))
288
- .map((n) => ({ key: n.key, dx: m.get(n.key)!.x, dy: m.get(n.key)!.y, w: n.w, h: n.h }))
295
+ .map((n) => ({ key: n.key, dx: m.get(n.key)!.x, dy: m.get(n.key)!.y, w: n.w, h: extent(n), ch: n.h }))
289
296
  sceneBoxes.set(scene, box(scene, parts))
290
297
  }
291
298
 
@@ -14,6 +14,7 @@ import { Component, createElement, useEffect, useRef, useState, type ComponentTy
14
14
  import { flushSync } from 'react-dom'
15
15
  import { createRoot } from 'react-dom/client'
16
16
  import { frameFile, frames, layoutChain, layouts, providers } from '../frame-host/registry.ts'
17
+ import { SLIDE_DOC_CSS } from '../const.ts'
17
18
  import { createInspect } from '../frame-host/inspect.js'
18
19
 
19
20
  const params = new URLSearchParams(location.search)
@@ -21,13 +22,54 @@ const params = new URLSearchParams(location.search)
21
22
  // token systems, .dark for class-keyed ones (Tailwind/shadcn). Missing the class made
22
23
  // play render class-keyed apps light while the canvas showed them dark.
23
24
  const bootTheme = params.get('theme') ?? 'light'
24
- // slides mode (v1.5): the shell says so in the URL; the stage stamps the ONE
25
- // attribute the content primitives observe (data-sl-play lifts the rest-state
26
- // motion reset; data-sl-entered arms the entrance presets after each swap
27
- // settles). `tr=none` and prefers-reduced-motion both skip view transitions.
25
+ // slides mode: the shell says so in the URL; the stage stamps the attributes every slide
26
+ // can key its own motion off - data-sl-play (the deck is playing) on <html> for the whole
27
+ // show, data-sl-entered once each swap settles, data-mv-slide while the mounted frame is a
28
+ // `slide: true` frame. `tr=none` and prefers-reduced-motion both skip view transitions.
28
29
  const slidesMode = params.get('slides') === '1'
29
30
  const deckTransition = params.get('tr') ?? 'fade'
30
31
  if (slidesMode) document.documentElement.setAttribute('data-sl-play', '')
32
+
33
+ /** The deck's playback CSS - player behaviour, so it lives with the player and serves every
34
+ * slide, whatever its markup: one tempo (--marver-slide-tempo) for the morphs between slides
35
+ * and the optional entrance presets (data-animate + data-animate-delay), which stay inert
36
+ * until the slide has arrived. Anything beyond these is the author's own CSS or JS. */
37
+ const DECK_CSS = `
38
+ ::view-transition-group(*), ::view-transition-old(root), ::view-transition-new(root) {
39
+ animation-duration: var(--marver-slide-tempo, 350ms);
40
+ }
41
+ [data-sl-play] [data-animate] { opacity: 0 }
42
+ [data-sl-play][data-sl-entered] [data-animate] {
43
+ opacity: 1; animation-duration: var(--marver-slide-tempo, 350ms); animation-timing-function: cubic-bezier(.2, .7, .2, 1);
44
+ animation-fill-mode: both;
45
+ }
46
+ [data-sl-play][data-sl-entered] [data-animate="fade-up"] { animation-name: sl-fade-up }
47
+ [data-sl-play][data-sl-entered] [data-animate="fade"] { animation-name: sl-fade }
48
+ [data-sl-play][data-sl-entered] [data-animate="scale-in"] { animation-name: sl-scale-in }
49
+ [data-sl-play][data-sl-entered] [data-animate-delay="1"] { animation-delay: 80ms }
50
+ [data-sl-play][data-sl-entered] [data-animate-delay="2"] { animation-delay: 160ms }
51
+ [data-sl-play][data-sl-entered] [data-animate-delay="3"] { animation-delay: 240ms }
52
+ @keyframes sl-fade-up { from { opacity: 0; transform: translateY(18px) } to { opacity: 1; transform: none } }
53
+ @keyframes sl-fade { from { opacity: 0 } to { opacity: 1 } }
54
+ @keyframes sl-scale-in { from { opacity: 0; transform: scale(.94) } to { opacity: 1; transform: none } }
55
+ @media (prefers-reduced-motion: reduce) {
56
+ [data-sl-play] [data-animate] { opacity: 1; animation: none !important }
57
+ }
58
+ `
59
+ if (slidesMode) {
60
+ const el = document.createElement('style')
61
+ el.setAttribute('data-mv-deck', '')
62
+ el.textContent = DECK_CSS
63
+ document.head.appendChild(el)
64
+ }
65
+ // the slide document baseline, keyed on data-mv-slide (set per mounted frame): any mode can
66
+ // show a slide, and its geometry must not depend on what the stage showed before
67
+ {
68
+ const base = document.createElement('style')
69
+ base.setAttribute('data-mv-slide-doc', '')
70
+ base.textContent = SLIDE_DOC_CSS
71
+ document.head.appendChild(base)
72
+ }
31
73
  const reducedMotion = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches
32
74
  document.documentElement.dataset.theme = bootTheme
33
75
  document.documentElement.classList.toggle('dark', bootTheme === 'dark')
@@ -45,7 +87,7 @@ document.addEventListener('gesturestart', (e) => e.preventDefault())
45
87
  // B0.2: a nested scroll container hitting its boundary must not chain into the shell page
46
88
  document.documentElement.style.overscrollBehavior = 'contain'
47
89
 
48
- interface Mounted { id: string; Frame: ComponentType; wrappers: ComponentType[] }
90
+ interface Mounted { id: string; Frame: ComponentType; wrappers: ComponentType[]; slide: boolean }
49
91
 
50
92
  /** Resolve a frame id to its component + wrapper chain. Modules are import()-cached by
51
93
  * Vite, so re-resolving a chain yields the SAME component references - React keeps
@@ -59,7 +101,7 @@ async function resolve(id: string): Promise<Mounted> {
59
101
  const providerKey = Object.keys(providers)[0]
60
102
  if (providerKey) wrappers.push((await providers[providerKey]() as any).default)
61
103
  for (const lk of layoutChain(fileKey)) wrappers.push((await layouts[lk]() as any).default)
62
- return { id, Frame: mod.default, wrappers: wrappers.filter((w) => w != null) }
104
+ return { id, Frame: mod.default, wrappers: wrappers.filter((w) => w != null), slide: mod.meta?.slide === true }
63
105
  }
64
106
 
65
107
  class Boundary extends Component<{ resetKey: string; children?: ReactNode }, { err: Error | null }> {
@@ -119,7 +161,14 @@ function Stage() {
119
161
  for (const el of document.querySelectorAll('[data-animate]'))
120
162
  if (getComputedStyle(el).viewTransitionName !== 'none') el.removeAttribute('data-animate')
121
163
  }
122
- const apply = () => { if (seq === swapSeq.current) { flushSync(() => { setErr(null); setMounted(next) }); current.current = id; disarm() } }
164
+ const apply = () => {
165
+ if (seq !== swapSeq.current) return
166
+ // the slide mark first: content primitives read it while they render
167
+ document.documentElement.toggleAttribute('data-mv-slide', next.slide)
168
+ flushSync(() => { setErr(null); setMounted(next) })
169
+ current.current = id
170
+ disarm()
171
+ }
123
172
  const entered = () => {
124
173
  if (!slidesMode || seq !== swapSeq.current) return
125
174
  document.documentElement.setAttribute('data-sl-entered', '')
@@ -132,8 +132,9 @@ Report where the request came from: chat requests get chat replies; only comment
132
132
  raw HTML is inert. Readers comment on a note's text like on a frame element,
133
133
  fold it to its corner, hide all with N. An edit lands live without reloading the frame.
134
134
  Placement is not your job: every composed layout (recipe, auto board, tidy, device views)
135
- reserves the note's room in front of its frame and re-applies when a note lands - never
136
- move frames or pad a lane for a note, just write the file.
135
+ reserves the note's room in front of its frame and below it (a long note pushes the next
136
+ row down) and re-applies when a note lands or grows - never move frames or pad a lane for
137
+ a note, sideways or down, just write the file.
137
138
  Keep it an aside, a screen's worth at most: specs, flows and mood boards stay content frames.
138
139
  Full guide: instructions/shape.md.
139
140
 
@@ -132,8 +132,9 @@ Report where the request came from: chat requests get chat replies; only comment
132
132
  raw HTML is inert. Readers comment on a note's text like on a frame element,
133
133
  fold it to its corner, hide all with N. An edit lands live without reloading the frame.
134
134
  Placement is not your job: every composed layout (recipe, auto board, tidy, device views)
135
- reserves the note's room in front of its frame and re-applies when a note lands - never
136
- move frames or pad a lane for a note, just write the file.
135
+ reserves the note's room in front of its frame and below it (a long note pushes the next
136
+ row down) and re-applies when a note lands or grows - never move frames or pad a lane for
137
+ a note, sideways or down, just write the file.
137
138
  Keep it an aside, a screen's worth at most: specs, flows and mood boards stay content frames.
138
139
  Full guide: instructions/shape.md.
139
140