@marver-design/marver 0.19.0 → 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,32 @@
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
+
18
+ ## 0.19.1 - 2026-09-08
19
+
20
+ ### Fixed
21
+
22
+ - **A note landing on a composed board makes its own room.** 0.19.0 reserved a note's width only
23
+ when tidy ran; a note file added to a board that already had saved positions stood on its left
24
+ neighbour until someone pressed `t`. A board with a recipe (or the auto board) now re-applies
25
+ its layout when a note has no room - as the file lands while the board is open, and at load
26
+ when it landed while the board was closed - and saves the result. Boards dragged by hand keep
27
+ their positions, as before. A deleted frame's card counts as an obstacle; a drag inside the
28
+ reflow's debounce is never overwritten. The agent instructions say it plainly: placement is
29
+ the canvas's job, write the file.
30
+
5
31
  ## 0.19.0 - 2026-09-08
6
32
 
7
33
  ### Added
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`. 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.
@@ -20,6 +20,13 @@ links jump to a frame; `http(s)` links open a new tab; images come from `design/
20
20
  inert. A scene note is 380 wide, a frame note 260; when a frame has both, they stack in one column,
21
21
  scene first. An edit lands on the canvas as you save, without reloading the frame.
22
22
 
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 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.
29
+
23
30
  ## Diagrams
24
31
 
