@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
@@ -13,6 +13,8 @@ import { humanize as slugLabel, readTitle } from '../../shared/board-tree.ts'
13
13
  import { CommentsController, revealThread } from './Comments.tsx'
14
14
  import { poweredByUrl } from '../../shared/utm.ts'
15
15
  import { avatarFallback, useComments } from './comments-store.ts'
16
+ import { goTo } from './goto.ts'
17
+ import { useNotes } from './notes.ts'
16
18
  import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
17
19
 
18
20
  const commentsStore = () => useComments.getState()
@@ -599,17 +601,9 @@ export function App() {
599
601
  if (c.active) c.setActive(null)
600
602
  if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
601
603
  } else if (data.type === 'sh:go') {
602
- const target = String(data.target ?? '')
603
- const carry = s.interact === nodeKey // a goto from inside an interacting frame
604
- // CARRIES interact mode to the target - walking a flow must not eject you to
605
- // design mode at every hop (and must survive a board switch)
606
- const existing = s.nodes.find((n) => n.frame === target && !n.missing)
607
- if (existing) {
608
- gotoSeq++ // a local goto supersedes any cross-board one in flight
609
- select(existing.key)
610
- if (carry) setInteract(existing.key)
611
- setTimeout(() => canvasCtl.fitNode(existing.key), 50)
612
- } else void gotoAcrossBoards(target, carry)
604
+ // a goto from inside an interacting frame CARRIES interact mode to the target - walking
605
+ // a flow must not eject you to design mode at every hop (and must survive a board switch)
606
+ goTo(String(data.target ?? ''), s.interact === nodeKey)
613
607
  } else if (data.type === 'sh:wheel') {
614
608
  // B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
615
609
  // interact target or play (the app scrolls itself - the parent stays authoritative).
@@ -681,6 +675,8 @@ export function App() {
681
675
  toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
682
676
  }
683
677
  if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
678
+ // sticky notes (spec 18): any note visible -> hide all; none -> show all
679
+ if (e.key === 'n' && !e.shiftKey) { const visible = document.querySelectorAll('.sh-notes:not(.off)').length > 0; useNotes.getState().toggleAll(visible); toast(visible ? 'notes hidden' : 'notes shown'); return }
684
680
  // Shift+L = laser comment: the laser-sharp lighting on the element a comment tags
685
681
  // (pick hover, compose lock, open-thread highlight). Pins and cards stay - this
686
682
  // only dims the lighting inside the artwork.
@@ -1017,58 +1013,6 @@ function JamAvatar({ author }: { author: { name?: string; avatar?: string; id?:
1017
1013
  : <span className="sh-jam-ava init" style={{ background: `hsl(${hue} 55% 45%)` }}>{initials}</span>
1018
1014
  }
1019
1015
 
1020
- /** A data-goto whose target frame is not on the current board follows the frame HOME:
1021
- * the first curated board (switcher rank) that pins it is switched to and the frame
1022
- * focused there - a link is navigation, and navigation never edits a board. Only a
1023
- * frame NO board pins spawns onto the current board (the original prototype behavior
1024
- * for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
1025
- * bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
1026
- let gotoSeq = 0
1027
- async function gotoAcrossBoards(target: string, carry: boolean) {
1028
- const s = useStore.getState()
1029
- // an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
1030
- // must not send us on a trip that ends in a silent timeout
1031
- if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
1032
- const seq = ++gotoSeq
1033
- let home: string | null = null
1034
- try {
1035
- const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
1036
- for (const name of names) {
1037
- if ((await boardFrames(name)).includes(target)) { home = name; break }
1038
- }
1039
- } catch {
1040
- // a transport failure is NOT proof the frame is unpinned - spawning here would
1041
- // recreate the board mutation this function exists to prevent
1042
- return useStore.getState().toast(`goto: could not read the boards - try again`)
1043
- }
1044
- if (seq !== gotoSeq) return // superseded by newer navigation
1045
- if (!home) {
1046
- // no curated board pins it - the original prototype behavior: spawn beside you
1047
- const st = useStore.getState()
1048
- const node = st.spawn(target)
1049
- if (!node) return st.toast(`unknown goto target "${target}"`)
1050
- st.select(node.key)
1051
- if (carry) st.setInteract(node.key)
1052
- setTimeout(() => canvasCtl.fitNode(node.key), 50)
1053
- return
1054
- }
1055
- await s.switchBoard(home)
1056
- for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
1057
- if (seq !== gotoSeq) return
1058
- const st = useStore.getState()
1059
- if (st.board === home) { // a cancelled/failed switch must not select here
1060
- const node = st.nodes.find((n) => n.frame === target && !n.missing)
1061
- if (node) {
1062
- st.select(node.key)
1063
- if (carry) st.setInteract(node.key)
1064
- setTimeout(() => canvasCtl.fitNode(node.key), 50)
1065
- return
1066
- }
1067
- }
1068
- await new Promise((r) => setTimeout(r, 250))
1069
- }
1070
- }
1071
-
1072
1016
  /** View from a notification: threads are frame-scoped, so first try to reveal RIGHT HERE
1073
1017
  * (the current board may show the thread's frame); only when it doesn't, switch to the
1074
1018
  * note's origin board and retry while its comments load. Dismiss only on success: a
@@ -9,6 +9,7 @@ import { createPortal } from 'react-dom'
9
9
  import { avatarFallback, useComments } from './comments-store.ts'
10
10
  import { useStore, type Node } from './store.ts'
11
11
  import { threadHostKey } from './keys.ts'
12
+ import { isNoteAnchor, resolveNoteAnchor, useNotes } from './notes.ts'
12
13
  import { canvasCtl } from './canvas/ctl.ts'
13
14
  import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
14
15
  import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
@@ -138,11 +139,15 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
138
139
  // a remote resolve (thread leaves `open` while `active` is unchanged) still clears the lock
139
140
  const activeShown = !!active && open.some((t) => t.id === active)
140
141
 
142
+ // anchors split by where they live: in the frame document (inspect.js resolves them over
143
+ // postMessage) or on a sticky note (spec 18: shell DOM, resolved right here)
144
+ const inFrame = anchored.filter((t) => !isNoteAnchor(t.anchor))
145
+ const onNote = anchored.filter((t) => isNoteAnchor(t.anchor))
141
146
  // resolve anchors against the live frame whenever threads or the document change
142
147
  useEffect(() => {
143
148
  const win = iframe.current?.contentWindow
144
- if (!win || node.status !== 'ready' || !anchored.length) return
145
- const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: anchored.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
149
+ if (!win || node.status !== 'ready' || !inFrame.length) return
150
+ const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: inFrame.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
146
151
  const onMsg = (e: MessageEvent) => {
147
152
  if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects') return
148
153
  const next: typeof rects = {}
@@ -153,7 +158,44 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
153
158
  ask()
154
159
  const iv = setInterval(ask, 4000) // re-renders, scroll, hot reloads - cheap to re-ask
155
160
  return () => { clearInterval(iv); window.removeEventListener('message', onMsg) }
156
- }, [node.status, anchored.map((t) => t.id).join(','), iframe])
161
+ }, [node.status, inFrame.map((t) => t.id).join(','), iframe])
162
+ // note anchors: their own map, measured in the shell, in node-body coordinates (negative x -
163
+ // the note is left of the frame). The composing draft is measured too (key 'draft'), so it
164
+ // follows a fold like a saved pin. A folded column parks pins on the fold; a note whose file
165
+ // is gone, or whose element the markdown no longer has, parks like an orphan. Re-measured on
166
+ // every fold/unfold (twice: at once and past the transition) and on the same 4 s beat.
167
+ const [noteRects, setNoteRects] = useState<typeof rects>({})
168
+ const notesState = useNotes()
169
+ const draftOnNote = draft?.nodeKey === node.key && isNoteAnchor(draft.anchor) ? draft.anchor : null
170
+ useEffect(() => {
171
+ if (!onNote.length && !draftOnNote) { setNoteRects((r) => (Object.keys(r).length ? {} : r)); return }
172
+ const measure = () => {
173
+ const host = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`) as HTMLElement | null
174
+ const origin = host?.querySelector('.cm-layer')?.getBoundingClientRect()
175
+ if (!host || !origin) return
176
+ const s = origin.width ? origin.width / host.offsetWidth : 1
177
+ const toBody = (r: DOMRect) => ({ x: (r.left - origin.left) / s, y: (r.top - origin.top) / s, w: r.width / s, h: r.height / s })
178
+ const column = host.querySelector('.sh-notes')
179
+ const fold = column?.querySelector('.sh-notes-fold')
180
+ const place = (a: any) => {
181
+ const body = column?.querySelector(`[data-sticky="${a.el.note}"] .sh-sticky-body`)
182
+ if (!body) return null
183
+ if (column!.classList.contains('off')) return fold ? toBody(fold.getBoundingClientRect()) : null
184
+ const el = resolveNoteAnchor(a, body)
185
+ return el ? toBody(el.getBoundingClientRect()) : null
186
+ }
187
+ const next: typeof rects = {}
188
+ for (const t of onNote) next[t.id] = place(t.anchor)
189
+ if (draftOnNote) next.draft = place(draftOnNote)
190
+ setNoteRects(next)
191
+ }
192
+ measure()
193
+ const late = setTimeout(measure, 260)
194
+ const iv = setInterval(measure, 4000)
195
+ return () => { clearTimeout(late); clearInterval(iv) }
196
+ }, [onNote.map((t) => t.id).join(','), draftOnNote, notesState.all, notesState.hidden.join(','), node.key, node.w, node.h])
197
+ /** This node's sticky column when it is showing: the docked card must clear it (world px). */
198
+ const noteColumn = (): HTMLElement | null => document.querySelector(`[data-node-notes="${CSS.escape(node.key)}"]:not(.off)`)
157
199
 
158
200
  // #4/#5: drive the persistent element highlight into this frame - the composing draft on
159
201
  // this node (lock on pick), or the open thread anchored here (highlight on open). null
@@ -168,7 +210,15 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
168
210
  const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null
169
211
  const activeHere = active ? open.find((t) => t.id === active && (t.anchor as any)?.el) : undefined
170
212
  const anchor = show && showAnchor ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
171
- win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: anchor ?? null }, location.origin)
213
+ win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: isNoteAnchor(anchor) ? null : anchor ?? null }, location.origin)
214
+ // a note anchor lights its element in the shell the way the frame lights its own
215
+ const host = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)
216
+ for (const el of host?.querySelectorAll('[data-sh-lock]') ?? []) el.removeAttribute('data-sh-lock')
217
+ if (isNoteAnchor(anchor)) {
218
+ const body = host?.querySelector(`[data-sticky="${anchor.el.note}"] .sh-sticky-body`)
219
+ const el = body && resolveNoteAnchor(anchor, body)
220
+ if (el) el.setAttribute('data-sh-lock', '')
221
+ }
172
222
  // active/draft/show/showAnchor/activeShown are the triggers; `open` is read fresh from the closure
