@marver-design/marver 0.7.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 (54) hide show
  1. package/CHANGELOG.md +141 -3
  2. package/README.md +72 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
  7. package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
  8. package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
  9. package/dist/daemon-DkyNOwIt.mjs +878 -0
  10. package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
  11. package/dist/events-BMtBvvgU.mjs +101 -0
  12. package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
  13. package/dist/ledger-CbzTJrV2.mjs +64 -0
  14. package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
  15. package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
  16. package/dist/profile-BkiWglVE.mjs +39 -0
  17. package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
  18. package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
  19. package/dist/work-CLrmY-vQ.mjs +97 -0
  20. package/dist/work-lzC-lPY0.mjs +76 -0
  21. package/package.json +16 -1
  22. package/src/client/const.ts +1 -1
  23. package/src/client/content/diagram.tsx +1 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +1 -1
  26. package/src/client/content/palette.ts +2 -2
  27. package/src/client/frame-host/bridge.js +1 -1
  28. package/src/client/frame-host/inspect.js +16 -3
  29. package/src/client/frame-host/serialize.ts +2 -2
  30. package/src/client/shell/App.tsx +165 -26
  31. package/src/client/shell/Comments.tsx +434 -78
  32. package/src/client/shell/Play.tsx +12 -8
  33. package/src/client/shell/Toolbar.tsx +14 -2
  34. package/src/client/shell/canvas/Canvas.tsx +1 -1
  35. package/src/client/shell/canvas/FrameNode.tsx +47 -15
  36. package/src/client/shell/canvas/snapshots.ts +3 -3
  37. package/src/client/shell/comments-store.ts +147 -33
  38. package/src/client/shell/hash.ts +2 -2
  39. package/src/client/shell/icons.tsx +5 -1
  40. package/src/client/shell/keys.ts +39 -0
  41. package/src/client/shell/mentions.ts +18 -0
  42. package/src/client/shell/perf.ts +1 -1
  43. package/src/client/shell/store.ts +127 -39
  44. package/src/client/shell/styles.css +303 -30
  45. package/src/client/shell/tidy.ts +5 -5
  46. package/src/client/stage/main.tsx +2 -2
  47. package/src/shared/events.ts +23 -6
  48. package/src/shared/utm.ts +22 -0
  49. package/templates/AGENTS-embedded.md +49 -1
  50. package/templates/AGENTS-studio.md +49 -1
  51. package/templates/instructions/configure.md +15 -0
  52. package/templates/instructions/jam.md +85 -0
  53. package/templates/instructions/publish.md +1 -1
  54. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -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
  *
@@ -125,6 +125,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
125
125
  dw: number; dh: number; ready: number
126
126
  }) {
127
127
  const show = useComments((s) => s.show)
128
+ const showAnchor = useComments((s) => s.showAnchor)
128
129
  const active = useComments((s) => s.active)
129
130
  const draft = useComments((s) => s.draft)
130
131
  const allThreads = useComments((s) => s.threads)
@@ -165,11 +166,12 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
165
166
  useEffect(() => {
166
167
  const win = iframe.current?.contentWindow
167
168
  if (!win) return
168
- // only while the thread is active (or composing) AND pins are shown - closing or ⇧C clears it
169
- const at = !show ? null : (draft?.frame === frameId ? draft.anchor
169
+ // only while the thread is active (or composing) AND pins are shown AND element focus is
170
+ // on (⇧L) - closing, ⇧C, or dimming focus clears it
171
+ const at = !show || !showAnchor ? null : (draft?.frame === frameId ? draft.anchor
170
172
  : (active ? (threads.find((t) => t.id === active && (t.anchor as any)?.el)?.anchor ?? null) : null))
171
173
  win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: at ?? null }, location.origin)
172
- }, [active, draft, show, frameId, iframe, rects, ready])
174
+ }, [active, draft, show, showAnchor, frameId, iframe, rects, ready])
173
175
 
174
176
  if (!show) return null
175
177
 
@@ -201,7 +203,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
201
203
  </div>
202
204
  )
203
205
  })}
