@marver-design/marver 0.17.0 → 0.19.0

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 (48) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +3 -1
  3. package/dist/bake-kaf5kGZ7.mjs +747 -0
  4. package/dist/{build-D_g53Bp2.mjs → build-7ed5H2vT.mjs} +215 -13
  5. package/dist/cli.mjs +19 -11
  6. package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
  7. package/dist/{daemon-Bbh_jmui.mjs → daemon-DbHvLQUL.mjs} +1 -1
  8. package/dist/{dev-LnIISva5.mjs → dev-D3mP2x27.mjs} +205 -10
  9. package/dist/{init-B7YhcN2o.mjs → init-BQYCS3EU.mjs} +2 -2
  10. package/dist/{manifest-CaslQIAO.mjs → manifest-B01PSyDc.mjs} +34 -7
  11. package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
  12. package/dist/{plugin-D2msH1cj.mjs → plugin-DI-7NAnx.mjs} +108 -29
  13. package/dist/{poster-BEjUcQP3.mjs → poster-DNh6N27C.mjs} +1 -1
  14. package/dist/publish-bakes-Dp-ZFk3d.mjs +216 -0
  15. package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
  16. package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
  17. package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
  18. package/dist/{shot-DlmTO8AF.mjs → shot-DMDvDbeP.mjs} +14 -7
  19. package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
  20. package/docs/publish.md +27 -5
  21. package/docs/sticky-notes.md +43 -0
  22. package/package.json +2 -1
  23. package/src/client/content/diagram.tsx +27 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +48 -0
  26. package/src/client/frame-host/bridge.js +4 -9
  27. package/src/client/frame-host/main.tsx +20 -6
  28. package/src/client/shell/App.tsx +14 -63
  29. package/src/client/shell/Comments.tsx +77 -14
  30. package/src/client/shell/Play.tsx +6 -3
  31. package/src/client/shell/canvas/Canvas.tsx +12 -3
  32. package/src/client/shell/canvas/FrameNode.tsx +99 -89
  33. package/src/client/shell/canvas/Sticky.tsx +284 -0
  34. package/src/client/shell/canvas/admission.ts +70 -0
  35. package/src/client/shell/canvas/sleep.ts +194 -0
  36. package/src/client/shell/goto.ts +72 -0
  37. package/src/client/shell/notes.ts +151 -0
  38. package/src/client/shell/store.ts +36 -18
  39. package/src/client/shell/styles.css +92 -25
  40. package/src/client/shell/tidy.ts +20 -2
  41. package/src/shared/sleep-rule.ts +41 -0
  42. package/templates/AGENTS-embedded.md +15 -0
  43. package/templates/AGENTS-studio.md +15 -0
  44. package/templates/instructions/craft.md +9 -0
  45. package/templates/instructions/publish.md +62 -9
  46. package/templates/instructions/shape.md +62 -0
  47. package/src/client/frame-host/serialize.ts +0 -195
  48. package/src/client/shell/canvas/snapshots.ts +0 -233
@@ -1,13 +1,16 @@
1
- import { memo, useCallback, useEffect, useRef } from 'react'
1
+ import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
+ import { admit, release } from './admission.ts'
3
4
  import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, SlideFrameIcon, XIcon } from '../icons.tsx'
4
5
  import { CommentLayer } from '../Comments.tsx'
5
6
  import { useComments } from '../comments-store.ts'
6
7
  import { threadHostKey } from '../keys.ts'
7
8
  import { registerFrame, unregisterFrame } from './frame-registry.ts'
8
9
  import { primeCameraFor } from './camera-broadcast.ts'
9
- import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
10
+ import { sleep, wake } from './sleep.ts'
10
11
  import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
12
+ import { Stickies, type NoteSpec } from './Sticky.tsx'
13
+ import { noteId, sceneNoteHost } from '../notes.ts'
11
14
 
12
15
  export const HEADER = 28
13
16
  const SNAP = 12
@@ -34,6 +37,12 @@ function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
34
37
  * One frame on the canvas. Iframe laws: the iframe element is created once per node key
35
38
  * and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
36
39
  *
40
+ * At rest the frame SLEEPS in place (sleep.ts, spec 16): the same live document, its animations
41
+ * paused and its backdrop-filters replaced by certified textures. Interact mode wakes it; laser,
42
+ * comment pins and selection act on the sleeping document as it is. A frame the human has
43
+ * interacted with is a state the compiler cannot reproduce from its URL: it stays awake until
44
+ * it is reloaded. Any change of theme, size or source wakes first and sleeps again once settled.
45
+ *
37
46
  * Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
38
47
  * TARGET's classList, nothing else), and drags additionally raise the store gesture flag,
39
48
  * which hard-disables canvas panning for the duration. Both are needed: the class stops the
@@ -44,6 +53,18 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
44
53
  const selected = useStore((s) => s.selection.includes(node.key))
45
54
  const interact = useStore((s) => s.interact === node.key)
46
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 })
47
68
  const workingSince = useStore((s) => s.workingSince[node.frame])
48
69
  // B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
49
70
  // pan/zoom tick. gestureScale below measures the world rect (the canonical source,
@@ -51,21 +72,46 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
51
72
  const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, reloadFrame, setGesture, toast } = useStore.getState()
52
73
  const iframeRef = useRef<HTMLIFrameElement>(null)
53
74
  const themeRef = useRef(node.theme)
54
- // src is frozen at mount: theme changes ride sh:set-theme (never navigation), so
55
- // frame state (forms, scroll, dialogs) survives a theme flip. Real file changes below.
56
- const initialSrc = useRef<string | null>(null)
57
- if (frame && initialSrc.current === null) initialSrc.current = frameUrl(frame, node.theme)
75
+ // The JSX src is set ONCE, at ADMISSION, with the theme and revision of that moment, and never
76
+ // changes again (React would write it back after any later navigation and load the frame twice):
77
+ // theme changes ride sh:set-theme (never navigation), so frame state (forms, scroll, dialogs)
78
+ // survives a theme flip; file changes and reloads navigate imperatively below, only once
79
+ // admitted - a queued frame takes the fresh revision when its turn comes.
80
+ const [src, setSrc] = useState<string>()
81
+ const admitted = src !== undefined
82
+ const admittedRef = useRef(false)
58
83
  const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
