@marver-design/marver 0.18.0 → 0.19.1

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 (32) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +2 -1
  3. package/dist/{bake-jr38C_pX.mjs → bake-kaf5kGZ7.mjs} +1 -1
  4. package/dist/{build-ER_7T6Cw.mjs → build-7ed5H2vT.mjs} +40 -7
  5. package/dist/cli.mjs +3 -3
  6. package/dist/{daemon-CKhg0zuT.mjs → daemon-DbHvLQUL.mjs} +1 -1
  7. package/dist/{dev-BvLbY98O.mjs → dev-D3mP2x27.mjs} +6 -5
  8. package/dist/{init-BWbHqVng.mjs → init-BQYCS3EU.mjs} +1 -1
  9. package/dist/{manifest-DAnEL8_a.mjs → manifest-B01PSyDc.mjs} +33 -6
  10. package/dist/{plugin-Cai5C1WV.mjs → plugin-DI-7NAnx.mjs} +9 -9
  11. package/dist/{poster-CIuz_PwH.mjs → poster-DNh6N27C.mjs} +1 -1
  12. package/dist/{publish-bakes-D0LhbQQ3.mjs → publish-bakes-Dp-ZFk3d.mjs} +2 -2
  13. package/dist/{shot-iicees2e.mjs → shot-DMDvDbeP.mjs} +2 -2
  14. package/docs/sticky-notes.md +49 -0
  15. package/package.json +2 -1
  16. package/src/client/content/diagram.tsx +27 -1
  17. package/src/client/content/index.tsx +2 -2
  18. package/src/client/content/md.ts +48 -0
  19. package/src/client/shell/App.tsx +7 -63
  20. package/src/client/shell/Comments.tsx +75 -12
  21. package/src/client/shell/Play.tsx +6 -3
  22. package/src/client/shell/canvas/Canvas.tsx +10 -1
  23. package/src/client/shell/canvas/FrameNode.tsx +17 -0
  24. package/src/client/shell/canvas/Sticky.tsx +284 -0
  25. package/src/client/shell/goto.ts +72 -0
  26. package/src/client/shell/notes.ts +181 -0
  27. package/src/client/shell/store.ts +52 -25
  28. package/src/client/shell/styles.css +83 -0
  29. package/src/client/shell/tidy.ts +20 -2
  30. package/templates/AGENTS-embedded.md +18 -0
  31. package/templates/AGENTS-studio.md +18 -0
  32. package/templates/instructions/shape.md +69 -0
@@ -1,6 +1,7 @@
1
1
  import { create } from 'zustand'
2
2
  import { ROUTE, slideSize } from '../const.ts'
3
- import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
3
+ import { tidy, parseLayout, type BoardLayout, type TidyNode } from './tidy.ts'
4
+ import { noteReserve, notesCramped } from './notes.ts'
4
5
  import { stableNodeKey } from './keys.ts'
5
6
  // @ts-expect-error virtual module provided by the plugin
6
7
  import shConfig from 'virtual:sh-config'
