@marver-design/marver 0.19.1 → 0.19.2

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
4
4
 
5
+ ## 0.19.2 - 2026-09-09
6
+
7
+ ### Fixed
8
+
9
+ - **A note longer than its frame gets its room below.** 0.19.1 reserved a note's width beside its
10
+ frame and nothing under it: a long note ran on below the card and over the next row's notes and
11
+ frames. The column's height is now measured as it renders (markdown, fonts and diagrams decide
12
+ it) and fed to the layout: a composed board's next row starts under the note, the gutter still
13
+ the card's, and a board already saved re-applies its recipe when a note lands or grows past its
14
+ frame, as it did for width. Two columns running into each other count as cramped too; the empty
15
+ canvas under a card beside a long column is free. Leaving a device view re-checks the room the
16
+ restored rows have. Folding a note keeps its room, so a fold never moves the board.
17
+
5
18
  ## 0.19.1 - 2026-09-08
6
19
 
7
20
  ### Fixed
package/README.md CHANGED
@@ -45,7 +45,7 @@ Frames appear on the canvas the moment the files land. That's the loop.
45
45
  - **Prototype links.** `data-goto="scene/frame"` on any element links frames into a walkable prototype - across boards, too.
46
46
  - **Five ways to view a board.** The canvas (frames on a plane), the board (the same, tidy), **present** (`p`: a full-screen clickable walkthrough - `data-goto` navigates, arrows step, `[` / `]` cycle variants, laser, comments, theme and device pickers in the toolbar), **focus** (one frame as a document - the reading preset for specs), and **slides** (a deck). A published board names its landing view; a frame deep link opens straight into it.
47
47
  - **Content frames.** Specs, Mermaid diagrams, mood boards, and slides live on the same canvas as the screens - import `Doc`, `Md`, `Diagram`, `Img`, `Slide`, `Chart`, `Video` from `@marver-design/marver/content` and think a feature through before any pixels exist. Works in a repo with no app at all: idea first, design second.
48
- - **Sticky notes.** A markdown file beside a frame (`cart.note.md`) or a scene (`_note.md`) becomes a yellow note left of the frame on the canvas - what it is for, how two variations differ, an open question. Markdown, `goto:` links to frames, hand-drawn Mermaid; readers comment on a note's text like on a frame's, fold it to its corner, hide them all with `n`. The layout makes room for it; published canvases carry them.
48
+ - **Sticky notes.** A markdown file beside a frame (`cart.note.md`) or a scene (`_note.md`) becomes a yellow note left of the frame on the canvas - what it is for, how two variations differ, an open question. Markdown, `goto:` links to frames, hand-drawn Mermaid; readers comment on a note's text like on a frame's, fold it to its corner, hide them all with `n`. The layout makes room for it, beside the frame and below; published canvases carry them.
49
49
  - **Hi-fi at rest.** A frame at rest is its own live document, asleep: animations paused and every `backdrop-filter` element painted with a certified texture of its filtered backdrop (compiled by the dev server's headless Chrome), so a board of 30 glass screens pans like 30 statics and wakes pixel-identical on interact. Glass inside glass, blend modes and frames whose paint depends on random data or the clock stay live; `?awake=1` keeps every frame live for comparison.
50
50
  - **Charts and video in any frame.** `Chart` (Apache ECharts, SVG, still at rest) inherits the ink, typeface and accent of whatever frame it sits in - a Tailwind dashboard, a dark spec, a slide - sizes its type to the context and follows the layout on resize. `Video` is poster-first everywhere: click to play wherever the frame is live, `autoplay` for an ambient loop, `ratio` for vertical clips; omit the poster and marver renders one from the clip. A screen with a chart or a clip is still a screen.
51
51
  - **Copy as image.** Select a frame, press `i` - a 2x PNG of it lands on the clipboard, rendered by the same headless Chrome that serves `marver shot`; `⇧i` for 4x (a slide is 5120×2880). Paste into Slack, a doc, or a chat with your agent.
@@ -21,10 +21,11 @@ inert. A scene note is 380 wide, a frame note 260; when a frame has both, they s
21
21
  scene first. An edit lands on the canvas as you save, without reloading the frame.
22
22
 
23
23
  The room is the layout's job. Every layout the shell composes (a board's `layout` recipe, the auto
24
- board, tidy, device views) reserves the note's width in front of its frame, and a note landing on a
25
- board already composed re-applies the recipe so the frames make way - whether the board is open at
26
- the time or not. Nobody moves frames for a note. A board dragged by hand keeps its positions; `t`
27
- makes the room there.
24
+ board, tidy, device views) reserves the note's width in front of its frame and its height below it:
25
+ a note longer than its frame runs on under the card, and the row beneath starts under the note, the
26
+ gutter unchanged. A note landing on a board already composed re-applies the recipe so the frames
27
+ make way - whether the board is open at the time or not - and so does a note that grows. Nobody
28
+ moves frames for a note. A board dragged by hand keeps its positions; `t` makes the room there.
28
29
 
29
30
  ## Diagrams
30
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
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,
@@ -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
 
@@ -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
  }
@@ -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
 
@@ -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
 
@@ -208,10 +208,12 @@ element of a note exactly as on a frame element.
208
208
 
209
209
  Placement is not your job: the canvas keeps the room. Every layout the shell composes - a
210
210
  board's `layout` recipe, the auto board, tidy, device views - reserves the note's width and
211
- gutter in front of its frame, and when a note lands on a board that is already composed (open
212
- or not) the recipe re-applies and the frames make way. Never move frames, pad a lane or
213
- measure a gap for a note: write the file and the board takes care of it. Only a board the
214
- human dragged by hand keeps its positions as they are; their `t` makes the room there.
211
+ gutter in front of its frame, and its height below it: a note longer than its frame runs on
212
+ under the card and the next row starts under the note. When a note lands on a board that is
213
+ already composed (open or not), or grows, the recipe re-applies and the frames make way. Never
214
+ move frames, pad a lane or measure a gap for a note, sideways or down: write the file and the
215
+ board takes care of it. Only a board the human dragged by hand keeps its positions as they
216
+ are; their `t` makes the room there.
215
217
 
216
218
  ````md
217
219
  ## Why the jobs list leads