59
84
 
60
- // theme switch without remount: the live iframe flips via message (no navigation). The lean is
61
- // INVALIDATED (not mutated) - a baked mermaid SVG can't be re-themed in place, so we drop it, show
62
- // live while it re-renders in the new theme, and the capture effect (theme is a dep) rebuilds a
63
- // fresh lean that is only shown once ready. No light-on-dark flash.
85
+ // admission (admission.ts): the iframe gets its src when a boot slot is free, nearest the centre
86
+ // of the view first; the slot goes back when the frame is ready, errored, gone, or its watchdog acts
87
+ const navigate = (url: string) => { if (admittedRef.current && iframeRef.current) iframeRef.current.src = url }
88
+ useEffect(() => {
89
+ if (!frame || node.missing) { admittedRef.current = false; setSrc(undefined); return } // gone: the card replaces the iframe; a return is a new document, admitted anew
90
+ if (admittedRef.current) return // the document exists (the frame's id changed under it): no slot
91
+ const rank = () => { // visible first, then by distance to the centre of the canvas (the panel offsets the window's)
92
+ const r = iframeRef.current?.getBoundingClientRect(), c = document.querySelector('.sh-canvas')?.getBoundingClientRect()
93
+ if (!r || !c) return Infinity
94
+ const visible = r.right > c.left && r.left < c.right && r.bottom > c.top && r.top < c.bottom
95
+ return (visible ? 0 : 1e7) + Math.hypot(r.x + r.width / 2 - c.x - c.width / 2, r.y + r.height / 2 - c.y - c.height / 2)
96
+ }
97
+ const start = () => {
98
+ const s = useStore.getState(), n = s.nodes.find((x) => x.key === node.key), f = n && s.frameFor(n)
99
+ if (!n || !f) { release(node.key); return }
100
+ if (n.status !== 'loading') setStatus(node.key, 'loading') // a returning frame boots under the watchdog like any other
101
+ admittedRef.current = true
102
+ setSrc(frameUrl(f, n.theme))
103
+ }
104
+ admit({ key: node.key, rank, start })
105
+ return () => release(node.key)
106
+ }, [frame?.id, node.key, node.missing])
107
+ useEffect(() => { if (node.status !== 'loading') release(node.key) }, [node.status, node.key])
108
+
109
+ // theme switch without remount: the live iframe flips via message (no navigation); the frame
110
+ // reports sh:theme-applied and only then sleeps again under the new theme (node.themeOn)
64
111
  useEffect(() => {
65
112
  if (themeRef.current !== node.theme) {
66
113
  themeRef.current = node.theme
67
114
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
68
- invalidateLean(node.key)
69
115
  }
70
116
  }, [node.theme, node.key])
71
117
 
@@ -90,53 +136,6 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
90
136
  registerWin()
91
137
  }, [node.key])
92
138
 
93
- // Register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
94
- const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
95
- // capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
96
- // (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
97
- // so neither is a dep - keeping captures rare. Never during a gesture; the coordinator serialises.
98
- useEffect(() => {
99
- // capture reads the live iframe's same-origin document - true in dev AND publish (published frames
100
- // are bundled same-origin and served by `marver serve`), so the lean tier works in both via this
101
- // client-side capture. Fail-soft: a frame that can't serialise stays live (publish == today's
102
- // behaviour in the worst case). No headless build step / heavy dependency needed.
103
- if (node.status !== 'ready' || node.missing) return
104
- const iframe = iframeRef.current
105
- if (!iframe) return
106
- const t = setTimeout(() => {
107
- // never re-admit a cover while this frame hosts an open thread / draft: the live app
108
- // must stay visible (its highlight updates in real time). A status/theme change would
109
- // otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
110
- // hostsCard rail recaptures a clean lean once the card closes.
111
- const c = useComments.getState()
112
- // hosting goes through the resolver (keys.ts): an adopted thread's card renders
113
- // here even though its stored nodeKey names a node that no longer exists
114
- const hosting = (!!c.active && c.threads.some((th) =>
115
- th.id === c.active && !th.resolved && threadHostKey(th, useStore.getState().nodes) === node.key)) || c.draft?.nodeKey === node.key
116
- if (hosting) return
117
- scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
118
- }, 450)
119
- return () => clearTimeout(t)
120
- // node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
121
- // flip, so a theme change re-captures after the live frame re-renders (key includes theme).
122
- }, [node.status, node.nav, node.key, node.missing, node.theme])
123
- useEffect(() => () => dropSnapshot(node.key), [node.key]) // drop the snapshot on unmount
124
- // a reload / file-swap / error takes the frame out of 'ready': drop its cover so a stale picture
125
- // never lingers (nav may not bump on a same-file reload). The next 'ready' re-captures.
126
- useEffect(() => { if (node.status !== 'ready') dropSnapshot(node.key) }, [node.status, node.key])
127
- // LEAN-PRIMARY focus handoff: entering interact shows the live app (drop the now-stale lean at
128
- // once); leaving it recaptures the live frame's CURRENT state (the user may have typed/toggled)
129
- // and only swaps back to lean once that fresh capture is admitted. force=true: same nav/theme.
130
- const prevInteract = useRef(interact)
131
- useEffect(() => {
132
- if (prevInteract.current === interact) return
133
- const wasInteract = prevInteract.current
134
- prevInteract.current = interact
135
- if (interact) invalidateLean(node.key)
136
- else if (wasInteract && node.status === 'ready' && iframeRef.current)
137
- scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
138
- }, [interact, node.key, node.nav, node.theme, node.status])
139
-
140
139
  // laser mode rides the same rail; re-sent when a frame becomes ready