204
- {activeThread && <ThreadCard key={active!} thread={activeThread} at={pinAt(activeThread)} bounds={{ w: dw, h: dh }} />}
206
+ {activeThread && <ThreadCard key={active!} thread={activeThread} at={pinAt(activeThread)} bounds={{ w: dw, h: dh }} stage />}
205
207
  {draftAt && <DraftComposer at={draftAt} bounds={{ w: dw, h: dh }} hue={(draft?.anchor as any)?.el?.hue} />}
206
208
  </div>
207
209
  )
@@ -218,6 +220,7 @@ function PlayInner() {
218
220
  const playNav = useStore((s) => s.playNav)
219
221
  const laser = useStore((s) => s.laser)
220
222
  const commentMode = useComments((s) => s.commentMode)
223
+ const playShowAnchor = useComments((s) => s.showAnchor)
221
224
  const iframeRef = useRef<HTMLIFrameElement>(null)
222
225
  // the src is frozen at mount - navigation happens INSIDE the stage; device and theme
223
226
  // changes must never reload it (a phone does not remount when you flip dark mode)
@@ -244,7 +247,7 @@ function PlayInner() {
244
247
  // laser / comment ride the same rail as canvas frames, broadcast to the stage iframe.
245
248
  // Re-sent on stage-ready (below) so a stage reload restores the mode.
246
249
  useEffect(() => { postStage({ type: 'sh:laser', on: laser }) }, [laser])
247
- useEffect(() => { postStage({ type: 'sh:pick', on: commentMode }) }, [commentMode])
250
+ useEffect(() => { postStage({ type: 'sh:pick', on: commentMode, quiet: !useComments.getState().showAnchor }) }, [commentMode, playShowAnchor])
248
251
 
249
252
  const exit = () => {
250
253
  const { at } = useStore.getState().play ?? {}
@@ -278,7 +281,7 @@ function PlayInner() {
278
281
  }
279
282
  const restart = () => { const list = playList(); if (list.length) goTo(list[0]) }
280
283
 
281
- /** 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
282
285
  * review question is "which direction is better on THIS screen" - switch in place,
283
286
  * device and theme preserved, each variant's own data-goto links drive after. */
284
287
  const variantList = () => {
@@ -333,7 +336,7 @@ function PlayInner() {
333
336
  postStage({ type: 'sh:set-theme', theme: p.theme })
334
337
  }
335
338
  postStage({ type: 'sh:laser', on: s.laser })
336
- postStage({ type: 'sh:pick', on: commentsStore().commentMode })
339
+ postStage({ type: 'sh:pick', on: commentsStore().commentMode, quiet: !commentsStore().showAnchor })
337
340
  setStageReady((n) => n + 1) // a reload wiped the frame's lock; let PlayComments re-drive it
338
341
  } else if (data.type === 'sh:stage-at') {
339
342
  const p = s.play
@@ -397,6 +400,7 @@ function PlayInner() {
397
400
  if (key === 'l') { const s = useStore.getState(); if (!s.laser) commentsStore().setMode(false); s.setLaser(!s.laser); return }
398
401
  if (key === 'c') { const c = commentsStore(); if (!c.commentMode) useStore.getState().setLaser(false); c.setMode(!c.commentMode); return }
399
402
  if (key === 'C') { const c = commentsStore(); c.setShow(!c.show); return } // ⇧C hides/shows pins
403
+ if (key === 'L') { const c = commentsStore(); c.setShowAnchor(!c.showAnchor); return } // ⇧L element focus
400
404
 
401
405
  if (/^Digit[1-9]$/.test(code)) {
402
406
  const names = Object.keys(CONFIG.viewports)
@@ -73,13 +73,24 @@ export function useHideUI() {
73
73
 
74
74
  // ---- shared controls ----------------------------------------------------------------
75
75
 
76
+ /** Two-row shortcut tooltip: one action per row, key right-aligned - the provenance
77
+ * tooltip's bold-value/muted-key language, shared by every two-shortcut button. */
78
+ function TipRows({ rows }: { rows: [string, string][] }) {
79
+ return (
80
+ <span className="sh-tip-rows">
81
+ {rows.map(([label, key]) => <span className="r" key={key}><b>{label}</b><span>{key}</span></span>)}
82
+ </span>
83
+ )
84
+ }
85
+
76
86
  /** Laser toggle - store-level, identical on canvas and prototype. The per-surface code
77
87
  * broadcasts sh:laser to its frame(s); this only flips the flag (and drops comment mode,
78
88
  * since the two are one-at-a-time). */
79
89
  export function LaserButton() {
80
90
  const laser = useStore((s) => s.laser)
91
+ const showAnchor = useComments((s) => s.showAnchor)
81
92
  return (
82
- <Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
93
+ <Tip side="bottom" label={<TipRows rows={[['Laser mode', 'L'], [showAnchor ? 'Hide laser comment' : 'Show laser comment', '⇧L']]} />}>
83
94
  <button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
84
95
  if (!laser) commentsStore().setMode(false)
85
96
  useStore.getState().setLaser(!laser)
@@ -91,8 +102,9 @@ export function LaserButton() {
91
102
  /** Comment toggle - store-level, identical on canvas and prototype. */
92
103
  export function CommentButton() {
93
104
  const commentMode = useComments((s) => s.commentMode)
105
+ const show = useComments((s) => s.show)
94
106
  return (
95
- <Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
107
+ <Tip side="bottom" label={<TipRows rows={[['Comment', 'C'], [show ? 'Hide pins' : 'Show pins', '⇧C']]} />}>
96
108
  <button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
97
109
  const c = commentsStore()
98
110
  if (!c.commentMode) useStore.getState().setLaser(false)
@@ -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() {
@@ -1,8 +1,9 @@
1
1
  import { memo, useCallback, useEffect, useRef } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
- import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
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,8 +11,26 @@ import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from
10
11
  export const HEADER = 28
11
12
  const SNAP = 12
12
13
 
14
+ /** Live Jam working shimmer: a slim 2x6 strip of tiny marver marks on the frame's left
15
+ * flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
16
+ * --mv-w0 (set on the node) so parallel frames never pulse in sync. */
17
+ const SHIM_DELAYS = Array.from({ length: 12 }, (_, i) => {
18
+ const r = Math.floor(i / 2), c = i % 2
19
+ return (((r * 7 + c * 13) % 9) / 9) * 0.95
20
+ })
21
+ function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
22
+ // a variant badge owns the top of the left flank - the shimmer yields and sits below it
23
+ return (
24
+ <div className={`sh-work-ind${belowBadge ? ' below-vbadge' : ''}`} aria-hidden>
25
+ {SHIM_DELAYS.map((d, i) => (
26
+ <ParallelogramFillIcon key={i} size={5} style={{ animationDelay: `calc(var(--mv-w0, 0s) + ${d.toFixed(2)}s)` }} />
27
+ ))}
28
+ </div>
29
+ )
30
+ }
31
+
13
32
  /**
14
- * 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
15
34
  * and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
16
35
  *
17
36
  * Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
@@ -23,6 +42,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
23
42
  const frame = useStore((s) => s.frameFor(node))
24
43
  const selected = useStore((s) => s.selection.includes(node.key))
25
44
  const interact = useStore((s) => s.interact === node.key)
45
+ const working = useStore((s) => s.working.includes(node.frame)) // Live Jam: Marver is editing this frame
46
+ const workingSince = useStore((s) => s.workingSince[node.frame])
26
47
  // B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
27
48
  // pan/zoom tick. gestureScale below measures the world rect (the canonical source,
28
49
  // Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
@@ -68,7 +89,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
68
89
  registerWin()
69
90
  }, [node.key])
70
91
 
71
- // 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.
72
93
  const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
73
94
  // capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
74
95
  // (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
@@ -87,7 +108,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
87
108
  // otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
88
109
  // hostsCard rail recaptures a clean lean once the card closes.
89
110
  const c = useComments.getState()
90
- 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
91
115
  if (hosting) return
92
116
  scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
93
117
  }, 450)
@@ -112,7 +136,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
112
136
  scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
113
137
  }, [interact, node.key, node.nav, node.theme, node.status])
114
138
 
115
- // 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
116
140
  // so late loaders join an already-lasered board
117
141
  const laser = useStore((s) => s.laser)
118
142
  const commentMode = useComments((s) => s.commentMode)
@@ -120,7 +144,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
120
144
  // neighbors - each node is a stacking context, so an overflowing card would
121
145
  // otherwise paint under the next frame
122
146
  const hostsCard = useComments((s) =>
123
- (!!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)
124
149
  // a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
125
150
  // cover: the active-element highlight lives in the live DOM and updates in real time
126
151
  // (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
@@ -140,11 +165,13 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
140
165
  if (node.status === 'ready' || !laser)
141
166
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
142
167
  }, [laser, node.status])
143
- // comment mode = pick mode in the frame (late loaders join like laser does)
168
+ // comment mode = pick mode in the frame (late loaders join like laser does); quiet when
169
+ // laser comment (⇧L) is off - clicks still anchor, no lighting in the artwork
170
+ const showAnchor = useComments((s) => s.showAnchor)
144
171
  useEffect(() => {
145
172
  if (node.status === 'ready' || !commentMode)
146
- iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
147
- }, [commentMode, node.status])
173
+ iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !showAnchor }, location.origin)
174
+ }, [commentMode, showAnchor, node.status])
148
175
  // B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
149
176
  // wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
150
177
  useEffect(() => {
@@ -177,7 +204,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
177
204
  if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
178
205
  }, [node.nav])
179
206
 
180
- // ready timeout (spec §7): 10s without sh:ready -> error card with reload
207
+ // ready timeout: 10s without sh:ready -> error card with reload
181
208
  useEffect(() => {
182
209
  if (node.status !== 'loading') return
183
210
  const t = setTimeout(() => setStatus(node.key, 'error', 'frame never reported ready (10s)'), 10_000)
@@ -280,18 +307,22 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
280
307
  )
281
308
  }
282
309
 
283
- // 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
284
311
  // artwork, world-anchored (scales with zoom) with a screen-space minimum via --sh-inv
285
312
  const variantName = frame.title
286
313
  ?? cap((frame.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
287
314
 
288
315
  return (
289
316
  <div
290
- className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
317
+ className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}${working ? ' working' : ''}`}
291
318
  data-theme={node.theme}
292
- style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined }}
319
+ style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined,
320
+ // phase every working animation by when THIS frame's job started - parallel
321
+ // frames pulsing in sync would read as one fake choreography
322
+ ...(working ? { ['--mv-w0' as string]: `${-(Date.now() - (workingSince ?? Date.now()))}ms` } : {}) }}
293
323
  data-node={node.key}
294
324
  >
325
+ {working && <WorkShimmer belowBadge={!!frame.variantGroup} />}
295
326
  {frame.variantGroup && (
296
327
  <div className="sh-vbadge sh-no-pan" title={`${frame.variantGroup} · variant ${frame.variant?.toUpperCase()} - click to select`}
297
328
  onPointerDown={(e) => e.stopPropagation()}
@@ -301,7 +332,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
301
332
  </div>
302
333
  )}
303
334
  <div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
304
- {/* content frames carry their intent glyph in the chrome (SPEC-026) */}
335
+ {/* content frames carry their intent glyph in the chrome */}
305
336
  {frame.intent && <IntentGlyph intent={frame.intent} size={12} className="iicon sh-no-pan" aria-label={frame.intent} />}
306
337
  <span className="id sh-no-pan">{frame.title ?? frame.id}</span>
307
338
  <span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
@@ -331,7 +362,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
331
362
  onLoad={registerWin}
332
363
  style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
333
364
  />
334
- {/* 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
335
366
  the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
336
367
  device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
337
368
  so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
@@ -348,6 +379,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
348
379
  onDoubleClick={(e) => { e.stopPropagation(); setInteract(node.key) }}
349
380
  />
350
381
  )}
382
+ {working && <div className="sh-work-wave" />}
351
383
  </div>
352
384
 
353
385
  {/* comments live OUTSIDE the clipped body: a card or pin near the frame edge
@@ -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)
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Comment state for the shell (SPEC-M3 §6) - its own small store beside the canvas
2
+ * Comment state for the shell - its own small store beside the canvas
3
3
  * store. One API client for both worlds: `marver dev` (api.ts mirror, local profile,
4
4
  * poll) and the published serve (collab.ts, sessions, SSE). The shell never branches
5
5
  * on which one it is beyond capability flags the endpoints themselves report.
@@ -11,32 +11,42 @@ import { ROUTE } from '../const.ts'
11
11
  export interface Me { email: string; name: string; avatar?: string }
12
12
 
13
13
  interface CommentsState {
14
- events: CommentEvent[] // the active board's log
15
- threads: Thread[] // derived on every change
14
+ events: CommentEvent[] // EVERY board's log, unioned - comments are frame-scoped
15
+ threads: Thread[] // derived on every change; each carries its origin board
16
16
  board: string | null
17
17
  me: Me | null // null on published until signed in
18
18
  local: boolean // dev mirror (identity is the local profile)
19
+ connected: boolean // dev only: a connect account provides name/email (read-only here)
19
20
  commentMode: boolean // C - picking + composing
20
21
  show: boolean // Shift+C - pins visible at all
22
+ showAnchor: boolean // Shift+L - light the tagged ELEMENT while its thread is open
21
23
  active: string | null // open thread id
22
- draft: { nodeKey: string; frame: string; anchor: unknown } | null // picked, composing
24
+ // picked, composing. `board` is captured at pick time - the write must land in the
25
+ // log of the board the user was LOOKING at, however long the composer stays open
26
+ draft: { nodeKey: string; frame: string; anchor: unknown; board?: string } | null
23
27
  needsIdentity: boolean // published viewer tried to comment while signed out
24
28
  inviteToken: string | null // arrived via an invite link - claim flow, token known
25
29
 
26
30
  load(board: string): Promise<void>
27
31
  live(board: string): () => void
28
- send(events: CommentEvent[]): Promise<boolean>
32
+ /** Fetch a board's log NOW (Live Jam: the daemon just wrote a reply THERE) - same union
33
+ * path as the poll, so dedup + the reply notification behave identically. ANY board:
34
+ * notifications must reach the user wherever they are on the canvas. */
35
+ poke(board?: string): void
36
+ /** `board` routes the write to a thread's ORIGIN log; default = the active board. */
37
+ send(events: CommentEvent[], board?: string): Promise<boolean>
29
38
  create(body: string): Promise<void>
30
39
  reply(threadId: string, body: string): Promise<void>
31
40
  replyOk(threadId: string, body: string): Promise<boolean>
32
41
  resolve(threadId: string, reopen?: boolean): Promise<void>
33
42
  setMode(on: boolean): void
34
43
  setShow(show: boolean): void
44
+ setShowAnchor(on: boolean): void
35
45
  setActive(id: string | null): void
36
46
  setDraft(d: CommentsState['draft']): void
37
47
  signIn(email: string, password: string): Promise<string | null>
38
48
  claim(token: string, password: string, name: string, avatar?: string): Promise<string | null>
39
- saveProfile(patch: Partial<Me>): Promise<void>
49
+ saveProfile(patch: Partial<Me>): Promise<string | null>
40
50
  dismissIdentity(): void
41
51
  }
42
52
 
@@ -51,23 +61,112 @@ const api = async (path: string, body?: unknown) => {
51
61
  }
52
62
 
53
63
  export const useComments = create<CommentsState>((set, get) => {
54
- const derive = (events: CommentEvent[]) => ({ events, threads: replay(events) })
64
+ // replay PER BOARD, then concatenate: replay keys threads by commentId alone, so a
65
+ // (copied, malicious, or just colliding) id in two logs must not merge across boards
66
+ const derive = (events: CommentEvent[]) => {
67
+ const byBoard = new Map<string, CommentEvent[]>()
68
+ for (const e of events) {
69
+ const k = e.board ?? ''
70
+ const arr = byBoard.get(k) ?? []
71
+ arr.push(e)
72
+ byBoard.set(k, arr)
73
+ }
74
+ return { events, threads: [...byBoard.values()].flatMap((evs) => replay(evs)) }
75
+ }
76
+ // a static 0.8.1 serve answers every /api/* with 404 - comments simply do not exist
77
+ // there. Latch off after detection so N boards are not swept into 404s every 30s.
78
+ let apiOff = false
79
+ // the boot instant: an event younger than the page can never be "history", so it may
80
+ // notify even before its board's baseline sweep lands (the poke/SSE-beats-load race)
81
+ const bootTs = Date.now()
82
+ // Live Jam: a Marver reply that arrives AFTER the baseline raises a persistent
83
+ // bottom-right pill - from ANY board (the store holds every log), keyed on the event id
84
+ // via `union`'s fresh filter so it fires exactly once. The pill's board is the THREAD's
85
+ // origin board, so View can navigate there.
86
+ const notifyAgent = (fresh: CommentEvent[]) => {
87
+ const replies = fresh.filter((e) => e.agent && e.type === 'reply')
88
+ if (!replies.length) return
89
+ void import('./store.ts').then(({ useStore }) => {
90
+ const s = useStore.getState()
91
+ for (const e of replies) {
92
+ const threadId = e.parentId ?? e.commentId ?? ''
93
+ // the FRAME is the news: resolve the reply's thread -> frame -> manifest title + intent
94
+ const t = get().threads.find((t) => t.id === threadId)
95
+ const entry = t?.frame ? s.manifest?.frames.find((f) => f.id === t.frame) : undefined
96
+ s.jamToast({
97
+ threadId, board: t?.board ?? e.board ?? get().board ?? '', ts: e.ts,
98
+ preview: (e.body ?? '').replace(/\s+/g, ' ').slice(0, 90),
99
+ frame: t?.frame, frameTitle: entry?.title ?? t?.frame, intent: entry?.intent,
100
+ })
101
+ }
102
+ })
103
+ }
104
+ // events are append-only everywhere, so union is ALWAYS safe - the one rule is the
105
+ // baseline, PER BOARD: a board's first successful read must never replay its history
106
+ // as notifications (the switcher list can arrive after the first sweep, so a global
107
+ // flag would announce a late board's whole past as news)
108
+ const baselinedBoards = new Set<string>()
109
+ // dedup by (board, id): the same id in two DIFFERENT logs is two events, never one
110
+ const key = (e: CommentEvent) => `${e.board ?? ''}|${e.id}`
55
111
  const union = (events: CommentEvent[]) => {
56
- const have = new Set(get().events.map((e) => e.id))
57
- const fresh = events.filter((e) => !have.has(e.id))
58
- if (fresh.length) set(derive([...get().events, ...fresh]))
112
+ const have = new Set(get().events.map(key))
113
+ // dedupe within the batch too - a union-merged log can carry a duplicated line
114
+ const fresh = events.filter((e) => !have.has(key(e)) && (have.add(key(e)), true))
115
+ if (!fresh.length) return
116
+ set(derive([...get().events, ...fresh]))
117
+ notifyAgent(fresh.filter((e) => (e.board && baselinedBoards.has(e.board)) || e.ts > bootTs))
118
+ }
119
+ /** Legacy logs (0.8.0 clients never sent `board`) get it from the endpoint they came
120
+ * from - without it, replies to their threads would route to whatever board is open. */
121
+ const stamped = (events: CommentEvent[] | undefined, board: string): CommentEvent[] =>
122
+ (events ?? []).map((e) => (e.board ? e : { ...e, board }))
123
+ /** Every board that could hold a log: the switcher list plus the active board -
124
+ * the sum of what this client can navigate to. Transport failure = just the
125
+ * active board; the next sweep widens again. */
126
+ const watchNames = async (): Promise<string[]> => {
127
+ const names = new Set<string>()
128
+ try {
129
+ const { fetchBoardNames } = await import('./store.ts')
130
+ for (const n of await fetchBoardNames()) names.add(n)
131
+ } catch { /* keep what we have */ }
132
+ const b = get().board
133
+ if (b) names.add(b)
134
+ return [...names]
135
+ }
136
+ /** One sweep across every board's log - the notification feed and the frame-scoped
137
+ * thread state are the same fetch. Boards baseline AFTER their events land. */
138
+ const fetchAll = async () => {
139
+ if (apiOff) return
140
+ const names = await watchNames()
141
+ const results = await Promise.all(names.map((n) =>
142
+ api(`comments/${n}`).then((r) => (r.ok ? stamped(r.data.events as CommentEvent[] | undefined, n) : null))))
143
+ union(results.flatMap((evs) => evs ?? []))
144
+ names.forEach((n, i) => { if (results[i] !== null) baselinedBoards.add(n) })
59
145
  }
60
146
 
61
147
  return {
62
- events: [], threads: [], board: null, me: null, local: false,
63
- commentMode: false, show: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
148
+ events: [], threads: [], board: null, me: null, local: false, connected: false,
149
+ commentMode: false, show: true, showAnchor: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
150
+
151
+ poke(board) {
152
+ const b = board ?? get().board
153
+ if (!b || apiOff) return
154
+ void api(`comments/${b}`).then((r) => {
155
+ if (!r.ok) return
156
+ union(stamped(r.data.events, b)) // brand-new events still notify via the bootTs rule
157
+ baselinedBoards.add(b)
158
+ })
159
+ },
64
160
 
65
161
  async load(board) {
66
- set({ board })
162
+ // threads are frame-scoped and global - a board switch keeps them all (the same
163
+ // frame on another board keeps its pins); only the open card and a draft reset
164
+ if (get().board !== board) set({ board, active: null, draft: null })
165
+ else set({ board })
67
166
  const me = await api('me')
68
- if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local })
69
- const res = await api(`comments/${board}`)
70
- if (res.ok && get().board === board) set(derive(res.data.events ?? []))
167
+ if (me.status === 404) { apiOff = true; return } // static serve: no comments API at all
168
+ if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local, connected: !!me.data.connected })
169
+ await fetchAll()
71
170
  },
72
171
 
73
172
  /** Liveness: SSE on the published serve; dev has no event rail (its sync loop
@@ -76,12 +175,13 @@ export const useComments = create<CommentsState>((set, get) => {
76
175
  const stops: (() => void)[] = []
77
176
  let es: EventSource | null = null
78
177
  const wantSSE = () => {
79
- if (es || get().local) return
178
+ if (es || get().local || apiOff) return
80
179
  es = new EventSource(`${ROUTE}/api/events`)
81
180
  es.addEventListener('comment', (e) => {
82
181
  try {
83
182
  const { board: b, ev } = JSON.parse((e as MessageEvent).data)
84
- if (b === get().board) union([ev])
183
+ // every board: notifications reach the viewer anywhere (envelope names the log)
184
+ union(stamped([ev], typeof b === 'string' ? b : ''))
85
185
  } catch { /* ignore */ }
86
186
  })
87
187
  es.addEventListener('resync', () => { void get().load(board) })
@@ -90,7 +190,7 @@ export const useComments = create<CommentsState>((set, get) => {
90
190
  // `local` is only known once load() has answered - defer the SSE decision past it
91
191
  const t = setTimeout(wantSSE, 1500)
92
192
  stops.push(() => { clearTimeout(t); es?.close() })
93
- const poll = () => { if (get().board === board) void api(`comments/${board}`).then((r) => r.ok && union(r.data.events ?? [])) }
193
+ const poll = () => { if (get().board === board) void fetchAll() }
94
194
  const iv = setInterval(poll, 30_000)
95
195
  const onFocus = () => poll()
96
196
  window.addEventListener('focus', onFocus)
@@ -98,10 +198,15 @@ export const useComments = create<CommentsState>((set, get) => {
98
198
  return () => stops.forEach((f) => f())
99
199
  },
100
200
 
101
- async send(events) {
102
- const { board } = get()
103
- if (!board) return false
104
- const res = await api(`comments/${board}`, { events })
201
+ async send(events, board) {
202
+ const target = board ?? get().board
203
+ if (!target) return false
204
+ // stamp the origin board BEFORE the POST: the dev server fills it at origin, but
205
+ // the published server stores events as sent - an unstamped event would come back
206
+ // board-less on the next load and its replies could then route to the wrong log.
207
+ // Stamping client-side keeps the stored bytes and the optimistic copy identical.
208
+ const stamped = events.map((e) => ({ ...e, board: e.board ?? target }))
209
+ const res = await api(`comments/${target}`, { events: stamped })
105
210
  if (res.status === 401) { set({ needsIdentity: true }); return false }
106
211
  // any other refusal gets said out loud - a silent dead Enter key reads as a bug
107
212
  // (the canonical case: a signed-in viewer on a read-only board)
@@ -109,11 +214,10 @@ export const useComments = create<CommentsState>((set, get) => {
109
214
  const { useStore } = await import('./store.ts')
110
215
  useStore.getState().toast(String((res.data as any)?.error ?? 'comment rejected'))
111
216
  }
112
- // union only what the server took - a rejected send must not leave phantoms -
113
- // and only if the user is still LOOKING at that board (a slow response after a
114
- // board switch must not leak events into the wrong client state); client ids
115
- // keep the eventual SSE/poll echo idempotent either way
116
- if (res.ok && get().board === board) union(events)
217
+ // union only what the server took - a rejected send must not leave phantoms; the
218
+ // store is global now, so an accepted write lands regardless of the viewed board
219
+ // (client ids keep the eventual SSE/poll echo idempotent)
220
+ if (res.ok) union(stamped)
117
221
  return res.ok
118
222
  },
119
223
 
@@ -125,27 +229,32 @@ export const useComments = create<CommentsState>((set, get) => {
125
229
  id: uuid(), ts: Date.now(), type: 'create', commentId: id,
126
230
  nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
127
231
  author: me ?? undefined, body: body.trim(),
128
- }])
232
+ }], draft.board)
129
233
  if (ok) set({ draft: null, active: id, commentMode: false })
130
234
  },
131
235
 
132
236
  async reply(threadId, body) { await get().replyOk(threadId, body) },
133
237
  async replyOk(threadId, body) {
134
238
  if (!body.trim()) return false
239
+ // route to the thread's ORIGIN log - the viewer may be reading it from another
240
+ // board (frame-scoped display); a reply landing in the wrong log would fork it
241
+ const origin = get().threads.find((t) => t.id === threadId)?.board
135
242
  return get().send([{
136
243
  id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
137
244
  author: get().me ?? undefined, body: body.trim(),
138
- }])
245
+ }], origin)
139
246
  },
140
247
 
141
248
  async resolve(threadId, reopen = false) {
142
- await get().send([{ id: uuid(), ts: Date.now(), type: reopen ? 'reopen' : 'resolve', commentId: threadId }])
249
+ const origin = get().threads.find((t) => t.id === threadId)?.board
250
+ await get().send([{ id: uuid(), ts: Date.now(), type: reopen ? 'reopen' : 'resolve', commentId: threadId }], origin)
143
251
  },
144
252
 
145
253
  setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
146
254
  setShow(show) { set({ show }) },
255
+ setShowAnchor(showAnchor) { set({ showAnchor }) },
147
256
  setActive(active) { set({ active }) },
148
- setDraft(draft) { set({ draft }) },
257
+ setDraft(draft) { set({ draft: draft ? { ...draft, board: draft.board ?? get().board ?? undefined } : null }) },
149
258
  dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
150
259
 
151
260
  async signIn(email, password) {
@@ -162,7 +271,9 @@ export const useComments = create<CommentsState>((set, get) => {
162
271
  },
163
272
  async saveProfile(patch) {
164
273
  const res = await api('profile', patch)
165
- if (res.ok) set({ me: res.data.user })
274
+ if (!res.ok) return res.data?.error ?? 'could not save - try again'
275
+ set({ me: res.data.user })
276
+ return null
166
277
  },
167
278
  }
168
279
  })
@@ -173,6 +284,9 @@ if (typeof window !== 'undefined' && (import.meta as any).env?.DEV) (window as a
173
284
  /** Initials + deterministic hue for avatarless authors - the whole fallback ladder. */
174
285
  export const avatarFallback = (author?: { email?: string; name?: string }) => {
175
286
  const name = author?.name || author?.email || '?'
287
+ // the unset dev default ("You", no account) renders in the COMMENT green - it's the mode's
288
+ // own hue, not a fake identity color, until a real profile is set
289
+ if (name === 'You' && !author?.email) return { initials: 'Y', hue: 131 }
176
290
  const initials = name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase()
177
291
  let h = 0
178
292
  for (const c of (author?.email || name)) h = (h * 31 + c.charCodeAt(0)) % 360
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Deep links (SPEC-M2 §3). Hash-based so any static host serves them and a URL copied
2
+ * Deep links. Hash-based so any static host serves them and a URL copied
3
3
  * from dev works verbatim on a published site. The URL is a projection of state - the
4
4
  * shell writes it as board/selection/play change and parses it once on boot; camera
5
5
  * intent is always fit semantics (fit-all / fit-selection), never raw coordinates.
@@ -7,7 +7,7 @@
7
7
  * #/ default board, fit all
8
8
  * #/b/<board> board, fit all
9
9
  * #/b/<board>?n=k1,k2 board with nodes selected, camera fit to selection
10
- * #/b/<board>?c=<id> board with a comment thread open (SPEC-M3 §6)
10
+ * #/b/<board>?c=<id> board with a comment thread open
11
11
  * #/i/<token> invite link - opens the claim dialog with the token
12
12
  * #/p/<board>?at=<frame-id>&device=<viewport>&theme=<theme> play mode
13
13
  */