@marver-design/marver 0.8.0 → 0.8.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 (48) hide show
  1. package/CHANGELOG.md +97 -5
  2. package/README.md +72 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-B8z902TW.mjs → build-BBVQRetk.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-pLGzSvm5.mjs → collab-s3k5byM1.mjs} +3 -3
  7. package/dist/{comments-DSwMhdbZ.mjs → comments-BZBKhKRO.mjs} +2 -2
  8. package/dist/{comments-BrpC86Kf.mjs → comments-J06jqCVV.mjs} +3 -3
  9. package/dist/{daemon-C-huGHAM.mjs → daemon-DkyNOwIt.mjs} +30 -51
  10. package/dist/{dev-BYm9ebfN.mjs → dev-BTAhTie-.mjs} +19 -5
  11. package/dist/{init-DrLomSWq.mjs → init-DWdhjJD5.mjs} +16 -3
  12. package/dist/{ledger-wFvEIEGi.mjs → ledger-CbzTJrV2.mjs} +1 -1
  13. package/dist/{manifest-D3eaARf4.mjs → manifest-B4zcDGBf.mjs} +3 -3
  14. package/dist/{plugin-BVFuRfEo.mjs → plugin-BdQEeTLg.mjs} +41 -9
  15. package/dist/{serve-CwAfayJk.mjs → serve-CZqPnj19.mjs} +32 -8
  16. package/dist/{sync-Ch4Bymb1.mjs → sync-BJKKmy1n.mjs} +2 -2
  17. package/dist/work-CLrmY-vQ.mjs +97 -0
  18. package/dist/work-lzC-lPY0.mjs +76 -0
  19. package/package.json +16 -1
  20. package/src/client/const.ts +1 -1
  21. package/src/client/content/diagram.tsx +1 -1
  22. package/src/client/content/index.tsx +2 -2
  23. package/src/client/content/md.ts +1 -1
  24. package/src/client/content/palette.ts +2 -2
  25. package/src/client/frame-host/bridge.js +1 -1
  26. package/src/client/frame-host/inspect.js +1 -1
  27. package/src/client/frame-host/serialize.ts +2 -2
  28. package/src/client/shell/App.tsx +79 -22
  29. package/src/client/shell/Comments.tsx +24 -18
  30. package/src/client/shell/Play.tsx +2 -2
  31. package/src/client/shell/canvas/Canvas.tsx +1 -1
  32. package/src/client/shell/canvas/FrameNode.tsx +16 -11
  33. package/src/client/shell/canvas/snapshots.ts +3 -3
  34. package/src/client/shell/comments-store.ts +115 -42
  35. package/src/client/shell/hash.ts +2 -2
  36. package/src/client/shell/icons.tsx +1 -1
  37. package/src/client/shell/keys.ts +39 -0
  38. package/src/client/shell/mentions.ts +1 -1
  39. package/src/client/shell/perf.ts +1 -1
  40. package/src/client/shell/store.ts +99 -39
  41. package/src/client/shell/styles.css +12 -12
  42. package/src/client/shell/tidy.ts +5 -5
  43. package/src/client/stage/main.tsx +2 -2
  44. package/src/shared/events.ts +2 -2
  45. package/src/shared/utm.ts +22 -0
  46. package/templates/AGENTS-embedded.md +48 -1
  47. package/templates/AGENTS-studio.md +48 -1
  48. package/templates/instructions/publish.md +1 -1