@@ -50,8 +51,8 @@ export function hydrateBoardPolicy(boards: Record<string, { type?: string; open?
50
51
  for (const [k, v] of Object.entries(boards)) if (!BOARD_POLICY[k]) BOARD_POLICY[k] = v
51
52
  }
52
53
 
53
- export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean }
54
- export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string }[] }
54
+ export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean; note?: string }
55
+ export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string; note?: string }[] }
55
56
  export interface Node {
56
57
  key: string; frame: string; x: number; y: number; w: number; h: number
57
58
  /** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
@@ -207,6 +208,21 @@ export async function boardFrames(name: string): Promise<string[]> {
207
208
  }
208
209
 
209
210
  const HEADER = 28
211
+ /** What tidy sees: nodes with their scene, variant run, header-inclusive height, and the sticky
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). */
214
+ export function tidyInput(nodes: readonly Node[], manifest: Manifest | null): TidyNode[] {
215
+ const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
216
+ const sceneNote = (scene: string) => !!manifest?.scenes.find((s) => s.name === scene)?.note
217
+ return nodes.map((n) => {
218
+ const f = entryOf(n.frame)
219
+ const scene = f?.scene ?? ''
220
+ return {
221
+ 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
+ }
224
+ })
225
+ }
210
226
  let toastSeq = 0
211
227
  const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
212
228
 
@@ -395,17 +411,32 @@ export const useStore = create<State>((set, get) => {
395
411
  // One cancelable, BOARD-SCOPED reflow after content measurements settle.
396
412
  // The captured board name is the generation guard - a debounce surviving a board
397
413
  // switch fires into a name check and dies, never touching the new board.
414
+ // `onlyIf` (a note asking for room) is re-judged when the timer fires - a drag or a restore
415
+ // in the meantime may have settled it - and never downgrades an unconditional reflow pending.
398
416
  let reflowTimer: ReturnType<typeof setTimeout> | undefined
399
- const scheduleReflow = () => {
417
+ let reflowCheck: (() => boolean) | null = null
418
+ const scheduleReflow = (onlyIf?: () => boolean) => {
400
419
  const boardAt = get().board
420
+ reflowCheck = reflowTimer !== undefined && reflowCheck === null ? null : (onlyIf ?? null)
401
421
  clearTimeout(reflowTimer)
402
422
  reflowTimer = setTimeout(() => {
423
+ reflowTimer = undefined
424
+ const check = reflowCheck
425
+ reflowCheck = null
403
426
  const s = get()
404
427
  if (s.board !== boardAt) return
405
- if (s.gesture) { scheduleReflow(); return } // defer, never drop - retries after the drag
406
- if (s.layout || s.sceneRows?.length) s.runTidy()
428
+ if (s.gesture) { scheduleReflow(check ?? undefined); return } // defer, never drop - retries after the drag
429
+ if (check && !check()) return
430
+ if (composed(s)) s.runTidy()
407
431
  }, 400)
408
432
  }
433
+ /** Boards whose layout the shell owns: a recipe, scene rows, or the auto board. Room for a
434
+ * note is made here; a board the human placed by hand is never moved (spec 18, 0.19.1). */
435
+ const composed = (s: { layout: BoardLayout | null; sceneRows: string[][] | null; boardAuto: boolean }) => !!(s.layout || s.sceneRows?.length || s.boardAuto)
436
+ const cramped = () => { const s = get(); return !!s.manifest && notesCramped(s.nodes, s.manifest) }
437
+ /** A note may have landed with no room (a frame note via the manifest, a scene note via
438
+ * sh:scenes, either merged late by boot/switch): a composed board re-applies its layout. */
439
+ const roomForNotes = () => { if (composed(get()) && cramped()) scheduleReflow(cramped) }
409
440
 
410
441
  /** Theme resolution ladder: user pin > the frame's declared meta.theme > viewTheme. */
411
442
  const resolveTheme = (frame?: FrameEntry, user?: string) => user ?? frame?.theme ?? get().viewTheme
@@ -612,26 +643,22 @@ export const useStore = create<State>((set, get) => {
612
643
  }
613
644
  }
614
645
  }
615
- if ((!boardHash || needTidy) && nodes.length) {
616
- const entryOf = (id: string) => manifest.frames.find((f) => f.id === id)
617
- const placedAll = tidy(nodes.map((n) => {
618
- const f = entryOf(n.frame)
619
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
620
- }), effectiveLayout(layout, sceneRows), layoutWarn)
646
+ // a note that landed while this board was closed has no room in the saved positions:
647
+ // a board with a recipe re-applies it (spec 18 - room is the layout's job)
648
+ const cramped = !!boardHash && !needTidy && !!(layout || sceneRows?.length || boardAuto) && notesCramped(nodes, manifest)
649
+ if ((!boardHash || needTidy || cramped) && nodes.length) {
650
+ const placedAll = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn)
621
651
  for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
622
652
  }
623
653
  // dirty matches disk by construction - except when load-time pruning changed the
624
- // node set; callers see dirty:true and schedule the save that persists the prune
654
+ // node set (or a cramped note re-ran the recipe); callers see dirty:true and
655
+ // schedule the save that persists it
625
656
  // surface recipe problems at load (dry-run): materialized boards otherwise
626
657
  // never run tidy, so a broken agent-authored layout would fail silently
627
- if (layout && boardHash && !needTidy && nodes.length) {
628
- const entryOf2 = (id: string) => manifest.frames.find((f) => f.id === id)
629
- tidy(nodes.map((n) => {
630
- const f = entryOf2(n.frame)
631
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
632
- }), layout, layoutWarn)
658
+ if (layout && boardHash && !needTidy && !cramped && nodes.length) {
659
+ tidy(tidyInput(nodes, manifest), layout, layoutWarn)
633
660
  }
634
- return { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, selection: [], dirty: prunedAtLoad }
661
+ return { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, selection: [], dirty: prunedAtLoad || cramped }
635
662
  } catch { return null }
636
663
  }
637
664
 
@@ -655,6 +682,7 @@ export const useStore = create<State>((set, get) => {
655
682
  if (next.dirty) scheduleSave() // load-time prune must reach the disk
656
683
  if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
657
684
  else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } }) // an sh:scenes that landed mid-fetch outranks the file we read
685
+ roomForNotes() // whichever way the notes arrived, they get their room
658
686
  return true
659
687
  },