141
140
  // so late loaders join an already-lasered board
142
141
  const laser = useStore((s) => s.laser)
@@ -147,21 +146,6 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
147
146
  const hostsCard = useComments((s) =>
148
147
  (!!s.active && s.threads.some((t) =>
149
148
  t.id === s.active && !t.resolved && threadHostKey(t, useStore.getState().nodes) === node.key)) || s.draft?.nodeKey === node.key)
150
- // a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
151
- // cover: the active-element highlight lives in the live DOM and updates in real time
152
- // (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
153
- // mode ends and either bakes a stale highlight or hides the live one. Mirror the interact
154
- // rail: drop the cover while hosting, rebuild a fresh lean once the card closes (the
155
- // highlight is cleared by then, so the recapture is clean).
156
- const prevHostsCard = useRef(hostsCard)
157
- useEffect(() => {
158
- if (prevHostsCard.current === hostsCard) return
159
- const wasHosting = prevHostsCard.current
160
- prevHostsCard.current = hostsCard
161
- if (hostsCard) invalidateLean(node.key)
162
- else if (wasHosting && node.status === 'ready' && iframeRef.current)
163
- scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
164
- }, [hostsCard, node.key, node.nav, node.theme, node.status])
165
149
  useEffect(() => {
166
150
  if (node.status === 'ready' || !laser)
167
151
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
@@ -179,6 +163,32 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
179
163
  if (node.status === 'ready' || !interact)
180
164
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
181
165
  }, [interact, node.status])
166
+ // SLEEP lifecycle. Interact = awake and DIRTY (the app's state is now its own); a resize drag =
167
+ // awake for the whole drag; any other change of the key (theme once applied, size, source revision,
168
+ // navigation) wakes first - the old override describes another state - and sleeps again once the
169
+ // new one has settled. Laser and comment mode need nothing: the sleeping document IS the live one.
170
+ const dirty = useRef(false)
171
+ const lastDoc = useRef<Document | null>(null) // a new document (reload, self-reload) is pristine again
172
+ const resizing = useRef(false)
173
+ const [resizeTick, setResizeTick] = useState(0)
174
+ useEffect(() => { dirty.current = false }, [node.nav]) // a fresh document is pristine again
175
+ const w = Math.round(node.w), h = Math.round(node.h)
176
+ // a layout effect: the wake lands BEFORE the first paint of the new state (a stretched texture
177
+ // must never be painted at a new size)
178
+ useLayoutEffect(() => {
179
+ const iframe = iframeRef.current
180
+ if (!iframe || !frame || node.missing) { wake(node.key, null); return } // nothing to keep (a deleted frame's card must not retain its old document)
181
+ const doc = iframe.contentDocument
182
+ if (doc && doc !== lastDoc.current) { lastDoc.current = doc; dirty.current = false }
183
+ if (interact) dirty.current = true
184
+ wake(node.key, iframe)
185
+ if (interact || dirty.current || resizing.current || node.status !== 'ready') return
186
+ if (node.themeOn !== undefined && node.themeOn !== node.theme) return // the frame has not painted the new theme yet
187
+ const t = setTimeout(() => { void sleep(node.key, iframe, { frame: frame.id, theme: node.theme, w, h }) }, 250)
188
+ return () => clearTimeout(t)
189
+ }, [interact, node.status, node.theme, node.themeOn, node.rev, node.nav, w, h, node.missing, frame?.id, node.key, resizeTick])
190
+ useEffect(() => () => wake(node.key, iframeRef.current), [node.key])
191
+
182
192
  // image-LOD: once the frame is ready its content listener is live, so send the settled zoom - a static
183
193
  // board that never gets a gesture then still sharpens its images from the cheap low-res first paint.
184
194
  useEffect(() => {
@@ -191,7 +201,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
191
201
  const sig = `${frame.kind}:${frame.file}`
192
202
  if (fileRef.current !== null && fileRef.current !== sig && iframeRef.current) {
193
203
  setStatus(node.key, 'loading')
194
- iframeRef.current.src = frameUrl(frame, node.theme)
204
+ navigate(frameUrl(frame, node.theme))
195
205
  }
196
206
  fileRef.current = sig
197
207
  }, [frame?.kind, frame?.file])
@@ -202,7 +212,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
202
212
  useEffect(() => {
203
213
  if ((node.nav ?? 0) === navRef.current) return
204
214
  navRef.current = node.nav ?? 0
205
- if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
215
+ if (frame) navigate(frameUrl(frame, node.theme))
206
216
  }, [node.nav])
207
217
 
208
218
  // Reload the frame, assigning the fresh URL SYNCHRONOUSLY so the live iframe's src is current the
@@ -215,8 +225,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
215
225
  reloadFrame(node.key, automatic)
216
226
  const after = useStore.getState().nodes.find((x) => x.key === node.key)
217
227
  const f = useStore.getState().frameFor(node)
218
- if (after && f && iframeRef.current) {
219
- iframeRef.current.src = frameUrl(f, node.theme)
228
+ if (after && f) {
229
+ navigate(frameUrl(f, node.theme))
220
230
  navRef.current = after.nav ?? 0
221
231
  }
222
232
  }
@@ -231,14 +241,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
231
241
  // auto-renavigates ONCE on a fresh rev; a second silence stays 'loading' (never a red error card).
232
242
  // node.nav is a dep so a fresh navigation restarts the full budget; readyRetried flips true on the
233
243
  // retry and bounds it to exactly one.
