@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
@@ -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()
@@ -550,11 +552,18 @@ export function App() {
550
552
  // sh:measure does, so a stale ready never marks a reloading frame ready.
551
553
  const gen = el.src.match(/[?&]r=(\d+)/)?.[1] ?? ''
552
554
  if (String(data.gen ?? '') !== gen) return
555
+ // a document that loaded again on its own (a full reload) is a new source revision: the
556
+ // sleeping override of the previous document must not be trusted for this one
557
+ if (s.nodes.find((n) => n.key === nodeKey)?.status === 'ready') s.bumpRev(nodeKey)
553
558
  s.setStatus(nodeKey, 'ready')
554
559
  } else if (data.type === 'sh:error') {
555
560
  s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
556
561
  } else if (data.type === 'sh:exit-interact') {
557
562
  if (s.interact === nodeKey) setInteract(null)
563
+ } else if (data.type === 'sh:theme-applied') {
564
+ if (typeof data.theme === 'string') s.setThemeOn(nodeKey, data.theme)
565
+ } else if (data.type === 'sh:hmr') {
566
+ s.bumpRev(nodeKey)
558
567
  } else if (data.type === 'sh:measure') {
559
568
  // Generation guard: the sender echoes ITS document's URL rev; a
560
569
  // WindowProxy survives navigation, so a stale pre-navigation message would
@@ -592,17 +601,9 @@ export function App() {
592
601
  if (c.active) c.setActive(null)
593
602
  if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
594
603
  } else if (data.type === 'sh:go') {
595
- const target = String(data.target ?? '')
596
- const carry = s.interact === nodeKey // a goto from inside an interacting frame
597
- // CARRIES interact mode to the target - walking a flow must not eject you to
598
- // design mode at every hop (and must survive a board switch)
599
- const existing = s.nodes.find((n) => n.frame === target && !n.missing)
600
- if (existing) {
601
- gotoSeq++ // a local goto supersedes any cross-board one in flight
602
- select(existing.key)
603
- if (carry) setInteract(existing.key)
604
- setTimeout(() => canvasCtl.fitNode(existing.key), 50)
605
- } 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)
606
607
  } else if (data.type === 'sh:wheel') {
607
608
  // B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
608
609
  // interact target or play (the app scrolls itself - the parent stays authoritative).
@@ -674,6 +675,8 @@ export function App() {
674
675
  toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
675
676
  }
676
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 }
677
680
  // Shift+L = laser comment: the laser-sharp lighting on the element a comment tags
678
681
  // (pick hover, compose lock, open-thread highlight). Pins and cards stay - this
679
682
  // only dims the lighting inside the artwork.
@@ -1010,58 +1013,6 @@ function JamAvatar({ author }: { author: { name?: string; avatar?: string; id?:
1010
1013
  : <span className="sh-jam-ava init" style={{ background: `hsl(${hue} 55% 45%)` }}>{initials}</span>
1011
1014
  }
1012
1015
 
1013
- /** A data-goto whose target frame is not on the current board follows the frame HOME:
1014
- * the first curated board (switcher rank) that pins it is switched to and the frame
1015
- * focused there - a link is navigation, and navigation never edits a board. Only a
1016
- * frame NO board pins spawns onto the current board (the original prototype behavior
1017
- * for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
1018
- * bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
1019
- let gotoSeq = 0
1020
- async function gotoAcrossBoards(target: string, carry: boolean) {
1021
- const s = useStore.getState()
1022
- // an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
1023
- // must not send us on a trip that ends in a silent timeout
1024
- if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
1025
- const seq = ++gotoSeq
1026
- let home: string | null = null
1027
- try {
1028
- const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
1029
- for (const name of names) {
1030
- if ((await boardFrames(name)).includes(target)) { home = name; break }
1031
- }
1032
- } catch {
1033
- // a transport failure is NOT proof the frame is unpinned - spawning here would
1034
- // recreate the board mutation this function exists to prevent
1035
- return useStore.getState().toast(`goto: could not read the boards - try again`)
1036
- }
1037
- if (seq !== gotoSeq) return // superseded by newer navigation
1038
- if (!home) {
1039
- // no curated board pins it - the original prototype behavior: spawn beside you
1040
- const st = useStore.getState()
1041
- const node = st.spawn(target)
1042
- if (!node) return st.toast(`unknown goto target "${target}"`)
1043
- st.select(node.key)
1044
- if (carry) st.setInteract(node.key)
1045
- setTimeout(() => canvasCtl.fitNode(node.key), 50)
1046
- return
1047
- }
1048
- await s.switchBoard(home)
1049
- for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
1050
- if (seq !== gotoSeq) return
1051
- const st = useStore.getState()
1052
- if (st.board === home) { // a cancelled/failed switch must not select here
1053
- const node = st.nodes.find((n) => n.frame === target && !n.missing)
1054
- if (node) {
1055
- st.select(node.key)
1056
- if (carry) st.setInteract(node.key)
1057
- setTimeout(() => canvasCtl.fitNode(node.key), 50)
1058
- return
1059
- }
1060
- }
1061
- await new Promise((r) => setTimeout(r, 250))
1062
- }
1063
- }
1064
-
1065
1016
  /** View from a notification: threads are frame-scoped, so first try to reveal RIGHT HERE
1066
1017
  * (the current board may show the thread's frame); only when it doesn't, switch to the
1067
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)
@@ -935,8 +998,8 @@ export function CommentsController() {
935
998
  return () => window.removeEventListener('hashchange', onHash)
936
999
  }, [])
937
1000
 
938
- // broadcast pick mode to every LIVE frame (laser rides along inside the bridge). Scoped to
939
- // .sh-live so the lean cover (.sh-lean, a scriptless snapshot) is never messaged.
1001
+ // broadcast pick mode to every frame (laser rides along inside the bridge); a sleeping frame
1002
+ // is the live document itself, so it is messaged like any other
940
1003
  useEffect(() => {
941
1004
  for (const f of document.querySelectorAll('iframe.sh-live'))
942
1005
  (f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !ctlShowAnchor }, location.origin)
@@ -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)
@@ -246,8 +255,8 @@ export function Canvas() {
246
255
  if (!app || !canvas) return
247
256
  let settle = 0
248
257
  const beginGesture = (panning: boolean) => {
249
- // sh-camera = a CANVAS pan/zoom (drives the snapshot cover); sh-gesturing also drops iframe
250
- // pointer-events. A frame click/drag sets only sh-gesturing, so it never flashes a snapshot.
258
+ // sh-camera = a CANVAS pan/zoom (the image-LOD freeze signal); sh-gesturing also drops iframe
259
+ // pointer-events. A frame click/drag sets only sh-gesturing.
251
260
  document.getElementById('sh-world')?.classList.add('sh-gesturing', 'sh-camera')
252
261
  document.body.classList.toggle('sh-panning', panning)
253
262
  clearTimeout(settle)