173
223
  }, [active, draft, show, showAnchor, activeShown, node.status, node.key, frameId, iframe])
174
224
 
@@ -181,6 +231,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
181
231
  // badge is widest, the working shimmer next. Reactive, so a job starting mid-read adjusts.
182
232
  const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
183
233
  const flankShim = useStore((s) => s.working.includes(frameId))
234
+ // (a sticky column is read from the DOM at render: it is per viewer and per host node)
184
235
  const drafting = draft?.nodeKey === node.key
185
236
  const alertIds = useMentionAlerts()
186
237
  if (!show || (!open.length && !drafting)) return null
@@ -198,7 +249,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
198
249
 
199
250
  const pinPos = (t: Thread) => {
200
251
  const a = t.anchor as any
201
- const r = rects[t.id]
252
+ const r = isNoteAnchor(a) ? noteRects[t.id] : rects[t.id]
202
253
  if (a?.el && r) return { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h, orphan: false }
203
254
  if (a?.el && r === null) return { x: node.w - 16, y: 16, orphan: true } // orphan parks top-right
204
255
  const p = a?.pos // frame-level: stored fraction of the frame
@@ -214,15 +265,23 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
214
265
  const W = 320, GAP = 30
215
266
  const rect = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)?.getBoundingClientRect()
216
267
  if (!rect) return 'r'