@@ -1,5 +1,5 @@
1
1
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
- import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
2
+ import { useStore, CONFIG, PUBLISHED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
3
3
  import { Tip } from './Tip.tsx'
4
4
  import { PKG, ROUTE } from '../const.ts'
5
5
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
@@ -8,6 +8,7 @@ import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
8
  import { bootHash, parseHash, writeHash } from './hash.ts'
9
9
  import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
10
10
  import { CommentsController, revealThread } from './Comments.tsx'
11
+ import { poweredByUrl } from '../../shared/utm.ts'
11
12
  import { useComments } from './comments-store.ts'
12
13
  import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
13
14
 
@@ -126,7 +127,7 @@ function SelectionBar() {
126
127
  // frame top - the caption offset (8px screen, world-capped) - the caption's height
127
128
  // (screen-clamped 12..18px font) - the bar. Gate on the TOP edge of the selection,
128
129
  // not the last-selected frame: a mixed selection whose topmost frames are variants
129
- // still has a caption to clear (codex final review P2).
130
+ // still has a caption to clear.
130
131
  const capAtTop = selNodes.some((n) => n.y === by0 && useStore.getState().frameFor(n)?.variantGroup)
131
132
  const rawTop = capAtTop
132
133
  ? `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - clamp(4px * var(--sh-s, 1), 8px, 40px * var(--sh-s, 1)) - (clamp(12px, 17px * var(--sh-s, 1), 18px) * 1.4) - 44px)`
@@ -309,14 +310,14 @@ export function App() {
309
310
  const selection = useStore((s) => s.selection)
310
311
  const laser = useStore((s) => s.laser)
311
312
  const commentMode = useComments((s) => s.commentMode)
312
- // SPEC-M5: laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
313
+ // Laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
313
314
  // lean cover would hide them, so a body class suppresses the cover while either mode is active.
314
315
  useEffect(() => { document.body.classList.toggle('sh-laser', laser) }, [laser])
315
316
  useEffect(() => { document.body.classList.toggle('sh-commenting', commentMode) }, [commentMode])
316
317
  const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
317
318
  const [pillOpen, setPillOpen] = useState(true)
318
319
 
319
- // boot honors the deep link (SPEC-M2 §3): board before load, play mode after it.
320
+ // boot honors the deep link: board before load, play mode after it.
320
321
  // Selection + camera intent are restored by the Canvas boot effect. The module-level
321
322
  // guard makes boot single-shot: Fast Refresh re-runs mount effects on every App edit,
322
323
  // and a re-boot would revert live state to the long-consumed deep link.
@@ -442,7 +443,7 @@ export function App() {
442
443
  useEffect(() => {
443
444
  if (!import.meta.hot) return
444
445
  import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
445
- // Live Jam presence (SPEC §10): the daemon broadcasts the set of frames Marver is editing.
446
+ // Live Jam presence: the daemon broadcasts the set of frames Marver is editing.
446
447
  // Camera-safe by construction - this only toggles a glow class, never moves the view.
447
448
  import.meta.hot.on('sh:jam-activity', (m: any) => useStore.getState().setWorking(Array.isArray(m?.frames) ? m.frames.filter((x: unknown) => typeof x === 'string') : []))
448
449
  // Live Jam reply delivery: the daemon just wrote to a board's log - fetch it NOW instead of
@@ -457,7 +458,7 @@ export function App() {
457
458
  })
458
459
  // multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
459
460
  // re-boots silently, keeping whatever selection survives; a dirty one keeps its
460
- // edits and converges through the 409 path on its next save (disk wins, spec §8).
461
+ // edits and converges through the 409 path on its next save (disk wins).
461
462
  import.meta.hot.on('sh:board', (m: any) => {
462
463
  const s = useStore.getState()
463
464
  if (m?.name !== s.board || m?.sha256 === s.boardHash || s.dirty) return
@@ -470,7 +471,7 @@ export function App() {
470
471
  })
471
472
  }, [])
472
473
 
473
- // frame -> shell messages (spec §6 protocol); source validated against known iframes
474
+ // frame -> shell messages; source validated against known iframes
474
475
  useEffect(() => {
475
476
  const onMsg = (e: MessageEvent) => {
476
477
  const data = e.data
@@ -490,7 +491,7 @@ export function App() {
490
491
  } else if (data.type === 'sh:exit-interact') {
491
492
  if (s.interact === nodeKey) setInteract(null)
492
493
  } else if (data.type === 'sh:measure') {
493
- // SPEC-026 generation guard: the sender echoes ITS document's URL rev; a
494
+ // Generation guard: the sender echoes ITS document's URL rev; a
494
495
  // WindowProxy survives navigation, so a stale pre-navigation message would
495
496
  // otherwise route as if it came from the current document. Compare against
496
497
  // the iframe's CURRENT src - mismatched generations are dropped.
@@ -526,14 +527,16 @@ export function App() {
526
527
  if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
527
528
  } else if (data.type === 'sh:go') {
528
529
  const target = String(data.target ?? '')
530
+ const carry = s.interact === nodeKey // a goto from inside an interacting frame
531
+ // CARRIES interact mode to the target - walking a flow must not eject you to
532
+ // design mode at every hop (and must survive a board switch)
529
533
  const existing = s.nodes.find((n) => n.frame === target && !n.missing)
530
- const node = existing ?? spawn(target)
531
- if (!node) return toast(`unknown goto target "${target}"`)
532
- select(node.key)
533
- // a goto from inside an interacting frame CARRIES interact mode to the target -
534
- // walking a flow must not eject you to design mode at every hop
535
- if (s.interact === nodeKey) setInteract(node.key)
536
- setTimeout(() => canvasCtl.fitNode(node.key), 50)
534
+ if (existing) {
535
+ gotoSeq++ // a local goto supersedes any cross-board one in flight
536
+ select(existing.key)
537
+ if (carry) setInteract(existing.key)
538
+ setTimeout(() => canvasCtl.fitNode(existing.key), 50)
539
+ } else void gotoAcrossBoards(target, carry)
537
540
  } else if (data.type === 'sh:wheel') {
538
541
  // B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
539
542
  // interact target or play (the app scrolls itself - the parent stays authoritative).
@@ -590,7 +593,7 @@ export function App() {
590
593
  if (!s.laser) commentsStore().setMode(false)
591
594
  s.setLaser(!s.laser)
592
595
  }
593
- // C = comment mode (SPEC-M3 §6, the Figma/Miro convention) · Shift+C = hide/show
596
+ // C = comment mode (the Figma/Miro convention) · Shift+C = hide/show
594
597
  // pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
595
598
  if (e.key === 'c' && !e.shiftKey) {
596
599
  const c = commentsStore()
@@ -695,7 +698,7 @@ export function App() {
695
698
  {/* the mark links to the marver site; the title is the humanized repo name (C2: names
696
699
  the project so two concurrent canvases are never confused), ellipsed if long */}
697
700
  <Tip side="bottom" label="marver.design">
698
- <a className="mark-link" href="https://marver.design" target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
701
+ <a className="mark-link" href={poweredByUrl(CONFIG.projectName, PUBLISHED ? 'published-canvas' : 'dev-canvas', 'shell')} target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
699
702
  <ParallelogramDuoIcon size={21} className="mark" />
700
703
  </a>
701
704
  </Tip>
@@ -716,7 +719,7 @@ export function App() {
716
719
  canvasCtl.fitNodes(keys)
717
720
  }}>
718
721
  {(() => {
719
- // variant groups render as ONE surface row with A/B/C chips (SPEC-023 §5)
722
+ // variant groups render as ONE surface row with A/B/C chips
720
723
  const nodeFor = (id: string) => nodes.find((x) => x.frame === id && !x.missing) ?? nodes.find((x) => x.frame === id)
721
724
  // sidebar order follows the CANVAS (reading order: rows top-to-bottom,
722
725
  // left-to-right) - a story board's list must tell the same story as its
@@ -849,7 +852,7 @@ export function App() {
849
852
  )
850
853
  }
851
854
 
852
- /** The bottom-right notification corner (SPEC-live-jam §9). Plain toasts render as before.
855
+ /** The bottom-right notification corner. Plain toasts render as before.
853
856
  * Jam pills are FRAME-FIRST (icon + frame title, then Marver · preview) and stack as a DECK:
854
857
  * 1-2 show in full; 3+ collapse to the newest pill with two card edges peeking beneath and a
855
858
  * +N badge - click to expand the full list (newest first, timestamps, Clear all). */
@@ -886,10 +889,64 @@ function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
886
889
  )
887
890
  }
888
891
 
889
- /** View from a notification: switch to the note's board first when needed (the thread only exists
890
- * in that board's state), then reveal - retrying briefly while the board's comments load. Dismiss
891
- * only on success: a cross-board note must never be a destructive no-op. */
892
+ /** A data-goto whose target frame is not on the current board follows the frame HOME:
893
+ * the first curated board (switcher rank) that pins it is switched to and the frame
894
+ * focused there - a link is navigation, and navigation never edits a board. Only a
895
+ * frame NO board pins spawns onto the current board (the original prototype behavior
896
+ * for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
897
+ * bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
898
+ let gotoSeq = 0
899
+ async function gotoAcrossBoards(target: string, carry: boolean) {
900
+ const s = useStore.getState()
901
+ // an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
902
+ // must not send us on a trip that ends in a silent timeout
903
+ if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
904
+ const seq = ++gotoSeq
905
+ let home: string | null = null
906
+ try {
907
+ const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
908
+ for (const name of names) {
909
+ if ((await boardFrames(name)).includes(target)) { home = name; break }
910
+ }
911
+ } catch {
912
+ // a transport failure is NOT proof the frame is unpinned - spawning here would
913
+ // recreate the board mutation this function exists to prevent
914
+ return useStore.getState().toast(`goto: could not read the boards - try again`)
915
+ }
916
+ if (seq !== gotoSeq) return // superseded by newer navigation
917
+ if (!home) {
918
+ // no curated board pins it - the original prototype behavior: spawn beside you
919
+ const st = useStore.getState()
920
+ const node = st.spawn(target)
921
+ if (!node) return st.toast(`unknown goto target "${target}"`)
922
+ st.select(node.key)
923
+ if (carry) st.setInteract(node.key)
924
+ setTimeout(() => canvasCtl.fitNode(node.key), 50)
925
+ return
926
+ }
927
+ await s.switchBoard(home)
928
+ for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
929
+ if (seq !== gotoSeq) return
930
+ const st = useStore.getState()
931
+ if (st.board === home) { // a cancelled/failed switch must not select here
932
+ const node = st.nodes.find((n) => n.frame === target && !n.missing)
933
+ if (node) {
934
+ st.select(node.key)
935
+ if (carry) st.setInteract(node.key)
936
+ setTimeout(() => canvasCtl.fitNode(node.key), 50)
937
+ return
938
+ }
939
+ }
940
+ await new Promise((r) => setTimeout(r, 250))
941
+ }
942
+ }
943
+
944
+ /** View from a notification: threads are frame-scoped, so first try to reveal RIGHT HERE
945
+ * (the current board may show the thread's frame); only when it doesn't, switch to the
946
+ * note's origin board and retry while its comments load. Dismiss only on success: a
947
+ * cross-board note must never be a destructive no-op. */
892
948
  async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void) {
949
+ if (revealThread(note.threadId)) { dismiss(); return }
893
950
  const s = useStore.getState()
894
951
  if (note.board && s.board !== note.board) await s.switchBoard(note.board)
895
952
  for (let i = 0; i < 12; i++) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Comment surfaces on the canvas (SPEC-M3 §6). Canvas-first: pins live ON the frame
2
+ * Comment surfaces on the canvas. Canvas-first: pins live ON the frame
3
3
  * at their anchored elements; an inactive frame collapses its open threads into a
4
4
  * top-right stack; the thread card opens beside its pin. Pins keep a screen-space
5
5
  * size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
@@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react'
8
8
  import { createPortal } from 'react-dom'
9
9
  import { avatarFallback, useComments } from './comments-store.ts'
10
10
  import { useStore, type Node } from './store.ts'
11
+ import { threadHostKey } from './keys.ts'
11
12
  import { canvasCtl } from './canvas/Canvas.tsx'
12
13
  import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
13
14
  import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
@@ -120,7 +121,11 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
120
121
  // select the stable array, filter in render - a selector returning a fresh
121
122
  // .filter() array re-renders forever (Object.is never matches)
122
123
  const allThreads = useComments((s) => s.threads)
123
- const threads = allThreads.filter((t) => t.frame === frameId && (!t.nodeKey || t.nodeKey === node.key))
124
+ const allNodes = useStore((s) => s.nodes)
125
+ // one owner per thread (keys.ts): the stored anchor when it still holds, else the
126
+ // first node showing the frame - a stale key (pre-0.8.1 random keys, board rewrites,
127
+ // deleted copies) degrades to the frame, never to invisible, and never to two pins
128
+ const threads = allThreads.filter((t) => t.frame === frameId && threadHostKey(t, allNodes) === node.key)
124
129
  const selected = useStore((s) => s.selection.includes(node.key))
125
130
  const { setActive } = useComments.getState()
126
131
  const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
@@ -167,7 +172,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
167
172
 
168
173
  // the open card's frozen side (D3) - a HOOK, so it must live above the early returns; and the
169
174
  // reset must too: a card can close via the inactive-frame early return (clicking another frame),
170
- // which never reaches the render tail - reopen must still RE-PICK the side (Codex P2)
175
+ // which never reaches the render tail - reopen must still RE-PICK the side
171
176
  const sideRef = useRef<{ id: string; side: 'l' | 'r' } | null>(null)
172
177
  if (sideRef.current && sideRef.current.id !== active) sideRef.current = null
173
178
  // what occupies this frame's LEFT flank (drives the docked card's left gutter): a variant
@@ -177,7 +182,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
177
182
  const drafting = draft?.nodeKey === node.key
178
183
  if (!show || (!open.length && !drafting)) return null
179
184
 
180
- // inactive frame: the stack - count + avatars, top-right (SPEC-M3 §6)
185
+ // inactive frame: the stack - count + avatars, top-right
181
186
  const engaged = selected || open.some((t) => t.id === active) || drafting
182
187
  if (!engaged && open.length) {
183
188
  return (
@@ -247,7 +252,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
247
252
 
248
253
  /** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and
249
254
  * prototype (bounds = the on-screen stage size, `at` in screen coords). Geometry only. */
250
- // ---- Live Jam: @marver rendering + Marver identity (SPEC-live-jam §1, §7) ----------------------
255
+ // ---- Live Jam: @marver rendering + Marver identity ----------------------
251
256
 
252
257
  const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
253
258
 
@@ -273,7 +278,7 @@ function prettyModel(m: string): string {
273
278
  return cleaned.replace(/\b\w/g, (c) => c.toUpperCase())
274
279
  }
275
280
 
276
- /** The provenance tooltip on the Marver avatar: who orchestrated the change (SPEC §7).
281
+ /** The provenance tooltip on the Marver avatar: who orchestrated the change.
277
282
  * One row per fact, left-aligned, bold label + regular value - the house tooltip treatment. */
278
283
  function AgentMetaTip({ meta }: { meta?: AgentMeta }) {
279
284
  const rows = [
@@ -346,7 +351,7 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
346
351
  return (
347
352
  <div className="cm-inputwrap">
348
353
  {/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver shows
349
- its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted). SPEC §1. */}
354
+ its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted). */}
350
355
  <div className="cm-hl" ref={hlRef} aria-hidden>
351
356
  {parseMentions(value).map((s, i) => s.mention
352
357
  ? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
@@ -511,12 +516,12 @@ export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, sta
511
516
  return () => ro.disconnect()
512
517
  }, [])
513
518
  // ...and after EVERY render: an edited message changes scrollHeight without resizing the
514
- // height-capped box or the reply count (Codex P3). setShadows dedups, so this is free.
519
+ // height-capped box or the reply count. setShadows dedups, so this is free.
515
520
  useEffect(syncShadows)
516
521
  // PLAY: one static centered device, no zoom - the card docks FIXED beside it on the pin's side
517
522
  // (falling back to the roomier side), fully viewport-clamped so the composer is ALWAYS reachable
518
- // whatever the device layout or theme (SPEC §14). Recomputed when the stage moves under it:
519
- // a device/theme switch resizes .sh-play-stage and a window resize shifts it (Codex P2).
523
+ // whatever the device layout or theme. Recomputed when the stage moves under it:
524
+ // a device/theme switch resizes .sh-play-stage and a window resize shifts it.
520
525
  const computeStagePos = (): React.CSSProperties | null => {
521
526
  if (!stage) return null
522
527
  const W = 320, M = 12, maxH = Math.min(660, window.innerHeight - 2 * M)
@@ -542,7 +547,7 @@ export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, sta
542
547
  return () => { window.removeEventListener('resize', re); ro?.disconnect() }
543
548
  }, [stage, bounds.w, bounds.h])
544
549
  const flip = !float && !stage && at.x > bounds.w * 0.55
545
- // Nic's revision: the card scales EXACTLY like the pins - screen-constant via --sh-inv, pure
550
+ // The card scales EXACTLY like the pins - screen-constant via --sh-inv, pure
546
551
  // CSS, smooth per zoom tick with zero re-renders. Geometry in CSS math over the live vars:
547
552
  // top: grows DOWN from the pin, then UP once its bottom reaches the frame's bottom; floor
548
553
  // at -28 (the layer starts below the header - -28 = the frame's TOP border).
@@ -773,12 +778,13 @@ export function IdentityDialog() {
773
778
  export function revealThread(c: string): boolean {
774
779
  const t = useComments.getState().threads.find((t) => t.id === c)
775
780
  if (!t) return false
781
+ // resolve the host BEFORE activating: a thread whose frame is not on this board
782
+ // must not consume the deep link (the board switch that CAN show it comes later)
783
+ const key = threadHostKey(t, useStore.getState().nodes)
784
+ if (!key) return false
776
785
  useComments.getState().setActive(c)
777
- const node = t.nodeKey && useStore.getState().nodes.find((n) => n.key === t.nodeKey)
778
- if (node) {
779
- useStore.getState().select(node.key)
780
- setTimeout(() => canvasCtl.fitNode(node.key), 80)
781
- }
786
+ useStore.getState().select(key)
787
+ setTimeout(() => canvasCtl.fitNode(key), 80)
782
788
  return true
783
789
  }
784
790
 
@@ -815,7 +821,7 @@ export function CommentsController() {
815
821
  // a cross-board deep link parks its thread id here until the target board's
816
822
  // comments have actually loaded - a timer can't know how long that takes
817
823
  if (pendingThread && revealThread(pendingThread)) { pendingThread = null; return }
818
- // deep link ?c=<thread> (SPEC-M3 §6): open the thread, select its node, fit it.
824
+ // deep link ?c=<thread>: open the thread, select its node, fit it.
819
825
  // Consumed only on a successful find: the controller's first effect fires with
820
826
  // the pre-boot board (child effects run before the parent's boot effect), and
821
827
  // burning the flag on that empty pass would eat the link.
@@ -849,7 +855,7 @@ export function CommentsController() {
849
855
  }, [])
850
856
 
851
857
  // broadcast pick mode to every LIVE frame (laser rides along inside the bridge). Scoped to
852
- // .sh-live so the SPEC-M5 lean cover (.sh-lean, a scriptless snapshot) is never messaged.
858
+ // .sh-live so the lean cover (.sh-lean, a scriptless snapshot) is never messaged.
853
859
  useEffect(() => {
854
860
  for (const f of document.querySelectorAll('iframe.sh-live'))
855
861
  (f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !ctlShowAnchor }, location.origin)
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Play mode (SPEC-M2 §1): full-window near-black backdrop, ONE device shell centered at
2
+ * Play mode: full-window near-black backdrop, ONE device shell centered at
3
3
  * the chosen viewport's exact CSS pixels, scaled to fit - or `fill`, where the frame IS
4
4
  * the window. The device hosts a single stage iframe that swaps frames in place.
5
5
  *
@@ -281,7 +281,7 @@ function PlayInner() {
281
281
  }
282
282
  const restart = () => { const list = playList(); if (list.length) goTo(list[0]) }
283
283
 
284
- /** Variant siblings of the CURRENT frame present on this board (SPEC-023 §6): the
284
+ /** Variant siblings of the CURRENT frame present on this board: the
285
285
  * review question is "which direction is better on THIS screen" - switch in place,
286
286
  * device and theme preserved, each variant's own data-goto links drive after. */
287
287
  const variantList = () => {
@@ -100,7 +100,7 @@ export const canvasCtl = {
100
100
  wheel(_input: CanvasWheelInput) {},
101
101
  }
102
102
 
103
- /** Variant-group captions (SPEC-023 §4): "Landing · 3 variants" above each group with
103
+ /** Variant-group captions: "Landing · 3 variants" above each group with
104
104
  * 2+ members on this board. World-space (scales with the canvas); min screen size via
105
105
  * --sh-inv. Groups with one lone member on a curated board keep the badge, no caption. */
106
106
  function GroupCaptions() {
@@ -3,6 +3,7 @@ import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
3
  import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, XIcon } from '../icons.tsx'
4
4
  import { CommentLayer } from '../Comments.tsx'
5
5
  import { useComments } from '../comments-store.ts'
6
+ import { threadHostKey } from '../keys.ts'
6
7
  import { registerFrame, unregisterFrame } from './frame-registry.ts'
7
8
  import { primeCameraFor } from './camera-broadcast.ts'
8
9
  import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
@@ -10,7 +11,7 @@ import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from
10
11
  export const HEADER = 28
11
12
  const SNAP = 12
12
13
 
13
- /** Live Jam working shimmer (SPEC §10): a slim 2x6 strip of tiny marver marks on the frame's left
14
+ /** Live Jam working shimmer: a slim 2x6 strip of tiny marver marks on the frame's left
14
15
  * flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
15
16
  * --mv-w0 (set on the node) so parallel frames never pulse in sync. */
16
17
  const SHIM_DELAYS = Array.from({ length: 12 }, (_, i) => {
@@ -29,7 +30,7 @@ function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
29
30
  }
30
31
 
31
32
  /**
32
- * One frame on the canvas. Iframe laws (spec §7): the iframe element is created once per node key
33
+ * One frame on the canvas. Iframe laws: the iframe element is created once per node key
33
34
  * and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
34
35
  *
35
36
  * Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
@@ -88,7 +89,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
88
89
  registerWin()
89
90
  }, [node.key])
90
91
 
91
- // SPEC-M5: register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
92
+ // Register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
92
93
  const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
93
94
  // capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
94
95
  // (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
@@ -107,7 +108,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
107
108
  // otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
108
109
  // hostsCard rail recaptures a clean lean once the card closes.
109
110
  const c = useComments.getState()
110
- const hosting = (!!c.active && c.threads.some((th) => th.id === c.active && th.nodeKey === node.key && !th.resolved)) || c.draft?.nodeKey === node.key
111
+ // hosting goes through the resolver (keys.ts): an adopted thread's card renders
112
+ // here even though its stored nodeKey names a node that no longer exists
113
+ const hosting = (!!c.active && c.threads.some((th) =>
114
+ th.id === c.active && !th.resolved && threadHostKey(th, useStore.getState().nodes) === node.key)) || c.draft?.nodeKey === node.key
111
115
  if (hosting) return
112
116
  scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
113
117
  }, 450)
@@ -132,7 +136,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
132
136
  scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
133
137
  }, [interact, node.key, node.nav, node.theme, node.status])
134
138
 
135
- // laser mode (SPEC-M3 §7) rides the same rail; re-sent when a frame becomes ready
139
+ // laser mode rides the same rail; re-sent when a frame becomes ready
136
140
  // so late loaders join an already-lasered board
137
141
  const laser = useStore((s) => s.laser)
138
142
  const commentMode = useComments((s) => s.commentMode)
@@ -140,7 +144,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
140
144
  // neighbors - each node is a stacking context, so an overflowing card would
141
145
  // otherwise paint under the next frame
142
146
  const hostsCard = useComments((s) =>
143
- (!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key && !t.resolved)) || s.draft?.nodeKey === node.key)
147
+ (!!s.active && s.threads.some((t) =>
148
+ t.id === s.active && !t.resolved && threadHostKey(t, useStore.getState().nodes) === node.key)) || s.draft?.nodeKey === node.key)
144
149
  // a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
145
150
  // cover: the active-element highlight lives in the live DOM and updates in real time
146
151
  // (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
@@ -199,7 +204,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
199
204
  if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
200
205
  }, [node.nav])
201
206
 
202
- // ready timeout (spec §7): 10s without sh:ready -> error card with reload
207
+ // ready timeout: 10s without sh:ready -> error card with reload
203
208
  useEffect(() => {
204
209
  if (node.status !== 'loading') return
205
210
  const t = setTimeout(() => setStatus(node.key, 'error', 'frame never reported ready (10s)'), 10_000)
@@ -302,7 +307,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
302
307
  )
303
308
  }
304
309
 
305
- // variant badge (SPEC-023 §4): letter + name floating LEFT of the frame, outside the
310
+ // variant badge: letter + name floating LEFT of the frame, outside the
306
311
  // artwork, world-anchored (scales with zoom) with a screen-space minimum via --sh-inv
307
312
  const variantName = frame.title
308
313
  ?? cap((frame.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
@@ -312,7 +317,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
312
317
  className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}${working ? ' working' : ''}`}
313
318
  data-theme={node.theme}
314
319
  style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined,
315
- // phase every working animation by when THIS frame's job started (SPEC §10) - parallel
320
+ // phase every working animation by when THIS frame's job started - parallel
316
321
  // frames pulsing in sync would read as one fake choreography
317
322
  ...(working ? { ['--mv-w0' as string]: `${-(Date.now() - (workingSince ?? Date.now()))}ms` } : {}) }}
318
323
  data-node={node.key}
@@ -327,7 +332,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
327
332
  </div>
328
333
  )}
329
334
  <div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
330
- {/* content frames carry their intent glyph in the chrome (SPEC-026) */}
335
+ {/* content frames carry their intent glyph in the chrome */}
331
336
  {frame.intent && <IntentGlyph intent={frame.intent} size={12} className="iicon sh-no-pan" aria-label={frame.intent} />}
332
337
  <span className="id sh-no-pan">{frame.title ?? frame.id}</span>
333
338
  <span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
@@ -357,7 +362,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
357
362
  onLoad={registerWin}
358
363
  style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
359
364
  />
360
- {/* SPEC-M5 lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
365
+ {/* Lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
361
366
  the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
362
367
  device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
363
368
  so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SPEC-M5 slice 1: the lean-frame facade coordinator. Imperative on purpose - the facade is a
2
+ * The lean-frame facade coordinator. Imperative on purpose - the facade is a
3
3
  * `<iframe class="sh-lean" sandbox="allow-same-origin">` driven by setting .srcdoc directly, so no
4
4
  * FrameNode subscribes to snapshot state and a pan/zoom tick triggers zero React renders.
5
5
  *
@@ -13,7 +13,7 @@
13
13
  * INVALIDATE the lean (never mutate a displayed one - baked mermaid can't re-theme in place) and a
14
14
  * fresh capture is admitted before it is shown again. Captures run bounded-parallel, viewport-first, at idle, never while busy.
15
15
  *
16
- * Correctness beats the flash-guard (codex): a frame the serialiser cannot render faithfully
16
+ * Correctness beats the flash-guard: a frame the serialiser cannot render faithfully
17
17
  * (canvas/video/shadow-dom/cross-origin-css, or an unrestorable scroller) is left DEGRADED - no
18
18
  * `data-ready`, no cover, live pixels stay. Every install is guarded by a per-node GENERATION token
19
19
  * so an in-flight capture can never paint a stale/wrong-node cover after a reload or unmount.
@@ -87,7 +87,7 @@ function install(nodeKey: string): void {
87
87
  const doc = iframe.contentDocument
88
88
  if (!cur || !doc) return
89
89
  if (!restoreScroll(doc, cur.scrollMap)) { cur.degraded = [...cur.degraded, 'scroll']; iframe.removeAttribute('srcdoc'); return }
90
- // CSP guard (codex): if a hardened host blocked the inline <style> (style-src 'self'), the lean is
90
+ // CSP guard: if a hardened host blocked the inline <style> (style-src 'self'), the lean is
91
91
  // unstyled - the sentinel custom prop won't resolve. Stay live rather than show an unstyled cover.
92
92
  if (getComputedStyle(doc.documentElement).getPropertyValue('--mv-lean-ok').trim() !== '1') { iframe.removeAttribute('srcdoc'); return }
93
93
  applyTheme(doc, cur.theme)