25
32
  A ```mermaid fence renders hand-sketched on the paper: rough boxes, hatched fills, handwriting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.19.0",
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) }} />
@@ -40,6 +40,70 @@ export function sceneNoteHost(
40
40
  return best ? (best as { key: string }).key : null
41
41
  }
42
42
 
43
+ /** The node header the canvas draws above a frame body (FrameNode's HEADER) - a node's card is
44
+ * `h + NODE_HEADER` tall, and that is the height a note beside it has to clear. */
45
+ export const NODE_HEADER = 28
46
+
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. */
73
+ export function notesCramped(
74
+ nodes: readonly { key: string; frame: string; x: number; y: number; w: number; h: number; missing?: boolean }[],
75
+ manifest: { frames: { id: string; scene: string; note?: string }[]; scenes: { name: string; note?: string }[] } | null,
76
+ noteH: (key: string) => number = noteHeight,
77
+ ): boolean {
78
+ if (!manifest) return false
79
+ const entry = (id: string) => manifest.frames.find((f) => f.id === id)
80
+ const live = nodes.filter((n) => !n.missing)
81
+ const hosts = new Set<string>()
82
+ for (const s of manifest.scenes) {
83
+ if (!s.note) continue
84
+ const h = sceneNoteHost(live, (id) => entry(id)?.scene, s.name)
85
+ if (h) hosts.add(h)
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
+ }
96
+ return live.some((n) => {
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
+ })
104
+ })
105
+ }
106
+
43
107
  // ---- per-viewer visibility -------------------------------------------------------------
44
108
 
45
109
  const STORAGE = 'mv-notes'
@@ -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 } 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
@@ -411,17 +414,32 @@ export const useStore = create<State>((set, get) => {
411
414
  // One cancelable, BOARD-SCOPED reflow after content measurements settle.
412
415
  // The captured board name is the generation guard - a debounce surviving a board
413
416
  // switch fires into a name check and dies, never touching the new board.
417
+ // `onlyIf` (a note asking for room) is re-judged when the timer fires - a drag or a restore
418
+ // in the meantime may have settled it - and never downgrades an unconditional reflow pending.
414
419
  let reflowTimer: ReturnType<typeof setTimeout> | undefined
415
- const scheduleReflow = () => {
420
+ let reflowCheck: (() => boolean) | null = null
421
+ const scheduleReflow = (onlyIf?: () => boolean) => {
416
422
  const boardAt = get().board
423
+ reflowCheck = reflowTimer !== undefined && reflowCheck === null ? null : (onlyIf ?? null)
417
424
  clearTimeout(reflowTimer)
418
425
  reflowTimer = setTimeout(() => {
426
+ reflowTimer = undefined
427
+ const check = reflowCheck
428
+ reflowCheck = null
419
429
  const s = get()
420
430
  if (s.board !== boardAt) return
421
- if (s.gesture) { scheduleReflow(); return } // defer, never drop - retries after the drag
422
- if (s.layout || s.sceneRows?.length) s.runTidy()
431
+ if (s.gesture) { scheduleReflow(check ?? undefined); return } // defer, never drop - retries after the drag
432
+ if (check && !check()) return
433
+ if (composed(s)) s.runTidy()
423
434
  }, 400)
424
435
  }
436
+ /** Boards whose layout the shell owns: a recipe, scene rows, or the auto board. Room for a
437
+ * note is made here; a board the human placed by hand is never moved (spec 18, 0.19.1). */
438
+ const composed = (s: { layout: BoardLayout | null; sceneRows: string[][] | null; boardAuto: boolean }) => !!(s.layout || s.sceneRows?.length || s.boardAuto)
439
+ const cramped = () => { const s = get(); return !!s.manifest && notesCramped(s.nodes, s.manifest) }
440
+ /** A note may have landed with no room (a frame note via the manifest, a scene note via
441
+ * sh:scenes, either merged late by boot/switch): a composed board re-applies its layout. */
442
+ const roomForNotes = () => { if (composed(get()) && cramped()) scheduleReflow(cramped) }
425
443
 
426
444
  /** Theme resolution ladder: user pin > the frame's declared meta.theme > viewTheme. */
427
445
  const resolveTheme = (frame?: FrameEntry, user?: string) => user ?? frame?.theme ?? get().viewTheme
@@ -468,6 +486,7 @@ export const useStore = create<State>((set, get) => {
468
486
  * null = failure (transport, malformed manifest, non-404 board error) - the caller
469
487
  * keeps whatever board is currently mounted. */
470
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
471
490
  try {
472
491
  let raw: any
473
492
  if (DATA) raw = DATA.manifest
@@ -628,18 +647,22 @@ export const useStore = create<State>((set, get) => {
628
647
  }
629
648
  }
630
649
  }
631
- if ((!boardHash || needTidy) && nodes.length) {
650
+ // a note that landed while this board was closed has no room in the saved positions:
651
+ // a board with a recipe re-applies it (spec 18 - room is the layout's job)
652
+ const cramped = !!boardHash && !needTidy && !!(layout || sceneRows?.length || boardAuto) && notesCramped(nodes, manifest)
653
+ if ((!boardHash || needTidy || cramped) && nodes.length) {
632
654
  const placedAll = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn)
633
655
  for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
634
656
  }
635
657
  // dirty matches disk by construction - except when load-time pruning changed the
636
- // node set; callers see dirty:true and schedule the save that persists the prune
658
+ // node set (or a cramped note re-ran the recipe); callers see dirty:true and
659
+ // schedule the save that persists it
637
660
  // surface recipe problems at load (dry-run): materialized boards otherwise
638
661
  // never run tidy, so a broken agent-authored layout would fail silently
639
- if (layout && boardHash && !needTidy && nodes.length) {
662
+ if (layout && boardHash && !needTidy && !cramped && nodes.length) {
640
663
  tidy(tidyInput(nodes, manifest), layout, layoutWarn)
641
664
  }
642
- return { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, selection: [], dirty: prunedAtLoad }
665
+ return { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, selection: [], dirty: prunedAtLoad || cramped }
643
666
  } catch { return null }
644
667
  }
645
668
 
@@ -663,6 +686,7 @@ export const useStore = create<State>((set, get) => {
663
686
  if (next.dirty) scheduleSave() // load-time prune must reach the disk
664
687
  if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
665
688
  else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } }) // an sh:scenes that landed mid-fetch outranks the file we read
689
+ roomForNotes() // whichever way the notes arrived, they get their room
666
690
  return true
667
691
  },
668
692
 
@@ -697,6 +721,7 @@ export const useStore = create<State>((set, get) => {
697
721
  if (next.dirty) scheduleSave() // load-time prune must reach the disk
698
722
  if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
699
723
  else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } })
724
+ roomForNotes()
700
725
  },
701
726
 
702
727
  renameBoard(name, title, baseHash) {
@@ -749,6 +774,7 @@ export const useStore = create<State>((set, get) => {
749
774
  liveScenes = scenes
750
775
  const m = get().manifest
751
776
  if (m) set({ manifest: { ...m, scenes } }) // before the first manifest: kept for the boot's commit
777
+ roomForNotes() // a scene note arrived: room beside the scene's first frame
752
778
  },
753
779
 
754
780
  // The whole sidebar tree in one write: order, membership, the folders themselves. The
@@ -871,6 +897,7 @@ export const useStore = create<State>((set, get) => {
871
897
  ...(changed ? { dirty: true, baseLayout: nextBase } : {}),
872
898
  }))
873
899
  if (changed) scheduleSave()
900
+ roomForNotes() // a frame note file arrived: room in front of its frame
874
901
  },
875
902
 
876
903
  removeNode(key) {
@@ -973,6 +1000,11 @@ export const useStore = create<State>((set, get) => {
973
1000
  set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, h: H } : n)) }))
974
1001
  scheduleReflow()
975
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
+ },
976
1008
  setDeviceView(name) {
977
1009
  const vp = name ? CONFIG.viewports[name] : null
978
1010
  if (name && !vp) return
@@ -1003,7 +1035,7 @@ export const useStore = create<State>((set, get) => {
1003
1035
  return { deviceView: name, dirty: true, baseLayout, nodes }
1004
1036
  })
1005
1037
  if (name) get().runTidy() // restore must NOT tidy - it would destroy positions
1006
- else scheduleSave()
1038
+ else { scheduleSave(); roomForNotes() } // ...unless a note grew meanwhile and the restored rows stand under it
1007
1039
  },
1008
1040
  bumpRev(key) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, rev: (n.rev ?? 0) + 1 } : n)) })) },
1009
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
 
@@ -131,6 +131,10 @@ Report where the request came from: chat requests get chat replies; only comment
131
131
  width; gantt, journey and timeline are wide by nature - few items, or a content frame) -
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
+ 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 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.
134
138
  Keep it an aside, a screen's worth at most: specs, flows and mood boards stay content frames.
135
139
  Full guide: instructions/shape.md.
136
140
 
@@ -131,6 +131,10 @@ Report where the request came from: chat requests get chat replies; only comment
131
131
  width; gantt, journey and timeline are wide by nature - few items, or a content frame) -
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
+ 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 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.
134
138
  Keep it an aside, a screen's worth at most: specs, flows and mood boards stay content frames.
135
139
  Full guide: instructions/shape.md.
136
140
 
@@ -206,6 +206,15 @@ links open a new tab, images are `design/assets/` paths, raw HTML is inert, and
206
206
  ` ```mermaid ` fence renders hand-drawn, in the note's own yellow. Readers can comment on any
207
207
  element of a note exactly as on a frame element.
208
208
 
209
+ Placement is not your job: the canvas keeps the room. Every layout the shell composes - a
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 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.
217
+
209
218
  ````md
210
219
  ## Why the jobs list leads
211
220