268
+ // a showing sticky column sits in the left gutter in WORLD px (its width + 36, the CSS
269
+ // margin): the card lands beyond it, so both room and collision use that screen offset
270
+ const scale = rect.width / Math.max(1, node.w)
271
+ const col = noteColumn()
272
+ const gutter = col ? col.getBoundingClientRect().width + 36 * scale : 0
217
273
  const occupied = (s: 'l' | 'r') => {
218
- const rx = s === 'r' ? rect.right + 10 : rect.left - 10 - W
219
- return [...document.querySelectorAll('.sh-node')].some((n) => {
220
- if (n.getAttribute('data-node') === node.key) return false
274
+ const rx = s === 'r' ? rect.right + 10 : rect.left - gutter - 10 - W
275
+ return [...document.querySelectorAll('.sh-node, .sh-notes:not(.off)')].some((n) => {
276
+ if (n.getAttribute('data-node') === node.key || n.getAttribute('data-node-notes') === node.key) return false
221
277
  const r = n.getBoundingClientRect()
222
278
  return r.left < rx + W && r.right > rx && r.top < rect.bottom && r.bottom > rect.top
223
279
  })
224
280
  }
225
- const room = (s: 'l' | 'r') => (s === 'r' ? window.innerWidth - rect.right : rect.left) >= W + GAP
281
+ // the left room starts at the sidebar's edge, not the window's: a card parked under the
282
+ // panel is a card nobody can read (a note's gutter makes that reach likelier)
283
+ const panelRight = document.querySelector('.sh-panel:not(.closed)')?.getBoundingClientRect().right ?? 0
284
+ const room = (s: 'l' | 'r') => (s === 'r' ? window.innerWidth - rect.right : rect.left - gutter - panelRight) >= W + GAP
226
285
  const prefer: ('l' | 'r')[] = pinPos(activeThread2).x < node.w / 2 ? ['l', 'r'] : ['r', 'l']
227
286
  return prefer.find((s) => room(s) && !occupied(s)) ?? prefer.find(room) ?? prefer[0]
228
287
  }
@@ -247,9 +306,13 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
247
306
  })}
