@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 +26 -0
- package/README.md +1 -1
- package/docs/sticky-notes.md +7 -0
- package/package.json +1 -1
- package/src/client/shell/canvas/Sticky.tsx +16 -1
- package/src/client/shell/notes.ts +64 -0
- package/src/client/shell/store.ts +44 -12
- package/src/client/shell/tidy.ts +12 -5
- package/templates/AGENTS-embedded.md +4 -0
- package/templates/AGENTS-studio.md +4 -0
- package/templates/instructions/shape.md +9 -0
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`.
|
|
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.
|
package/docs/sticky-notes.md
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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)) })) },
|
package/src/client/shell/tidy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|