660
688
 
@@ -689,6 +717,7 @@ export const useStore = create<State>((set, get) => {
689
717
  if (next.dirty) scheduleSave() // load-time prune must reach the disk
690
718
  if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
691
719
  else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } })
720
+ roomForNotes()
692
721
  },
693
722
 
694
723
  renameBoard(name, title, baseHash) {
@@ -741,6 +770,7 @@ export const useStore = create<State>((set, get) => {
741
770
  liveScenes = scenes
742
771
  const m = get().manifest
743
772
  if (m) set({ manifest: { ...m, scenes } }) // before the first manifest: kept for the boot's commit
773
+ roomForNotes() // a scene note arrived: room beside the scene's first frame
744
774
  },
745
775
 
746
776
  // The whole sidebar tree in one write: order, membership, the folders themselves. The
@@ -863,6 +893,7 @@ export const useStore = create<State>((set, get) => {
863
893
  ...(changed ? { dirty: true, baseLayout: nextBase } : {}),
864
894
  }))
865
895
  if (changed) scheduleSave()
896
+ roomForNotes() // a frame note file arrived: room in front of its frame
866
897
  },
867
898
 
868
899
  removeNode(key) {
@@ -1203,11 +1234,7 @@ export const useStore = create<State>((set, get) => {
1203
1234
  },
1204
1235
  runTidy() {
1205
1236
  const { nodes, manifest, sceneRows, layout } = get()
1206
- const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
1207
- const placed = tidy(nodes.map((n) => {
1208
- const f = entryOf(n.frame)
1209
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
1210
- }), effectiveLayout(layout, sceneRows), layoutWarn)
1237
+ const placed = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn)
1211
1238
  set((s) => ({
1212
1239
  nodes: s.nodes.map((n) => {
1213
1240
  const p = placed.find((x) => x.key === n.key)
@@ -1022,6 +1022,8 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
1022
1022
  margin-left: calc(-1 * clamp(8px, calc(12px * var(--sh-inv, 1)), 36px)) }
1023
1023
  .cm-card.parked.dock-l.flank-shim { margin-left: calc(-1 * clamp(18px, calc(28px * var(--sh-inv, 1)), 68px)) }
1024
1024
  .cm-card.parked.dock-l.flank-badge { margin-left: calc(-1 * clamp(26px, calc(44px * var(--sh-inv, 1)), 108px)) }
1025
+ /* a sticky column (spec 18) is world-sized: the card clears it and its gutter */
1026
+ .cm-card.parked.dock-l.flank-note { margin-left: calc(-1 * (var(--sh-note-w, 260px) + 36px)) }
1025
1027
  /* PLAY: fixed beside the static device, viewport-clamped (position comes inline). Near-solid
1026
1028
  ground - the stage floats the card over arbitrary artwork on a dark room, glass is unreadable
1027
1029
  there. No transform: play has no zoom, and a leaked --sh-inv must never scale it. */
@@ -1265,3 +1267,84 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
1265
1267
  .sh-slides-strip .bar { width: 140px; height: 3px; border-radius: 999px; background: var(--glass-hover); overflow: hidden }
1266
1268
  .sh-slides-strip .bar i { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .25s ease }
1267
1269
  @media (max-width: 480px) { .sh-slides-strip .bar { width: 80px } }
1270
+
1271
+
1272
+ /* ---- sticky notes (spec 18): the yellow column left of a frame ------------------------------
1273
+ World-sized (it scales with the canvas, like the artwork); the fold's hit target keeps a screen
1274
+ minimum through --sh-inv. Yellow in both shell themes - a sticky is yellow. */
1275
+ .sh-node { --note-bg: #fff3a3; --note-bg-2: #ffeb85; --note-ink: #2b2500; --note-dim: rgba(43, 37, 0, .62);
1276
+ --note-line: rgba(107, 90, 0, .28); --note-link: #7a4d00; --note-fold: #e9cf5e; --note-fold-2: #c9ae3c }
1277
+ /* dark canvas: a deeper mustard so the paper reads as paper, not as a highlight; ink darker for contrast */
1278
+ .sh-node[data-theme="dark"] { --note-bg: #e6c94f; --note-bg-2: #d6b73c; --note-ink: #1f1a00; --note-dim: rgba(31, 26, 0, .68);
1279
+ --note-line: rgba(70, 56, 0, .35); --note-link: #5c3a00; --note-fold: #c9aa33; --note-fold-2: #8f7518 }
1280
+ .sh-notes { position: absolute; right: 100%; top: 0; margin-right: 24px;
1281
+ display: flex; flex-direction: column; align-items: flex-end; gap: 12px; pointer-events: none }
1282
+ .sh-notes.below-vbadge { top: 72px }
1283
+ .sh-notes > * { pointer-events: auto }
1284
+ .sh-sticky { position: relative; box-sizing: border-box; background: var(--note-bg); color: var(--note-ink);
1285
+ border-radius: 2px 10px 2px 2px; padding: 14px 16px 12px;
1286
+ box-shadow: 0 1px 2px rgba(60, 45, 0, .18), 0 6px 18px -6px rgba(60, 45, 0, .35);
1287
+ font: 400 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1288
+ transform-origin: top right; opacity: 1; visibility: visible;
1289
+ transition: transform .18s cubic-bezier(.2, .7, .3, 1), opacity .16s ease, visibility 0s linear 0s;
1290
+ user-select: text; -webkit-user-select: text; cursor: default }
1291
+ .sh-sticky[data-sticky="scene"] { padding: 18px 20px 16px; font-size: 14px }
1292
+ .sh-notes.off .sh-sticky { transform: scale(.04); opacity: 0; visibility: hidden;
1293
+ transition: transform .18s cubic-bezier(.4, 0, .8, .4), opacity .14s ease .04s, visibility 0s linear .18s; pointer-events: none }
1294
+ /* the dog-ear: on the column it is the top sticky's folded corner; folded, it is the tab that stays */
1295
+ .sh-notes-fold { position: absolute; top: 0; right: 0; z-index: 2; padding: 0; border: 0; cursor: pointer;
1296
+ width: clamp(22px, calc(14px * var(--sh-inv, 1)), 72px); height: clamp(22px, calc(14px * var(--sh-inv, 1)), 72px);
1297
+ border-radius: 0 10px 0 4px;
1298
+ background: linear-gradient(225deg, transparent 50%, var(--note-fold) 50%, var(--note-fold-2) 100%);
1299
+ transition: background .18s ease, border-radius .18s ease, box-shadow .18s ease, transform .18s ease }
1300
+ .sh-notes-fold:hover { filter: brightness(.96) }
1301
+ .sh-notes-fold:focus-visible { outline: 2px solid var(--accent, #2f6df6); outline-offset: 2px }
1302
+ .sh-notes.off .sh-notes-fold { border-radius: 3px 3px 3px 8px; background: linear-gradient(160deg, var(--note-bg) 0%, var(--note-bg-2) 100%);
1303
+ box-shadow: 0 1px 2px rgba(60, 45, 0, .25), 0 3px 8px -2px rgba(60, 45, 0, .35) }
1304
+ .sh-notes.off .sh-notes-fold::after { content: ""; position: absolute; right: 0; top: 0; width: 38%; height: 38%;
1305
+ border-radius: 0 3px 0 3px; background: linear-gradient(225deg, transparent 50%, var(--note-fold-2) 50%) }
1306
+ @media (prefers-reduced-motion: reduce) { .sh-sticky, .sh-notes-fold { transition: none } }
1307
+ /* comment mode: hover names the element the click would pick; the lock is the shell's own outline */
1308
+ body.sh-commenting .sh-sticky-body :is(p, li, h1, h2, h3, h4, blockquote, pre, img, table, .sh-sticky-diagram):hover { outline: 1.5px solid var(--accent, #2f6df6); outline-offset: 2px; border-radius: 3px }
1309
+ body.sh-commenting .sh-sticky-body { cursor: crosshair }
1310
+ .sh-sticky-body [data-sh-lock] { outline: 2px solid hsl(var(--cm-h, 48) 80% 42%); outline-offset: 3px; border-radius: 3px;
1311
+ box-shadow: 0 0 0 5px hsl(var(--cm-h, 48) 90% 60% / .28) }
1312
+ /* prose: the Md block's vocabulary, sized for an aside */
1313
+ .sh-sticky-body > :first-child { margin-top: 0 }
1314
+ .sh-sticky-body > :last-child { margin-bottom: 0 }
1315
+ .sh-sticky-body h1, .sh-sticky-body h2, .sh-sticky-body h3, .sh-sticky-body h4 { margin: 0 0 6px; line-height: 1.25; letter-spacing: -0.01em; text-wrap: balance }
1316
+ .sh-sticky-body h1 { font-size: 1.28em; font-weight: 700 }
1317
+ .sh-sticky-body h2 { font-size: 1.14em; font-weight: 650 }
1318
+ .sh-sticky-body h3, .sh-sticky-body h4 { font-size: 1em; font-weight: 650 }
1319
+ .sh-sticky-body p, .sh-sticky-body ul, .sh-sticky-body ol, .sh-sticky-body blockquote, .sh-sticky-body pre, .sh-sticky-body table { margin: 0 0 8px }
1320
+ .sh-sticky-body ul, .sh-sticky-body ol { padding-left: 18px }
1321
+ .sh-sticky-body li { margin: 2px 0 }
1322
+ .sh-sticky-body li > ul, .sh-sticky-body li > ol { margin-bottom: 0 }
1323
+ .sh-sticky-body blockquote { padding: 2px 0 2px 10px; border-left: 3px solid var(--note-line); color: var(--note-dim) }
1324
+ .sh-sticky-body a { color: var(--note-link); text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(122, 77, 0, .45) }
1325
+ .sh-sticky-body a:hover { text-decoration-color: currentColor }
1326
+ .sh-sticky-body a[data-goto]::after { content: "\2197"; font-size: .8em; margin-left: 2px; text-decoration: none; display: inline-block }
1327
+ .sh-sticky-body code { font: 500 .9em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: rgba(43, 37, 0, .07); padding: 1px 4px; border-radius: 3px }
1328
+ .sh-sticky-body pre { padding: 8px 10px; border-radius: 6px; background: rgba(43, 37, 0, .07); overflow-x: auto; white-space: pre }
1329
+ .sh-sticky-body pre code { background: none; padding: 0; font-weight: 400 }
1330
+ .sh-sticky-body pre.err { background: rgba(190, 40, 20, .1); color: #7a1f0f }
1331
+ .sh-sticky-body hr { border: 0; border-top: 1px solid var(--note-line); margin: 10px 0 }
1332
+ .sh-sticky-body img { max-width: 100%; border-radius: 4px; display: block }
1333
+ .sh-sticky-body strong { font-weight: 650 }
1334
+ .sh-sticky-body table { border-collapse: collapse; font-size: .92em; width: 100%; table-layout: auto }
1335
+ .sh-sticky-body td, .sh-sticky-body th { overflow-wrap: anywhere }
1336
+ .sh-sticky-body th, .sh-sticky-body td { border: 1px solid var(--note-line); padding: 3px 6px; text-align: left; vertical-align: top }
1337
+ .sh-sticky-body th { font-weight: 650; background: rgba(43, 37, 0, .05) }
1338
+ .sh-sticky-body input[type="checkbox"] { margin: 0 6px 0 0; vertical-align: -1px }
1339
+ .sh-sticky-body .mv-md-noimg { color: var(--note-dim); font-style: italic }
1340
+ .sh-sticky-body .mv-c-blue { color: #0070d6 } .sh-sticky-body .mv-c-orange { color: #c9640a } .sh-sticky-body .mv-c-purple { color: #9a23ad }
1341
+ .sh-sticky-body .mv-c-green { color: #1a8a3f } .sh-sticky-body .mv-c-red { color: #c72a22 } .sh-sticky-body .mv-c-gray { color: #6b7480 }
1342
+ /* the hand-drawn diagram: the fence's place, the paper's colors, scaled to the column */
1343
+ .sh-sticky-diagram { margin: 4px 0 10px; overflow: visible; display: flex; justify-content: center }
1344
+ .sh-sticky-diagram svg { display: block; max-width: 100%; height: auto; margin: 0 auto;
1345
+ /* the canvas scales the world with a CSS transform, and Chrome lays SVG text out ONCE for the
1346
+ scale it was inserted at (a diagram born at 13 % zoom keeps 13 %-zoom glyph positions at
1347
+ 140 %: labels drift, some vanish). geometricPrecision lays glyphs out scale-free - measured,
1348
+ research/notes/diagprobe5.ts */
1349
+ text-rendering: geometricPrecision }
1350
+ .sh-sticky-diagram svg text, .sh-sticky-diagram svg tspan { text-rendering: geometricPrecision }
@@ -1,4 +1,10 @@
1
- export interface TidyNode { key: string; frame: string; scene: string; group?: string; variant?: string; w: number; h: number }
1
+ export interface TidyNode {
2
+ key: string; frame: string; scene: string; group?: string; variant?: string; w: number; h: number
3
+ /** Sticky notes (spec 18), world px INCLUDING the gutter: reserved in front of this node
4
+ * (its own note), and in front of the scene's first placed node (the scene note - carried on
5
+ * every member, applied once). A column holds both, so the wider wins, never the sum. */
6
+ noteW?: number; sceneNoteW?: number
7
+ }
2
8
  export interface Placed { key: string; x: number; y: number }
3
9
 
4
10
  // Lane-flow grammar: one shape at both scopes. A scope is rows XOR columns
@@ -43,7 +49,7 @@ const box = (id: string, parts: Array<{ key: string; dx: number; dy: number; w:
43
49
  const runBox = (id: string, run: TidyNode[]): Box => {
44
50
  const parts: Array<{ key: string; dx: number; dy: number; w: number; h: number }> = []
45
51
  let dx = 0
46
- for (const n of run) { parts.push({ key: n.key, dx, dy: 0, w: n.w, h: n.h }); dx += n.w + frameGapX(n.w) }
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) }
47
53
  return box(id, parts)
48
54
  }
49
55
 
@@ -264,6 +270,18 @@ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () =>
264
270
  for (const scene of scenes) {
265
271
  const members = nodes.filter((n) => n.scene === scene)
266
272
  const m = layoutScene(scene, members, layout?.scenes?.[scene], warn)
273
+ // the scene note sits in front of the scene's first node in reading order (the host the
274
+ // shell picks): widen the scene box on the left by what that node's own note does not cover
275
+ const sceneNoteW = Math.max(0, ...members.map((n) => n.sceneNoteW ?? 0))
276
+ if (sceneNoteW) {
277
+ const placed = members.filter((n) => m.has(n.key))
278
+ const first = placed.reduce<TidyNode | null>((best, n) => {
279
+ const p = m.get(n.key)!, b = best && m.get(best.key)!
280
+ return !b || p.y < b.y || (p.y === b.y && p.x < b.x) ? n : best
281
+ }, null)
282
+ const extra = sceneNoteW - (first?.noteW ?? 0)
283
+ if (extra > 0) for (const [k, p] of m) m.set(k, { x: p.x + extra, y: p.y })
284
+ }
267
285
  sceneMaps.set(scene, m)
268
286
  const parts = members
269
287
  .filter((n) => m.has(n.key))
@@ -118,6 +118,24 @@ Report where the request came from: chat requests get chat replies; only comment
118
118
  - CONTENT frames (specs, mermaid diagrams, mood boards) are ordinary tsx frames built
119
119
  from the block primitives in '@marver-design/marver/content' - import them directly
120
120
  in the frame file and declare meta.intent. Full guide: instructions/shape.md.
121
+ - STICKY NOTES: the aside beside a frame. One markdown file, nothing to declare:
122
+ `design/scenes/<scene>/<frame>.note.md` beside the frame file (tsx, jsx or html),
123
+ `design/scenes/<scene>/_note.md` for the scene (it shows beside the scene's first frame),
124
+ `design/components/<name>.note.md` for a component. It renders as a yellow note left of the
125
+ frame on every canvas, dev and published. Write one when a reader needs what the screen
126
+ cannot say: what the frame is for, how two variations differ, how a mechanism works, an
127
+ open question. Markdown (headings, lists, tables, emphasis, code), `[text](goto:scene/frame)`
128
+ links that jump to a frame, images from design/assets/, and ```mermaid fences drawn
129
+ hand-sketched in the note's own yellow - plain mermaid, any family, no init/theme/style
130
+ lines, no URLs (flowchart, sequence, state, class, ER, pie, mindmap read well at note
131
+ width; gantt, journey and timeline are wide by nature - few items, or a content frame) -
132
+ raw HTML is inert. Readers comment on a note's text like on a frame element,
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 re-applies when a note lands - never
136
+ move frames or pad a lane for a note, just write the file.
137
+ Keep it an aside, a screen's worth at most: specs, flows and mood boards stay content frames.
138
+ Full guide: instructions/shape.md.
121
139
 
122
140
  ## Structure ladder (embedded mode: screens live in src/)
123
141
  1. First pass: write the whole page inline in the frame file. Diverge fast.
@@ -118,6 +118,24 @@ Report where the request came from: chat requests get chat replies; only comment
118
118
  - CONTENT frames (specs, mermaid diagrams, mood boards) are ordinary tsx frames built
119
119
  from the block primitives in '@marver-design/marver/content' - import them directly
120
120
  in the frame file and declare meta.intent. Full guide: instructions/shape.md.
121
+ - STICKY NOTES: the aside beside a frame. One markdown file, nothing to declare:
122
+ `design/scenes/<scene>/<frame>.note.md` beside the frame file (tsx, jsx or html),
123
+ `design/scenes/<scene>/_note.md` for the scene (it shows beside the scene's first frame),
124
+ `design/components/<name>.note.md` for a component. It renders as a yellow note left of the
125
+ frame on every canvas, dev and published. Write one when a reader needs what the screen
126
+ cannot say: what the frame is for, how two variations differ, how a mechanism works, an
127
+ open question. Markdown (headings, lists, tables, emphasis, code), `[text](goto:scene/frame)`
128
+ links that jump to a frame, images from design/assets/, and ```mermaid fences drawn
129
+ hand-sketched in the note's own yellow - plain mermaid, any family, no init/theme/style
130
+ lines, no URLs (flowchart, sequence, state, class, ER, pie, mindmap read well at note
131
+ width; gantt, journey and timeline are wide by nature - few items, or a content frame) -
132
+ raw HTML is inert. Readers comment on a note's text like on a frame element,
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 re-applies when a note lands - never
136
+ move frames or pad a lane for a note, just write the file.
137
+ Keep it an aside, a screen's worth at most: specs, flows and mood boards stay content frames.
138
+ Full guide: instructions/shape.md.
121
139
 
122
140
  ## Structure ladder
123
141
  1. First pass: write the whole page inline in the frame file. Diverge fast.
@@ -187,6 +187,75 @@ of described imagery every time (the full asset rules: instructions/craft.md,
187
187
  has the shell-less way) and adjust the per-row count until it reads well. "The code
188
188
  says they're the same width" proves nothing.
189
189
 
190
+ ## Sticky notes - the aside beside a frame
191
+
192
+ A sticky note is one markdown file beside the thing it explains. It renders as a yellow note
193
+ left of the frame on the canvas, in dev and in every published or shared canvas. Nothing to
194
+ declare, no board node, no imports, no iframe:
195
+
196
+ | For | Write | Shows |
197
+ |---|---|---|
198
+ | a frame `checkout/cart` (`cart.tsx`, `cart.jsx` or `cart.html`) | `design/scenes/checkout/cart.note.md` | left of that frame, 260 wide |
199
+ | a scene `checkout` | `design/scenes/checkout/_note.md` | left of the scene's first frame on the board, 380 wide |
200
+ | a component frame | `design/components/<name>.note.md` | as a frame note |
201
+
202
+ Write one when a reader needs something the screen cannot say: what the frame is for, what
203
+ differs between two variations, how a mechanism works, an open question. Markdown, the Md
204
+ block's rules: `[text](goto:scene/frame)` links jump to a frame on the canvas, `http(s)`
205
+ links open a new tab, images are `design/assets/` paths, raw HTML is inert, and a
206
+ ` ```mermaid ` fence renders hand-drawn, in the note's own yellow. Readers can comment on any
207
+ element of a note exactly as on a frame element.
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 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.
215
+
216
+ ````md
217
+ ## Why the jobs list leads
218
+
219
+ Drivers ask "where am I going first" - the list beats the map here.
220
+ Compare with the [empty day](goto:app-home/today-empty): same header, one call to action.
221
+
222
+ | Reason | Effect |
223
+ |---|---|
224
+ | Too far | lane weight down |
225
+
226
+ ```mermaid
227
+ flowchart LR
228
+ Login --> Today --> Jobs
229
+ ```
230
+ ````
231
+
232
+ ### Diagrams in a note
233
+
234
+ A ```mermaid fence in a note renders hand-sketched on the yellow paper: rough boxes, hatched
235
+ fills, handwriting labels, one ink. You write plain mermaid and nothing else - no `%%{init}%%`,
236
+ no theme, no colours, no `style` lines (the note has one look and applies it to every family),
237
+ no URLs or images in the source (refused). Every family works: flowchart, sequence, state,
238
+ class, ER, pie, mindmap, timeline, gantt, journey, quadrant, git graph, block.
239
+
240
+ Fit the note: flowchart (`TD` for a tall note, `LR` for three or four steps), sequence, state,
241
+ class, ER, pie and mindmap read well at 260 or 380 wide. Gantt, journey, timeline, quadrant and
242
+ git graphs are drawn at the note's width too but are wide by nature - keep them to a handful of
243
+ items, or give them a content frame. Five to eight nodes is the sweet spot; short labels
244
+ (two or three words); one diagram per note, above or below the prose it explains.
245
+
246
+ ```mermaid
247
+ sequenceDiagram
248
+ participant D as Driver
249
+ participant G as Gate
250
+ D->>G: scan gate code
251
+ G-->>D: bay + lot map
252
+ ```
253
+
254
+ Keep it an aside: a screen's worth of reading at most. The spec, the flow, the mood board stay
255
+ content frames - a note explains, it does not document. Every viewer can fold a note to its
256
+ corner tab (the choice is theirs, never saved to the board) and hide all of them with `N`.
257
+ Edits to a note file land on the canvas as you save, without reloading the frame.
258
+
190
259
  ## When Shape ends
191
260
 
192
261
  The board holds the agreed flow, spec, and direction. Wireframe picks up from