248
307
  {activeThread2 && (
249
308
  <ThreadCard key={active} thread={activeThread2} at={pinPos(activeThread2)} bounds={{ w: node.w, h: node.h }} nodeKey={node.key} side={cardSide}
250
- flank={cardSide === 'l' ? (flankBadge ? 'badge' : flankShim ? 'shim' : null) : null} />
309
+ flank={cardSide === 'l' ? (noteColumn() ? 'note' : flankBadge ? 'badge' : flankShim ? 'shim' : null) : null} />
251
310
  )}
252
- {draft?.nodeKey === node.key && <DraftComposer at={{ x: ((draft.anchor as any)?.rect?.x ?? 0) + ((draft.anchor as any)?.pos?.fx ?? 0.5) * ((draft.anchor as any)?.rect?.w ?? 0), y: ((draft.anchor as any)?.rect?.y ?? 0) + ((draft.anchor as any)?.pos?.fy ?? 0.5) * ((draft.anchor as any)?.rect?.h ?? 0) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />}
311
+ {draft?.nodeKey === node.key && (() => {
312
+ const a = draft.anchor as any
313
+ const r = (draftOnNote && noteRects.draft) || a?.rect
314
+ return <DraftComposer at={{ x: (r?.x ?? 0) + (a?.pos?.fx ?? 0.5) * (r?.w ?? 0), y: (r?.y ?? 0) + (a?.pos?.fy ?? 0.5) * (r?.h ?? 0) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />
315
+ })()}
253
316
  </>
254
317
  )
255
318
  }
@@ -538,7 +601,7 @@ function ComposeAvatar() {
538
601
  return <EditableAvatar author={me ?? undefined} size={24} />
539
602
  }
540
603
 
541
- export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, stage }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number }; nodeKey?: string; side?: 'l' | 'r'; flank?: 'badge' | 'shim' | null; stage?: boolean }) {
604
+ export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, stage }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number }; nodeKey?: string; side?: 'l' | 'r'; flank?: 'badge' | 'shim' | 'note' | null; stage?: boolean }) {
542
605
  const { resolve, setActive } = useComments.getState()
543
606
  const me = useComments((s) => s.me)
544
607
  const local = useComments((s) => s.local)
@@ -14,6 +14,7 @@ import { useEffect, useRef, useState } from 'react'
14
14
  import { useStore, BOARD_POLICY, BRANDING, CONFIG, PUBLISHED, SOURCE_REVEALED, boardLabel, boardLocked, cap, fetchBoardNames, modeAllowed, playsAsSlides, SLIDE_INTRINSIC, type Node } from './store.ts'
15
15
  import { deckOrder } from './play-order.ts'
16
16
  import { useComments } from './comments-store.ts'
17
+ import { isNoteAnchor } from './notes.ts'
17
18
  import { ROUTE } from '../const.ts'
18
19
  import { poweredByUrl } from '../../shared/utm.ts'
19
20
  import { canvasCtl } from './canvas/ctl.ts'
@@ -207,7 +208,9 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
207
208
  const draft = useComments((s) => s.draft)
208
209
  const allThreads = useComments((s) => s.threads)
209
210
  const { setActive } = useComments.getState()
210
- const threads = allThreads.filter((t) => t.frame === frameId && !t.resolved)
211
+ // a thread on a sticky note (spec 18) belongs to the canvas: the stage has no notes, and its
212
+ // anchor must never be handed to inspect.js (a same-named frame element would match)
213
+ const threads = allThreads.filter((t) => t.frame === frameId && !t.resolved && !isNoteAnchor(t.anchor))
211
214
  const anchored = threads.filter((t) => (t.anchor as any)?.el)
212
215
  const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
213
216
  const sx = vp.width ? dw / vp.width : 1
@@ -245,7 +248,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
245
248
  if (!win) return
246
249
  // only while the thread is active (or composing) AND pins are shown AND element focus is
247
250
  // on (⇧L) - closing, ⇧C, or dimming focus clears it
248
- const at = !show || !showAnchor ? null : (draft?.frame === frameId ? draft.anchor
251
+ const at = !show || !showAnchor ? null : (draft?.frame === frameId && !isNoteAnchor(draft.anchor) ? draft.anchor
249
252
  : (active ? (threads.find((t) => t.id === active && (t.anchor as any)?.el)?.anchor ?? null) : null))
250
253
  win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: at ?? null }, location.origin)