244
+ // The budget counts from ADMISSION (a queued frame is not silent). The retried document keeps its
245
+ // boot slot - its boot is still running - and gives it back after a second silence.
234
246
  useEffect(() => {
235
- if (!shouldArmReadyWatch(node, !!frame)) return
236
- const t = setTimeout(() => reloadRef.current(true), 10_000)
247
+ if (!admitted || node.status !== 'loading' || !frame || node.missing) return
248
+ const t = setTimeout(() => { if (shouldArmReadyWatch(node, true)) reloadRef.current(true); else release(node.key) }, 10_000)
237
249
  return () => clearTimeout(t)
238
250
  // depend on the frame's stable SIGNATURE, not the manifest object - that object is replaced on
239
251
  // every manifest reconcile, so depending on it would reset the budget on unrelated frames during
240
252
  // heavy Live Jam churn and starve the retry. kind/file still re-arm on a real file swap.
241
- }, [node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
253
+ }, [admitted, node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
242
254
 
243
255
  const drag = (e: React.PointerEvent, mode: 'move' | 'e' | 's' | 'se') => {
244
256
  e.stopPropagation()
@@ -268,8 +280,9 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
268
280
  const begin = () => {
269
281
  if (gesturing) return
270
282
  gesturing = true
271
- world.classList.add('sh-gesturing') // drops iframe pointer-events (sh-camera is NOT set, so no cover)
283
+ world.classList.add('sh-gesturing') // drops iframe pointer-events
272
284
  setGesture(true)
285
+ if (mode !== 'move') { resizing.current = true; wake(node.key, iframeRef.current); setResizeTick((t) => t + 1) } // awake before the first resized paint, for the whole resize
273
286
  }
274
287
  const MOVE_THRESHOLD = 3 // px in screen space before a press counts as a drag
275
288
 
@@ -297,6 +310,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
297
310
  try { el.releasePointerCapture(e.pointerId) } catch { /* already released */ }
298
311
  world.classList.remove('sh-gesturing')
299
312
  setGesture(false)
313
+ if (resizing.current) { resizing.current = false; setResizeTick((t) => t + 1) } // settle, then sleep again
300
314
  el.removeEventListener('pointermove', onMove)
301
315
  el.removeEventListener('pointerup', done)
302
316
  el.removeEventListener('pointercancel', done)
@@ -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()}
@@ -398,18 +415,11 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
398
415
  <iframe
399
416
  ref={bindIframe}
400
417
  className="sh-live"
401
- src={initialSrc.current ?? frameUrl(frame, node.theme)}
418
+ src={src}
402
419
  title={frame.id}
403
420
  onLoad={registerWin}
404
421
  style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
405
422
  />
406
- {/* Lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
407
- the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
408
- device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
409
- so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
410
- never messaged, pointer-events:none - it is NOT the live iframe (role: .sh-lean).
411
- Rendered in dev AND publish (runtime client-side capture; frames are same-origin in both). */}
412
- <iframe ref={bindLean} className="sh-lean" sandbox="allow-same-origin" title="" aria-hidden tabIndex={-1} />
413
423
  {/* the overlay eats mouse events for drag-by-body; laser and comment mode both
414
424
  need the mouse INSIDE the frame for hover highlights, so it steps aside
415
425
  (drag still works via the header) */}
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Sticky notes (spec 18): the yellow column left of a frame - the scene's note (when this node
3
+ * hosts it) above the frame's own. Shell DOM, never inside the iframe: sleep, bakes and the frame
4
+ * document are untouched. Markdown through the content package's renderer (the Md block's link
5
+ * and image policy, raw HTML inert); a ```mermaid fence renders hand-drawn on demand.
6
+ *
7
+ * Fold: the dog-ear at the column's top-right corner folds the whole column down to that corner
8
+ * (scale + fade toward it); the corner stays as the tab that unfolds it. Per viewer, in
9
+ * localStorage - never in the board file.
10
+ *
11
+ * Comments: in comment mode a click on a note element stages a draft on the node, anchored like
12
+ * a frame element (notes.ts builds the bundle; the CommentLayer resolves it here in the shell).
13
+ * Links: `goto:` navigates through the one goto path; in comment mode a click picks, never
14
+ * navigates (the bridge rule).
15
+ */
16
+ import { memo, useEffect, useMemo, useRef } from 'react'
17
+ import { renderMarkdown, sanitizeMarkdownHtml } from '../../content/md.ts'
18
+ import { cleanSource, guardDiagramSource, sanitizeSvg } from '../../content/diagram.tsx'
19
+ import { useComments } from '../comments-store.ts'
20
+ import { goTo } from '../goto.ts'
21
+ import { NOTE_W, SCENE_NOTE_W, noteAnchor, noteVisible, useNotes, type NoteKind } from '../notes.ts'
22
+
23
+ export interface NoteSpec { kind: NoteKind; id: string; text: string }
24
+
25
+ /** Diagram text only: the sketched look is the diagram's, prose stays in the shell font. */
26
+ const HAND_FONT = `"Segoe Print", "Bradley Hand", "Chalkboard SE", "Comic Sans MS", "Comic Neue", cursive`
27
+ /** Yellow paper for the hand-drawn look - the same values the sheet paints the sticky with. */
28
+ const PAPER = { bg: '#fff3a3', ink: '#2b2500', line: '#6b5a00', soft: '#ffe680', pale: '#fffbdc', hatch: '#f0cc4e' }
29
+ const THEME_VARS = {
30
+ background: PAPER.bg, fontFamily: HAND_FONT, fontSize: '17px',
31
+ primaryColor: PAPER.soft, primaryTextColor: PAPER.ink, primaryBorderColor: PAPER.line,
32
+ secondaryColor: PAPER.pale, secondaryTextColor: PAPER.ink, secondaryBorderColor: PAPER.line,
33
+ tertiaryColor: PAPER.pale, tertiaryTextColor: PAPER.ink, tertiaryBorderColor: PAPER.line,
34
+ lineColor: PAPER.line, textColor: PAPER.ink, mainBkg: PAPER.soft, nodeBorder: PAPER.line,
35
+ clusterBkg: PAPER.pale, clusterBorder: PAPER.line, titleColor: PAPER.ink,
36
+ edgeLabelBackground: PAPER.bg, noteBkgColor: PAPER.soft, noteTextColor: PAPER.ink, noteBorderColor: PAPER.line,
37
+ actorBkg: PAPER.soft, actorBorder: PAPER.line, actorTextColor: PAPER.ink, signalColor: PAPER.line, signalTextColor: PAPER.ink,
38
+ labelBoxBkgColor: PAPER.pale, labelBoxBorderColor: PAPER.line, labelTextColor: PAPER.ink, loopTextColor: PAPER.ink,
39
+ activationBkgColor: PAPER.pale, activationBorderColor: PAPER.line, sequenceNumberColor: PAPER.ink,
40
+ pie1: PAPER.soft, pie2: PAPER.pale, pie3: '#f3d96a', pie4: '#e9c94f', pie5: '#fff0b8', pie6: '#e0c04a', pieTitleTextColor: PAPER.ink, pieSectionTextColor: PAPER.ink, pieLegendTextColor: PAPER.ink, pieStrokeColor: PAPER.line, pieOuterStrokeColor: PAPER.line,
41
+ // every family that would otherwise bring its own rainbow: gantt sections, journey fills,
42
+ // git branches, the cScale mindmap and timeline use - all the paper's yellows, ink for text
43
+ sectionBkgColor: PAPER.pale, altSectionBkgColor: PAPER.bg, sectionBkgColor2: PAPER.soft,
44
+ taskBkgColor: PAPER.soft, taskBorderColor: PAPER.line, taskTextColor: PAPER.ink, taskTextLightColor: PAPER.ink, taskTextOutsideColor: PAPER.ink, taskTextDarkColor: PAPER.ink,
45
+ activeTaskBkgColor: '#f3d96a', activeTaskBorderColor: PAPER.line, doneTaskBkgColor: PAPER.pale, doneTaskBorderColor: PAPER.line,
46
+ critBkgColor: '#e9c94f', critBorderColor: PAPER.ink, gridColor: PAPER.line, todayLineColor: PAPER.ink,
47
+ fillType0: PAPER.soft, fillType1: PAPER.pale, fillType2: '#f3d96a', fillType3: PAPER.soft, fillType4: PAPER.pale, fillType5: '#f3d96a', fillType6: PAPER.soft, fillType7: PAPER.pale,
48
+ git0: '#e9c94f', git1: '#f3d96a', git2: PAPER.soft, git3: '#e0c04a', git4: PAPER.pale, git5: '#e9c94f', git6: '#f3d96a', git7: PAPER.soft,
49
+ gitBranchLabel0: PAPER.ink, gitBranchLabel1: PAPER.ink, gitBranchLabel2: PAPER.ink, gitBranchLabel3: PAPER.ink, gitBranchLabel4: PAPER.ink, gitBranchLabel5: PAPER.ink, gitBranchLabel6: PAPER.ink, gitBranchLabel7: PAPER.ink,
50
+ gitInv0: PAPER.ink, gitInv1: PAPER.ink, gitInv2: PAPER.ink, gitInv3: PAPER.ink, gitInv4: PAPER.ink, gitInv5: PAPER.ink, gitInv6: PAPER.ink, gitInv7: PAPER.ink,
51
+ commitLabelColor: PAPER.ink, commitLabelBackground: PAPER.bg, tagLabelColor: PAPER.ink, tagLabelBackground: PAPER.soft, tagLabelBorder: PAPER.line,
52
+ ...Object.fromEntries([...Array(12)].flatMap((_, i) => [[`cScale${i}`, [PAPER.soft, '#f3d96a', PAPER.pale, '#e9c94f'][i % 4]], [`cScaleLabel${i}`, PAPER.ink], [`cScalePeer${i}`, PAPER.line]])),
53
+ quadrant1Fill: PAPER.soft, quadrant2Fill: PAPER.pale, quadrant3Fill: PAPER.pale, quadrant4Fill: PAPER.soft,
54
+ quadrant1TextFill: PAPER.ink, quadrant2TextFill: PAPER.ink, quadrant3TextFill: PAPER.ink, quadrant4TextFill: PAPER.ink,
55
+ quadrantPointFill: PAPER.ink, quadrantPointTextFill: PAPER.ink, quadrantXAxisTextFill: PAPER.ink, quadrantYAxisTextFill: PAPER.ink,
56
+ quadrantInternalBorderStrokeFill: PAPER.line, quadrantExternalBorderStrokeFill: PAPER.line, quadrantTitleFill: PAPER.ink,
57
+ attributeBackgroundColorOdd: PAPER.pale, attributeBackgroundColorEven: PAPER.bg,
58
+ }
59
+ let diagramSeq = 0
60
+
61
+ /** A sticky is one colour of paper. Whatever a diagram family brings that the theme variables
62
+ * did not reach (journey actor dots, ER marker circles, a white attribute row, block fills) is
63
+ * mapped onto the palette AFTER render, by computed colour: white and light fills become paper,
64
+ * dark fills become ink, strokes likewise. Text is never touched (its fill is set above). */
65
+ const PALETTE = new Set([PAPER.bg, PAPER.ink, PAPER.line, PAPER.soft, PAPER.pale, PAPER.hatch, '#f3d96a', '#e9c94f', '#e0c04a', '#fff0b8', '#a88f2a'].map((c) => c.toLowerCase()))
66
+ const hex = (rgb: string): string | null => {
67
+ const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec(rgb)
68
+ if (!m || (m[4] !== undefined && Number(m[4]) === 0)) return null
69
+ return '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')
70
+ }
71
+ const luminance = (h: string) => { const n = parseInt(h.slice(1), 16); return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255 }
72
+ function inkPalette(svg: SVGSVGElement) {
73
+ for (const el of svg.querySelectorAll('path, rect, circle, ellipse, polygon, polyline, line') as NodeListOf<SVGElement>) {
74
+ const cs = getComputedStyle(el)
75
+ const fill = hex(cs.fill), stroke = hex(cs.stroke)
76
+ if (fill && !PALETTE.has(fill)) el.style.setProperty('fill', luminance(fill) > 0.55 ? (fill === '#ffffff' ? PAPER.pale : PAPER.soft) : PAPER.line, 'important')
77
+ // a LIGHT stroke is a hachure line or a faint grid (rough draws a white fill as white
78
+ // hatching): it becomes the hatch yellow; a mid stroke the line brown; a dark one the ink
79
+ if (stroke && !PALETTE.has(stroke)) el.style.setProperty('stroke', luminance(stroke) > 0.7 ? PAPER.hatch : luminance(stroke) > 0.35 ? PAPER.line : PAPER.ink, 'important')
80
+ }
81
+ }
82
+
83
+ /** One look for every diagram type. mermaid's hand-drawn look (rough.js) reaches flowchart,
84
+ * state, class, ER and block natively; sequence actors, journey and timeline boxes, mindmap
85
+ * nodes, quadrants, git commits, pie slices and legends come out as plain rects, circles and
86
+ * paths. Every visible one of those that mermaid did not already sketch is redrawn here with the
87
+ * same rough.js recipe the flowchart nodes got - hachured fill, sketched border - in the paper's
88
+ * colours, the plain shape kept underneath as a flat backing. Seeded per shape, so a re-render
89
+ * of the same note draws the same lines. */
90
+ const FILLS = ['hachure', 'cross-hatch', 'dots', 'zigzag-line', 'dashed'] as const
91
+ function sketchShapes(svg: SVGSVGElement, rough: any) {
92
+ const rc = rough.svg(svg)
93
+ let seed = 7, slice = 0
94
+ const visible = (el: Element) => { const f = getComputedStyle(el).fill; return f !== 'none' && !/rgba\(\d+, \d+, \d+, 0\)/.test(f) }
95
+ const skip = (el: Element) =>
96
+ (el as any).__sketched
97
+ || !!el.closest('.label, foreignObject, marker, defs, .face, .legend-text')
98
+ || sketchedByMermaid(el)
99
+ || (el.nextElementSibling?.tagName === 'g' && !!el.nextElementSibling.querySelector('path')) // mermaid's own rough
100
+ || !visible(el)
101
+ // the unified renderer's nodes carry `g.node` / `g.rough-node`, but the class is a promise, not
102
+ // a fact: a mindmap node wears `rough-node` around a plain path. Trust the evidence - rough
103
+ // output is classless paths, direct or inside the label container
104
+ const sketchedByMermaid = (el: Element) => {
105
+ const node = el.parentElement?.closest('g.node, g.rough-node, g.cluster')
106
+ if (!node) return false
107
+ const plain = (e: Element) => !(e.getAttribute('class') ?? '').trim() // an EMPTY class attribute is no class
108
+ for (const c of node.children) {
109
+ if (c.tagName === 'path' && plain(c)) return true
110
+ if (c.tagName === 'g' && (plain(c) || /\b(basic|label-container)\b/.test(c.getAttribute('class') ?? '')) && [...c.children].some((cc) => cc.tagName === 'path' && plain(cc))) return true
111
+ }
112
+ return false
113
+ }
114
+ const place = (el: Element, g: SVGGElement, backing: string) => {
115
+ // rough draws with presentation attributes; inside a mermaid node group the diagram's own
116
+ // stylesheet (`.node path { fill; stroke }`) would beat them - pin them as inline styles
117
+ for (const path of g.querySelectorAll('path')) {
118
+ path.style.fill = path.getAttribute('fill') ?? 'none'
119
+ path.style.stroke = path.getAttribute('stroke') ?? 'none'
120
+ path.style.strokeWidth = path.getAttribute('stroke-width') ?? '1'
121
+ }
122
+ el.setAttribute('style', `fill: ${backing}; stroke: none;`)
123
+ ;(el as any).__sketched = true
124
+ el.after(g)
125
+ }
126
+ const recipe = (extra: Record<string, unknown> = {}) => ({
127
+ seed: seed++, roughness: 0.7, bowing: 0.6, stroke: PAPER.line, strokeWidth: 1.6,
128
+ fill: PAPER.hatch, fillStyle: 'hachure', hachureAngle: 120, hachureGap: 4, fillWeight: 1.2, ...extra,
129
+ })
130
+ for (const r of [...svg.querySelectorAll('rect')]) {
131
+ if (skip(r)) continue
132
+ const w = Number(r.getAttribute('width') ?? 0), h = Number(r.getAttribute('height') ?? 0)
133
+ if (!(w > 6 && h > 6)) continue
134
+ const x = Number(r.getAttribute('x') ?? 0), y = Number(r.getAttribute('y') ?? 0)
135
+ // a quadrant is a field, not a box: the sketched border only, a flat pale backing
136
+ const field = !!r.closest('.quadrants') || w * h > 40_000
137
+ place(r, rc.rectangle(x, y, w, h, recipe(field ? { fill: undefined, fillStyle: 'solid' } : {})), field ? PAPER.pale : PAPER.soft)
138
+ }
139
+ for (const c of [...svg.querySelectorAll('circle')]) {
140
+ if (skip(c)) continue
141
+ const d = 2 * Number(c.getAttribute('r') ?? 0)
142
+ if (d < 6) continue
143
+ place(c, rc.circle(Number(c.getAttribute('cx') ?? 0), Number(c.getAttribute('cy') ?? 0), d, recipe(d < 30 ? { fillStyle: 'solid', fill: PAPER.hatch } : {})), PAPER.soft)
144
+ }
145
+ for (const e of [...svg.querySelectorAll('ellipse')]) {
146
+ if (skip(e)) continue
147
+ place(e, rc.ellipse(Number(e.getAttribute('cx') ?? 0), Number(e.getAttribute('cy') ?? 0), 2 * Number(e.getAttribute('rx') ?? 0), 2 * Number(e.getAttribute('ry') ?? 0), recipe()), PAPER.soft)
148
+ }
149
+ // pie slices: one fill style per slice, so the wedges read apart in one colour of ink;
150
+ // mindmap and timeline draw their boxes as paths (node-bkg) - the same box recipe
151
+ for (const p of [...svg.querySelectorAll('path.pieCircle, path.node-bkg')]) {
152
+ if (skip(p)) continue
153
+ const d = p.getAttribute('d')
154
+ if (!d) continue
155
+ const pie = p.classList.contains('pieCircle')
156
+ place(p, rc.path(d, recipe(pie ? { fillStyle: FILLS[slice++ % FILLS.length], hachureGap: 5, fillWeight: 1 } : {})), PAPER.soft)
157
+ }
158
+ }
159
+
160
+ /** Render every ```mermaid fence in a sticky body: hand-drawn, strict, sanitized. The pre stays
161
+ * mounted through an error (the message replaces the code), so a healed edit heals in place. */
162
+ async function renderDiagrams(body: HTMLElement, alive: () => boolean) {
163
+ const fences = [...body.querySelectorAll('pre > code.language-mermaid')] as HTMLElement[]
164
+ if (!fences.length) return
165
+ const [{ default: mermaid }, { default: rough }] = await Promise.all([import('mermaid'), import('roughjs')])
166
+ if (!alive()) return
167
+ mermaid.initialize({
168
+ startOnLoad: false, securityLevel: 'strict', theme: 'base', look: 'handDrawn', themeVariables: THEME_VARS, fontFamily: HAND_FONT,
169
+ // legible at sticky size. Flowcharts get rough.js boxes and HTML labels; every other type
170
+ // draws plain SVG text in a handwriting face that has no bold - so SVG text is inked with a
171
+ // thin stroke (paint-order keeps the fill on top), lines and boxes are darker and thicker.
172
+ themeCSS: [
173
+ `.nodeLabel, .edgeLabel, .label { font-weight: 700 !important; letter-spacing: .01em }`,
174
+ `text, tspan { font-weight: 700 !important; fill: ${PAPER.ink}; stroke: ${PAPER.ink}; stroke-width: .45px; paint-order: stroke fill; letter-spacing: .01em }`,
175
+ `.edgePath path, .flowchart-link { stroke-width: 2px } .node path, .node rect { stroke-width: 1.6px }`,
176
+ // sequence: actor boxes, lifelines, messages and arrowheads in the paper's ink
177
+ `.actor-line { stroke: ${PAPER.line} !important; stroke-width: 1.4px !important }`,
178
+ `.messageLine0, .messageLine1 { stroke: ${PAPER.ink} !important; stroke-width: 1.8px !important } #arrowhead path, .arrowheadPath { fill: ${PAPER.ink} !important; stroke: ${PAPER.ink} !important }`,
179
+ `.messageText, .actor > tspan, text.actor { fill: ${PAPER.ink} !important }`,
180
+ `.loopLine { stroke: ${PAPER.line} !important } .labelBox, .loopText, .note { stroke: ${PAPER.line} }`,
181
+ `.er.attributeBoxOdd { fill: ${PAPER.pale} !important; stroke: ${PAPER.line} } .er.attributeBoxEven { fill: ${PAPER.bg} !important; stroke: ${PAPER.line} } .er.relationshipLine { stroke: ${PAPER.ink} !important }`,
182
+ `.section0, .section2, .section4 { fill: ${PAPER.pale} } .section1, .section3 { fill: ${PAPER.bg} } .grid .tick line { stroke: ${PAPER.line} } .today { stroke: ${PAPER.ink} }`,
183
+ `.commit-label, .branchLabel text { fill: ${PAPER.ink} !important } .commit-label-bkg { fill: ${PAPER.bg} !important } .commit-arrow, .arrow { stroke: ${PAPER.ink} !important }`,
184
+ `.mindmap-node > path, .mindmap-node rect { stroke: ${PAPER.line}; stroke-width: 1.4px } .edge { stroke: ${PAPER.line}; stroke-width: 2px }`,
185
+ // state / class / er: the same ink
186
+ `.statediagram-state rect, .classGroup rect, .er.entityBox { stroke: ${PAPER.line}; stroke-width: 1.6px; fill: ${PAPER.soft} } .transition, .relation, .er.relationshipLine { stroke: ${PAPER.ink} !important; stroke-width: 1.8px !important }`,
187
+ ].join(' '),
188
+ flowchart: { padding: 10, nodeSpacing: 32, rankSpacing: 36 },
189
+ // one actor row (the mirrored bottom row is noise at note size), roomier boxes, bigger text
190
+ sequence: { mirrorActors: false, actorFontSize: 18, actorFontWeight: 700, messageFontSize: 17, messageFontWeight: 700, noteFontSize: 16, width: 96, height: 50, actorMargin: 26, boxMargin: 8, messageMargin: 30, diagramMarginX: 6, diagramMarginY: 6, wrap: true, bottomMarginAdj: 4 },
191
+ // the wide-by-nature types drawn AT the note's width instead of shrunk into it
192
+ gantt: { useWidth: 380, fontSize: 13, sectionFontSize: 13, barHeight: 22, barGap: 6, topPadding: 44, leftPadding: 64, rightPadding: 12, gridLineStartPadding: 20, numberSectionStyles: 2 },
193
+ journey: { width: 118, height: 40, leftMargin: 56, taskMargin: 12, taskFontSize: 13, diagramMarginX: 8, diagramMarginY: 8, boxMargin: 6, actorColours: [PAPER.line, PAPER.ink, '#a88f2a', '#e9c94f'] },
194
+ timeline: { disableMulticolor: true, padding: 10, width: 84, height: 40, leftMargin: 40, taskMargin: 8, diagramMarginX: 8, diagramMarginY: 8 },
195
+ quadrantChart: { chartWidth: 360, chartHeight: 360, titleFontSize: 16, quadrantLabelFontSize: 14, pointLabelFontSize: 13, xAxisLabelFontSize: 13, yAxisLabelFontSize: 13, pointRadius: 5 },
196
+ mindmap: { padding: 10, maxNodeWidth: 150 },
197
+ er: { minEntityWidth: 90, minEntityHeight: 50, entityPadding: 12, fontSize: 14, diagramPadding: 10 },
198
+ gitGraph: { showBranches: true, rotateCommitLabel: false, nodeLabel: { width: 60 } } as any,
199
+ })
200
+ for (const code of fences) {
201
+ const pre = code.parentElement!
202
+ const src = cleanSource(code.textContent ?? '') // front matter and %%{init}%% directives never reach mermaid (the Diagram rule)
203
+ const host = document.createElement('div')
204
+ host.className = 'sh-sticky-diagram'
205
+ try {
206
+ guardDiagramSource(src) // the zero-external-request boundary (the Diagram block's)
207
+ const { svg } = await mermaid.render(`sh-note-d${++diagramSeq}`, src)
208
+ if (!alive()) return
209
+ host.innerHTML = sanitizeSvg(svg)
210
+ const el = host.querySelector('svg')
211
+ // never larger than drawn: a family without mermaid's max-width (block) would upscale to
212
+ // the note's width and its hatching with it - the viewBox width is the ceiling
213
+ const vb = el?.viewBox?.baseVal
214
+ if (el && vb && vb.width > 0 && !el.style.maxWidth) el.style.maxWidth = `${Math.ceil(vb.width)}px`
215
+ pre.replaceWith(host)
216
+ if (el) { sketchShapes(el, rough); inkPalette(el) } // computed colours need the svg in the document
217
+ } catch (e) {
218
+ if (!alive()) return
219
+ pre.classList.add('err')
220
+ code.textContent = `diagram: ${String((e as Error)?.message ?? e).split('\n')[0]}`
221
+ }
222
+ }
223
+ }
224
+
225
+ function StickyBody({ text, kind, nodeKey, frameId }: { text: string; kind: NoteKind; nodeKey: string; frameId: string }) {
226
+ const ref = useRef<HTMLDivElement>(null)
227
+ const html = useMemo(() => sanitizeMarkdownHtml(renderMarkdown(text)), [text])
228
+ useEffect(() => {
229
+ const el = ref.current
230
+ if (!el) return
231
+ let live = true
232
+ el.innerHTML = html
233
+ void renderDiagrams(el, () => live && ref.current === el)
234
+ return () => { live = false }
235
+ }, [html])
236
+
237
+ const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
238
+ const root = ref.current
239
+ const target = e.target instanceof Element ? e.target : null
240
+ if (!root || !target) return
241
+ const c = useComments.getState()
242
+ if (c.commentMode) {
243
+ // comment mode owns every click: pick the element, exactly like a frame pick
244
+ e.preventDefault(); e.stopPropagation()
245
+ // the picked element is the innermost element under the pointer; a click in the body's
246
+ // own padding picks the first block instead of the whole note
247
+ const pick = target === root ? root.firstElementChild : target
248
+ if (!pick) return
249
+ const nodeEl = root.closest('.sh-node') as HTMLElement | null
250
+ const bodyEl = nodeEl?.querySelector('.cm-layer') as HTMLElement | null
251
+ if (!nodeEl || !bodyEl) return
252
+ const origin = bodyEl.getBoundingClientRect()
253
+ const s = origin.width ? origin.width / nodeEl.offsetWidth : 1 // on-screen px per world px
254
+ const toBody = (r: DOMRect) => ({ x: (r.left - origin.left) / s, y: (r.top - origin.top) / s, w: r.width / s, h: r.height / s })
255
+ if (c.active) c.setActive(null)
256
+ c.setDraft({ nodeKey, frame: frameId, anchor: noteAnchor(pick, root, kind, e, toBody) })
257
+ return
258
+ }
259
+ const link = target.closest('a[data-goto]')
260
+ if (link) { e.preventDefault(); e.stopPropagation(); goTo(link.getAttribute('data-goto') ?? '') }
261
+ }
262
+ return <div ref={ref} className="sh-sticky-body" onClick={onClick} />
263
+ }
264
+
265
+ /** The column for one node. `underBadge`: a variant badge owns the top of the gutter. */
266
+ export const Stickies = memo(function Stickies({ nodeKey, frameId, notes, underBadge }: { nodeKey: string; frameId: string; notes: NoteSpec[]; underBadge: boolean }) {
267
+ const ids = notes.map((n) => n.id)
268
+ const on = useNotes((s) => noteVisible(s, ids))
269
+ if (!notes.length) return null
270
+ const width = Math.max(...notes.map((n) => (n.kind === 'scene' ? SCENE_NOTE_W : NOTE_W)))
271
+ return (
272
+ <div className={`sh-notes${on ? '' : ' off'}${underBadge ? ' below-vbadge' : ''}`} data-node-notes={nodeKey} style={{ width }}>
273
+ <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
+ onPointerDown={(e) => e.stopPropagation()}
275
+ onClick={(e) => { e.stopPropagation(); useNotes.getState().toggle(ids) }} />
276
+ {notes.map((n) => (
277
+ <div key={n.id} className="sh-sticky sh-no-pan" data-sticky={n.kind} data-note={n.id} style={{ width: n.kind === 'scene' ? SCENE_NOTE_W : NOTE_W }}
278
+ onPointerDown={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}>
279
+ <StickyBody text={n.text} kind={n.kind} nodeKey={nodeKey} frameId={frameId} />
280
+ </div>
281
+ ))}
282
+ </div>
283
+ )
284
+ })