251
254
  }, [active, draft, show, showAnchor, frameId, iframe, rects, ready])
@@ -261,7 +264,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
261
264
  return { x: (p?.fx ?? .94) * dw, y: (p?.fy ?? .06) * dh, orphan: false }
262
265
  }
263
266
  const activeThread = threads.find((t) => t.id === active)
264
- const draftAt = draft?.frame === frameId ? (() => {
267
+ const draftAt = draft?.frame === frameId && !isNoteAnchor(draft.anchor) ? (() => {
265
268
  const a = draft.anchor as any
266
269
  return { x: ((a?.rect?.x ?? 0) + (a?.pos?.fx ?? .5) * (a?.rect?.w ?? 0)) * sx, y: ((a?.rect?.y ?? 0) + (a?.pos?.fy ?? .5) * (a?.rect?.h ?? 0)) * sy }
267
270
  })() : null
@@ -4,6 +4,7 @@ import { CONFIG, useStore } from '../store.ts'
4
4
  import { bootHash } from '../hash.ts'
5
5
  import { startPerf } from '../perf.ts'
6
6
  import { FrameNode, HEADER } from './FrameNode.tsx'
7
+ import { NOTE_GAP } from '../notes.ts'
7
8
  import { startCameraBroadcast, setCameraScale } from './camera-broadcast.ts'
8
9
 
9
10
  /**
@@ -177,7 +178,15 @@ export function Canvas() {
177
178
  y0 -= Math.max(64, 36 / s1) // caption line above the group
178
179
  }
179
180
  }
180
- fitRect(x0, y0, x1 - x0, y1 - y0, focus)
181
+ // sticky notes (spec 18): a visible column hangs left of its frame, and may run below it
182
+ let y1n = y1
183
+ for (const n of ns) {
184
+ const col = document.querySelector(`[data-node-notes="${CSS.escape(n.key)}"]:not(.off)`) as HTMLElement | null
185
+ if (!col) continue
186
+ x0 = Math.min(x0, n.x - col.offsetWidth - NOTE_GAP)
187
+ y1n = Math.max(y1n, n.y + col.offsetTop + col.offsetHeight)
188
+ }
189
+ fitRect(x0, y0, x1 - x0, y1n - y0, focus)
181
190
  }
182
191
  canvasCtl.fitNode = (key: string) => fitKeys([key], true)
183
192
  canvasCtl.fitNodes = (keys: string[]) => fitKeys(keys, true)
@@ -9,6 +9,8 @@ import { registerFrame, unregisterFrame } from './frame-registry.ts'
9
9
  import { primeCameraFor } from './camera-broadcast.ts'
10
10
  import { sleep, wake } from './sleep.ts'
11
11
  import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
12
+ import { Stickies, type NoteSpec } from './Sticky.tsx'
13
+ import { noteId, sceneNoteHost } from '../notes.ts'
12
14
 
13
15
  export const HEADER = 28
14
16
  const SNAP = 12
@@ -51,6 +53,18 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
51
53
  const selected = useStore((s) => s.selection.includes(node.key))
52
54
  const interact = useStore((s) => s.interact === node.key)
53
55
  const working = useStore((s) => s.working.includes(node.frame)) // Live Jam: Marver is editing this frame
56
+ // sticky notes (spec 18): the scene's note when this node is the scene's first on the board
57
+ // (reading order), then the frame's own. Text is compared by value - a note edit re-renders
58
+ // only its column; a pan never re-renders any node (no position subscription here).
59
+ const sceneNote = useStore((s) => {
60
+ const scene = frame?.scene
61
+ const text = scene ? s.manifest?.scenes.find((sc) => sc.name === scene)?.note : undefined
62
+ if (!text) return undefined
63
+ return sceneNoteHost(s.nodes, (id) => s.manifest?.frames.find((f) => f.id === id)?.scene, scene!) === node.key ? text : undefined
64
+ })
65
+ const notes: NoteSpec[] = []
66
+ if (frame && sceneNote) notes.push({ kind: 'scene', id: noteId('scene', frame.scene), text: sceneNote })
67
+ if (frame?.note) notes.push({ kind: 'frame', id: noteId('frame', frame.id), text: frame.note })
54
68
  const workingSince = useStore((s) => s.workingSince[node.frame])
55
69
  // B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
56
70
  // pan/zoom tick. gestureScale below measures the world rect (the canonical source,
@@ -346,12 +360,15 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
346
360
  className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}${working ? ' working' : ''}`}
347
361
  data-theme={node.theme}
348
362
  style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined,
363
+ // the sticky column's width (spec 18): the docked comment card clears it on the left
364
+ ...(notes.length ? { ['--sh-note-w' as string]: `${Math.max(...notes.map((n) => (n.kind === 'scene' ? 380 : 260)))}px` } : {}),
349
365
  // phase every working animation by when THIS frame's job started - parallel
350
366
  // frames pulsing in sync would read as one fake choreography
351
367
  ...(working ? { ['--mv-w0' as string]: `${-(Date.now() - (workingSince ?? Date.now()))}ms` } : {}) }}
352
368
  data-node={node.key}
353
369
  >
354
370
  {working && <WorkShimmer belowBadge={!!frame.variantGroup} />}
371
+ <Stickies nodeKey={node.key} frameId={frame.id} notes={notes} underBadge={!!frame.variantGroup} />
355
372
  {frame.variantGroup && (
356
373
  <div className="sh-vbadge sh-no-pan" title={`${frame.variantGroup} · variant ${frame.variant?.toUpperCase()} - click to select`}
357
374
  onPointerDown={(e) => e.stopPropagation()}