@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
@@ -40,16 +40,20 @@ export const MonitorIcon = icon(<path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,
40
40
  export const TvIcon = icon(<path d="M216,64H147.31l34.35-34.34a8,8,0,1,0-11.32-11.32L128,60.69,85.66,18.34A8,8,0,0,0,74.34,29.66L108.69,64H40A16,16,0,0,0,24,80V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,136H40V80H216V200Z" />)
41
41
  export const SignpostIcon = icon(<path d="M246,106.65,212.33,69.3A16,16,0,0,0,200.44,64H136V32a8,8,0,0,0-16,0V64H40A16,16,0,0,0,24,80v64a16,16,0,0,0,16,16h80v64a8,8,0,0,0,16,0V160h64.44a16,16,0,0,0,11.89-5.3L246,117.35A8,8,0,0,0,246,106.65ZM200.44,144H40V80H200.44l28.8,32Z" />)
42
42
  export const ParallelogramDuoIcon = icon(<><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1" /><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z" /></>)
43
+ /** The marver mark, SOLID fill (for the Marver avatar - a filled white parallelogram on accent). */
44
+ export const ParallelogramFillIcon = icon(<path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31Z" />)
43
45
  /** Sidebar trigger, custom in Phosphor 256-space: outer frame + panel pill -
44
46
  * pill filled while the sidebar is open, hollow while collapsed. */
45
47
  export const PanelFilledIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" /></>)
46
48
 
47
49
  /** Plain UI frame - the rectangle every non-content sidebar row leads with. */
48
50
  export const FrameRectIcon = icon(<rect x="40" y="52" width="176" height="152" rx="20" fill="none" stroke="currentColor" strokeWidth="16" />)
51
+ export const UserIcon = icon(<path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z" />)
52
+ export const PencilSimpleIcon = icon(<path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z" />)
49
53
  export const LinkIcon = icon(<path d="M137.54,186.36a8,8,0,0,1,0,11.31l-9.94,10A56,56,0,0,1,48.38,128.4L72.5,104.28A56,56,0,0,1,149.31,102a8,8,0,1,1-10.64,12,40,40,0,0,0-54.85,1.63L59.7,139.72a40,40,0,0,0,56.58,56.58l9.94-9.94A8,8,0,0,1,137.54,186.36Zm70.08-138a56.08,56.08,0,0,0-79.22,0l-9.94,9.95a8,8,0,0,0,11.32,11.31l9.94-9.94a40,40,0,0,1,56.58,56.58L172.18,140.4a40,40,0,0,1-54.85,1.6,8,8,0,1,0-10.64,12,56,56,0,0,0,76.81-2.26l24.12-24.12A56.08,56.08,0,0,0,207.62,48.38Z" />)
50
54
  export const LaserIcon = icon(<><circle cx="128" cy="128" r="56" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" /><circle cx="128" cy="128" r="12" /></>)
51
55
 
52
- /* Content-frame intent glyphs (SPEC-026), custom in the same 256-space. */
56
+ /* Content-frame intent glyphs, custom in the same 256-space. */
53
57
  export const DiagramShapeIcon = icon(<><rect x="28" y="36" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="140" y="156" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M116 68 H184 V156" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" strokeLinejoin="round" /></>)
54
58
  export const SpecDocIcon = icon(<><path d="M44 48 H180 M44 96 H212 M44 144 H212 M44 192 H140" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" /></>)
55
59
  export const MoodboardIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="20" fill="none" stroke="currentColor" strokeWidth="16" /><circle cx="96" cy="104" r="18" /><path d="M56 192 L112 128 L150 168 L178 140 L200 164 V192 Z" /></>)
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Node-key identity for comments. Board nodes the file does not key yet used to
3
+ * get a RANDOM key per load - and comments anchor to node keys, so every comment
4
+ * created on a never-saved board orphaned on the next mount: dev board switches
5
+ * and reloads, and every single visit on a published canvas (which can never
6
+ * save keys back). Deriving the key from board + frame + occurrence makes the
7
+ * same board file yield the same keys in every session and for every viewer.
8
+ * Keys stored in the file still win; this only names the unnamed.
9
+ */
10
+
11
+ /** 64 bits from two independent 32-bit passes (djb2-xor with different seeds):
12
+ * a single 32-bit hash has practical collisions, and a collision here would
13
+ * make key assignment depend on which OTHER nodes happen to be present. */
14
+ const h32 = (s: string, seed: number): number => {
15
+ let h = seed
16
+ for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0
17
+ return h >>> 0
18
+ }
19
+
20
+ export function stableNodeKey(board: string, frame: string, salt: number): string {
21
+ const s = `${board}|${frame}|${salt}`
22
+ return 'n_' + h32(s, 5381).toString(36) + h32(s, 52711).toString(36)
23
+ }
24
+
25
+ /**
26
+ * The ONE answer to "which node renders this thread": the stored anchor when that
27
+ * node still exists AND still shows the thread's frame, else the first node showing
28
+ * the frame (adoption - a stale key from a past session, a board rewrite, a deleted
29
+ * copy must degrade to the frame, never to invisible), else null (frame not on this
30
+ * board). Every consumer - pin layer, reveal, the hosting checks - must agree, or
31
+ * a card renders on one node while another claims the hosting behavior.
32
+ */
33
+ export function threadHostKey(
34
+ t: { nodeKey?: string; frame?: string },
35
+ nodes: readonly { key: string; frame: string }[],
36
+ ): string | null {
37
+ if (t.nodeKey && nodes.some((n) => n.key === t.nodeKey && n.frame === t.frame)) return t.nodeKey
38
+ return nodes.find((n) => n.frame === t.frame)?.key ?? null
39
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @marver mention parsing - pure, so the rendering is unit-testable without
3
+ * pulling the whole comment UI (and its virtual:sh-config) into the test.
4
+ */
5
+
6
+ /** Split a comment body into plain text and @marver mention segments (case-insensitive, word-bounded). */
7
+ export function parseMentions(body: string): { text: string; mention: boolean }[] {
8
+ const out: { text: string; mention: boolean }[] = []
9
+ let last = 0
10
+ for (const m of body.matchAll(/@marver\b/gi)) {
11
+ const i = m.index ?? 0
12
+ if (i > last) out.push({ text: body.slice(last, i), mention: false })
13
+ out.push({ text: m[0], mention: true })
14
+ last = i + m[0].length
15
+ }
16
+ if (last < body.length) out.push({ text: body.slice(last), mention: false })
17
+ return out.length ? out : [{ text: body, mention: false }]
18
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * B0.4: canvas performance instrumentation. Zero cost unless explicitly enabled.
3
3
  *
4
- * Enabled in dev, or in ANY build (incl. packed/published - the SPEC-M4 gate is dev AND
4
+ * Enabled in dev, or in ANY build (incl. packed/published - the gate is dev AND
5
5
  * publish) via `?mvperf` in the URL or `localStorage.mvPerf==='1'`. So publish-side gate
6
6
  * measurement is possible without shipping an always-on probe.
7
7
  *
@@ -1,6 +1,7 @@
1
1
  import { create } from 'zustand'
2
2
  import { ROUTE } from '../const.ts'
3
3
  import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
4
+ import { stableNodeKey } from './keys.ts'
4
5
  // @ts-expect-error virtual module provided by the plugin
5
6
  import shConfig from 'virtual:sh-config'
6
7
  // @ts-expect-error virtual module: null in dev; a published build inlines manifest+boards
@@ -26,14 +27,22 @@ export interface Node {
26
27
  /** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
27
28
  * The only theme value that persists into the board file. */
28
29
  themeUser?: string
29
- /** Size provenance for CONTENT frames (SPEC-026), explicit: 'auto' = measured
30
+ /** Size provenance for CONTENT frames, explicit: 'auto' = measured
30
31
  * (transient - never serialized); 'manual' = the human's - measurements never
31
32
  * overwrite; 'device' = a preset's. UI frames never carry it. Only manual/device
32
33
  * persist, so a reload can tell an authored size from a measured one. */
33
34
  sizeMode?: 'auto' | 'manual' | 'device'
35
+ /** Transient (never serialized): this node was sized by the 390x844 GUESS because its
36
+ * frame was not in the manifest yet AND its file carried no w/h (the create-first
37
+ * choreography's append-before-scan race). applyManifest corrects ONLY these when the
38
+ * frame arrives - an authored size on a temporarily-missing frame is never touched. */
39
+ sizeFallback?: boolean
34
40
  status: 'loading' | 'ready' | 'error'; error?: string; missing?: boolean
35
41
  }
36
- export interface Toast { id: number; text: string }
42
+ /** A Live Jam notification: a persistent bottom-right glass pill for a Marver
43
+ * reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
44
+ export interface JamNote { threadId: string; board: string; preview: string; ts: number; frame?: string; frameTitle?: string; intent?: string }
45
+ export interface Toast { id: number; text: string; jam?: JamNote }
37
46
 
38
47
  export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string } = shConfig
39
48
 
@@ -55,6 +64,18 @@ export async function fetchBoardNames(): Promise<string[]> {
55
64
  /** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
56
65
  export const boardLabel = (n: string) => humanize(n)
57
66
 
67
+ /** The frames a board pins, straight from its file (dev fetch / published inline) -
68
+ * membership only, never a load. Cross-board data-goto asks "which board shows this
69
+ * frame?" without touching the live board state. */
70
+ export async function boardFrames(name: string): Promise<string[]> {
71
+ const board: any = DATA
72
+ ? DATA.boards[name]
73
+ : (await (await fetch(`${ROUTE}/api/boards/${name}`)).json())?.board
74
+ return Array.isArray(board?.nodes)
75
+ ? board.nodes.map((n: any) => (n && typeof n.frame === 'string' ? n.frame : '')).filter(Boolean)
76
+ : []
77
+ }
78
+
58
79
  const HEADER = 28
59
80
  let toastSeq = 0
60
81
  const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
@@ -82,12 +103,12 @@ const initialViewTheme = () => {
82
103
  }
83
104
  const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
84
105
 
85
- /** Latest measured content heights, keyed frameId@width. TRANSIENT by design
86
- * (SPEC-026): auto sizes are never serialized - a reload remeasures. */
106
+ /** Latest measured content heights, keyed frameId@width. TRANSIENT by design:
107
+ * auto sizes are never serialized - a reload remeasures. */
87
108
  const measuredHeights = new Map<string, number>()
88
109
 
89
110
  function defaultSize(frame: FrameEntry) {
90
- // content frames (SPEC-026): own width from Doc layout; height from the latest
111
+ // content frames: own width from Doc layout; height from the latest
91
112
  // measurement at that width, or a placeholder until sh:measure lands.
92
113
  // meta.viewport, when declared, wins - the existing precedence.
93
114
  if (frame.contentWidth && !frame.viewport) {
@@ -100,7 +121,7 @@ function defaultSize(frame: FrameEntry) {
100
121
 
101
122
  let resizedInGesture = false
102
123
 
103
- /** sceneRows is the legacy shorthand for a plain rows layout (SPEC-024 §5). */
124
+ /** sceneRows is the legacy shorthand for a plain rows layout. */
104
125
  const effectiveLayout = (layout: BoardLayout | null, sceneRows: string[][] | null): BoardLayout | undefined =>
105
126
  layout ?? (sceneRows?.length ? { rows: sceneRows.map((r) => [...r]) } : undefined)
106
127
 
@@ -116,14 +137,14 @@ interface State {
116
137
  selection: string[] // ordered; last entry is the primary (bar anchor)
117
138
  interact: string | null
118
139
  viewTheme: string // the global theme preference; sticky across boards and reloads
119
- play: { at: string; device: string; theme: string } | null // play mode (SPEC-M2 §1); at = current frame id
140
+ play: { at: string; device: string; theme: string } | null // play mode; at = current frame id
120
141
  gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
121
- laser: boolean // laser/inspect mode (SPEC-M3 §7): frames outline their structure
142
+ laser: boolean // laser/inspect mode: frames outline their structure
122
143
  board: string // active board name; 'all-scenes' is the auto board
123
144
  boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
124
145
  deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
125
- sceneRows: string[][] | null // LEGACY scene arrangement (SPEC-023 §2); still round-trips through save
126
- layout: BoardLayout | null // lane-flow recipe (SPEC-024), parsed; wins over sceneRows when both exist
146
+ sceneRows: string[][] | null // LEGACY scene arrangement; still round-trips through save
147
+ layout: BoardLayout | null // lane-flow recipe, parsed; wins over sceneRows when both exist
127
148
  layoutRaw: unknown // the author's layout VERBATIM - save round-trips this, never the parse
128
149
  baseLayout: Record<string, { x: number; y: number; w?: number; h?: number }> | null // snapshot taken on entering a device view; Default restores it exactly (auto content entries carry positions only - their sizes are measured)
129
150
  panelOpen: boolean
@@ -169,6 +190,15 @@ interface State {
169
190
  setTheme(theme: string): void
170
191
  runTidy(): void
171
192
  toast(text: string): void
193
+ jamToast(note: JamNote): void
194
+ dismissToast(id: number): void
195
+ clearJamToasts(): void
196
+ /** Frames Marver is editing right now (Live Jam presence) - drives the working glow. */
197
+ working: string[]
198
+ /** When each working frame's job started (ms epoch) - phases the animations so parallel
199
+ * frames never pulse in sync. */
200
+ workingSince: Record<string, number>
201
+ setWorking(frames: string[]): void
172
202
  spawn(frameId: string): Node | null
173
203
  save(): Promise<boolean>
174
204
  }
@@ -191,7 +221,7 @@ export const useStore = create<State>((set, get) => {
191
221
  let switchSeq = 0 // last click wins when board switches race
192
222
  const scheduleSave = () => { editRev++; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
193
223
 
194
- // SPEC-026: one cancelable, BOARD-SCOPED reflow after content measurements settle.
224
+ // One cancelable, BOARD-SCOPED reflow after content measurements settle.
195
225
  // The captured board name is the generation guard - a debounce surviving a board
196
226
  // switch fires into a name check and dies, never touching the new board.
197
227
  let reflowTimer: ReturnType<typeof setTimeout> | undefined
@@ -225,7 +255,7 @@ export const useStore = create<State>((set, get) => {
225
255
  },
226
256
  }
227
257
 
228
- /** Published parity for content-frame sizes (SPEC-026): manual/device sizes persist
258
+ /** Published parity for content-frame sizes: manual/device sizes persist
229
259
  * to the session the same way theme pins do - a board switch or reload on a
230
260
  * published canvas must not silently drop a hand-resized spec frame. Keys are
231
261
  * frame#occurrence: node keys are minted per load on virtual boards, but the node
@@ -293,19 +323,26 @@ export const useStore = create<State>((set, get) => {
293
323
  boardHash = sha256
294
324
  if (typeof board?.auto === 'boolean') boardAuto = board.auto
295
325
  // Agent-authored boards may be just a frame list - fill sizes/keys, tidy on first load.
296
- // Keys are load-bearing (iframe identity, selection) - dupes/blanks get reminted.
297
- // Board nodes whose file is gone stay, flagged - deletion is surfaced, never silent (spec §7).
326
+ // Keys are load-bearing (iframe identity, selection, COMMENT ANCHORS) - dupes/blanks
327
+ // get minted DETERMINISTICALLY (keys.ts): a random key here orphaned every comment
328
+ // created on a never-saved board the moment it reloaded.
329
+ // Board nodes whose file is gone stay, flagged - deletion is surfaced, never silent.
298
330
  const seenKeys = new Set<string>()
331
+ const ordinals = new Map<string, number>() // per-frame keyless-occurrence counter
299
332
  nodes = (Array.isArray(board?.nodes) ? board.nodes : [])
300
333
  .filter((n: any) => n && typeof n.frame === 'string')
301
334
  .map((n: any) => {
302
335
  const f = manifest.frames.find((x) => x.id === n.frame)
303
336
  const d = f ? defaultSize(f) : { w: 390, h: 844 }
304
337
  if (typeof n.x !== 'number' || typeof n.y !== 'number') needTidy = true
305
- let key = typeof n.key === 'string' && n.key ? n.key : nodeKey()
306
- if (seenKeys.has(key)) key = nodeKey()
338
+ let key = typeof n.key === 'string' && n.key ? n.key : ''
339
+ if (!key || seenKeys.has(key)) {
340
+ let salt = ordinals.get(n.frame) ?? 0
341
+ do { key = stableNodeKey(boardName, n.frame, salt++) } while (seenKeys.has(key))
342
+ ordinals.set(n.frame, salt)
343
+ }
307
344
  seenKeys.add(key)
308
- // content-frame size provenance round-trips (SPEC-026); auto nodes saved no
345
+ // content-frame size provenance round-trips; auto nodes saved no
309
346
  // w/h, so they fall to defaultSize (placeholder) and remeasure on mount
310
347
  const sizeMode = f?.contentWidth
311
348
  ? { sizeMode: (n.sizeMode === 'manual' || n.sizeMode === 'device' ? n.sizeMode : 'auto') as Node['sizeMode'] }
@@ -315,6 +352,7 @@ export const useStore = create<State>((set, get) => {
315
352
  frame: n.frame,
316
353
  x: typeof n.x === 'number' ? n.x : 0, y: typeof n.y === 'number' ? n.y : 0,
317
354
  w: typeof n.w === 'number' ? n.w : d.w, h: typeof n.h === 'number' ? n.h : d.h,
355
+ ...(!f && typeof n.w !== 'number' && typeof n.h !== 'number' ? { sizeFallback: true } : {}),
318
356
  ...sizeMode,
319
357
  // pins persist as their own field (exact round-trip). Legacy boards stored a
320
358
  // theme on EVERY node: only values differing from the frame's static default
@@ -345,7 +383,7 @@ export const useStore = create<State>((set, get) => {
345
383
  if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
346
384
  }
347
385
  // auto-managed goes both ways (friction log #15): an auto board gains new frames
348
- // AND sheds deleted ones. Tombstone cards are a curated-board concept (spec §7).
386
+ // AND sheds deleted ones. Tombstone cards are a curated-board concept.
349
387
  // Pruning at load DIRTIES the board (dev only) so the file on disk sheds the
350
388
  // tombstones too - otherwise a recreated frame id resurrects its stale node.
351
389
  let prunedAtLoad = false
@@ -361,6 +399,15 @@ export const useStore = create<State>((set, get) => {
361
399
  // saved piled up at the origin)
362
400
  if (boardAuto) {
363
401
  const placed = new Set(nodes.map((n) => n.frame))
402
+ // newcomers get deterministic keys too - on published auto boards (all-scenes)
403
+ // this path mints EVERY key, every visit, and comments anchor to them
404
+ const haveKeys = new Set(nodes.map((n) => n.key))
405
+ const mint = (frame: string) => {
406
+ let salt = 0, k = stableNodeKey(boardName, frame, salt)
407
+ while (haveKeys.has(k)) k = stableNodeKey(boardName, frame, ++salt)
408
+ haveKeys.add(k)
409
+ return k
410
+ }
364
411
  let maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0)
365
412
  for (const f of manifest.frames) {
366
413
  if (placed.has(f.id)) continue
@@ -375,7 +422,7 @@ export const useStore = create<State>((set, get) => {
375
422
  : null
376
423
  if (sibling) { x = sibling.x + sibling.w + Math.max(140, sibling.w * 0.12); y = sibling.y }
377
424
  else { x = nodes.length ? maxX + 96 : 0; y = 0 }
378
- const node: Node = { key: nodeKey(), frame: f.id, x, y, w, h, theme: resolveTheme(f), status: 'loading', ...(f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}) }
425
+ const node: Node = { key: mint(f.id), frame: f.id, x, y, w, h, theme: resolveTheme(f), status: 'loading', ...(f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}) }
379
426
  nodes.push(node)
380
427
  maxX = Math.max(maxX, x + w)
381
428
  }
@@ -420,7 +467,7 @@ export const useStore = create<State>((set, get) => {
420
467
  return {
421
468
  manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
422
469
  board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
423
- panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
470
+ panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
424
471
  pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0,
425
472
 
426
473
  async boot() {
@@ -485,15 +532,23 @@ export const useStore = create<State>((set, get) => {
485
532
  const d = defaultSize(f)
486
533
  const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
487
534
  // a grouped newcomer lands BESIDE its siblings, not at the canvas bottom - the
488
- // badge/caption say "these belong together"; the position must agree (SPEC-023 §3)
535
+ // badge/caption say "these belong together"; the position must agree
489
536
  const sibs = f.variantGroup
490
537
  ? next.filter((n) => { const g = m.frames.find((x) => x.id === n.frame)?.variantGroup; return g === f.variantGroup && !n.missing })
491
538
  : []
492
539
  const right = sibs.length ? sibs.reduce((a, n) => (n.x > a.x ? n : a)) : null
493
540
  const mode = f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}
541
+ // deterministic key (keys.ts): a comment dropped on a live-joined frame must
542
+ // survive the reload, where the SAME key gets minted by the board-load path
543
+ const mintKey = (() => {
544
+ const have = new Set(next.map((n) => n.key))
545
+ let salt = 0, k = stableNodeKey(get().board, f.id, salt)
546
+ while (have.has(k)) k = stableNodeKey(get().board, f.id, ++salt)
547
+ return k
548
+ })()
494
549
  const node = right
495
- ? { key: nodeKey(), frame: f.id, x: right.x + right.w + Math.max(140, right.w * 0.12), y: right.y, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const, ...mode }
496
- : { key: nodeKey(), frame: f.id, x: 0, y: maxY + 96, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const, ...mode }
550
+ ? { key: mintKey, frame: f.id, x: right.x + right.w + Math.max(140, right.w * 0.12), y: right.y, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const, ...mode }
551
+ : { key: mintKey, frame: f.id, x: 0, y: maxY + 96, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const, ...mode }
497
552
  next.push(node)
498
553
  // in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
499
554
  if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
@@ -504,7 +559,21 @@ export const useStore = create<State>((set, get) => {
504
559
  let retinted = false
505
560
  for (const n of next) {
506
561
  const missing = !live.has(n.frame)
507
- if (missing !== !!n.missing) { n.missing = missing; changed = true }
562
+ if (missing !== !!n.missing) {
563
+ // a node whose frame just ARRIVED (the create-first choreography: the agent
564
+ // appends the board node before the manifest registers the file) was sized by
565
+ // the 390x844 GUESS - its real viewport is only knowable now. ONLY guessed
566
+ // sizes are corrected (sizeFallback): a restored frame keeps its authored w/h.
567
+ const arrived = !missing && m.frames.find((x) => x.id === n.frame)
568
+ if (arrived && n.sizeFallback && !arrived.contentWidth) {
569
+ const d = defaultSize(arrived)
570
+ n.w = vp?.width ?? d.w
571
+ n.h = vp?.height ?? d.h
572
+ }
573
+ if (arrived) delete n.sizeFallback
574
+ n.missing = missing
575
+ changed = true
576
+ }
508
577
  // a frame's declared meta.theme may have changed - re-resolve unpinned nodes
509
578
  // (derived value: re-render yes, dirty/save no)
510
579
  const f = m.frames.find((x) => x.id === n.frame)
@@ -521,7 +590,7 @@ export const useStore = create<State>((set, get) => {
521
590
  if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
522
591
  }
523
592
  // auto boards prune deleted frames outright - "auto-managed" must manage both
524
- // directions (friction log #15). Curated boards keep the explicit card (spec §7).
593
+ // directions (friction log #15). Curated boards keep the explicit card.
525
594
  let final = next
526
595
  if (boardAuto) {
527
596
  final = next.filter((n) => !n.missing)
@@ -582,7 +651,7 @@ export const useStore = create<State>((set, get) => {
582
651
  set((s) => {
583
652
  const W = Math.max(120, w), H = Math.max(80, h)
584
653
  const cur = s.nodes.find((n) => n.key === key)
585
- // a human-resized CONTENT frame goes 'manual': measurements never overwrite it (SPEC-026)
654
+ // a human-resized CONTENT frame goes 'manual': measurements never overwrite it
586
655
  const content = !!s.manifest?.frames.find((x) => x.id === cur?.frame)?.contentWidth
587
656
  return {
588
657
  nodes: s.nodes.map((n) => (n.key === key ? { ...n, w: W, h: H, ...(content ? { sizeMode: 'manual' as const } : {}) } : n)),
@@ -598,7 +667,7 @@ export const useStore = create<State>((set, get) => {
598
667
  if (!inGesture) scheduleSave()
599
668
  },
600
669
 
601
- /** SPEC-026 sh:measure. Admission: content frames only, finite positive numbers,
670
+ /** sh:measure. Admission: content frames only, finite positive numbers,
602
671
  * clamped. A height only commits when it was measured at the width being applied;
603
672
  * auto sizes are transient - applying one never dirties the board (positions from
604
673
  * the follow-up reflow do, exactly like a human resize). */
@@ -607,7 +676,7 @@ export const useStore = create<State>((set, get) => {
607
676
  const node = s.nodes.find((n) => n.key === key)
608
677
  // Only an explicit DEVICE viewport locks a content frame's height. 'auto' and 'manual' both
609
678
  // auto-fit height: a content frame must always grow/shrink to show ALL its content, even after
610
- // the human drags its WIDTH (SPEC-026 r4). 'manual' just means the human owns the WIDTH; the
679
+ // the human drags its WIDTH. 'manual' just means the human owns the WIDTH; the
611
680
  // HEIGHT is still measured, so resizing width reflows and refits height (no clipped/empty frame).
612
681
  if (!node || node.sizeMode === 'device') return
613
682
  if (node.frame !== frameId) return // generation guard: a reused node key
@@ -657,7 +726,7 @@ export const useStore = create<State>((set, get) => {
657
726
  const f = s.manifest?.frames.find((x) => x.id === n.frame)
658
727
  const content = !!f?.contentWidth
659
728
  if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
660
- // digit 0 on a CONTENT frame = back to auto: measured size, remeasure ahead (SPEC-026)
729
+ // digit 0 on a CONTENT frame = back to auto: measured size, remeasure ahead
661
730
  const b = s.baseLayout?.[n.key]
662
731
  if (content && f) {
663
732
  const d = defaultSize(f)
@@ -702,7 +771,7 @@ export const useStore = create<State>((set, get) => {
702
771
  if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
703
772
  if (!f) return n
704
773
  const d = defaultSize(f)
705
- // digit 0 on a content frame clears provenance back to auto (SPEC-026)
774
+ // digit 0 on a content frame clears provenance back to auto
706
775
  return { ...n, w: d.w, h: d.h, ...(content ? { sizeMode: 'auto' as const } : {}) }
707
776
  })
708
777
  const baseLayout = s.baseLayout ? { ...s.baseLayout } : null
@@ -717,7 +786,7 @@ export const useStore = create<State>((set, get) => {
717
786
  selectAll() {
718
787
  set((s) => ({ selection: s.nodes.map((n) => n.key) }))
719
788
  },
720
- // group/scene selection from sidebar rows and canvas captions (SPEC-023 feedback)
789
+ // group/scene selection from sidebar rows and canvas captions
721
790
  selectMany(keys) {
722
791
  set((s) => {
723
792
  const valid = keys.filter((k) => s.nodes.some((n) => n.key === k))
@@ -745,7 +814,7 @@ export const useStore = create<State>((set, get) => {
745
814
  },
746
815
  setGesture(gesture) {
747
816
  set({ gesture })
748
- // SPEC-024 §4: a board WITH a layout recipe re-applies it when a resize
817
+ // A board WITH a layout recipe re-applies it when a resize
749
818
  // gesture ends - sizes changed, so lanes and gaps must recompute
750
819
  if (!gesture && resizedInGesture) {
751
820
  resizedInGesture = false
@@ -833,6 +902,22 @@ export const useStore = create<State>((set, get) => {
833
902
  set((s) => ({ toasts: [...s.toasts, { id, text }] }))
834
903
  setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 4000)
835
904
  },
905
+ /** A Live Jam reply notification: persistent (no auto-dismiss), one per thread (a newer reply
906
+ * supersedes), capped so the stack stays small. */
907
+ jamToast(note) {
908
+ const id = ++toastSeq
909
+ set((s) => ({ toasts: [...s.toasts.filter((t) => t.jam?.threadId !== note.threadId), { id, text: 'Marver replied', jam: note }].slice(-8) }))
910
+ },
911
+ dismissToast(id) { set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) },
912
+ clearJamToasts() { set((s) => ({ toasts: s.toasts.filter((t) => !t.jam) })) },
913
+ setWorking(frames) {
914
+ // preserve each frame's original start time; stamp now() only for newly-working frames -
915
+ // the start phases the working animations so parallel frames never pulse in sync
916
+ const prev = get().workingSince
917
+ const workingSince: Record<string, number> = {}
918
+ for (const f of frames) workingSince[f] = prev[f] ?? Date.now()
919
+ set({ working: frames, workingSince })
920
+ },
836
921
  spawn(frameId) {
837
922
  const { manifest, nodes, deviceView, baseLayout } = get()
838
923
  const f = manifest?.frames.find((x) => x.id === frameId)
@@ -855,7 +940,7 @@ export const useStore = create<State>((set, get) => {
855
940
  // a resize gesture in flight = torn state (new sizes, pre-recipe positions):
856
941
  // even a PREVIOUSLY scheduled timer must defer to the gesture-end save
857
942
  if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
858
- // a published canvas is read-only ON DISK (spec §12): theme pins persist to the
943
+ // a published canvas is read-only ON DISK: theme pins persist to the
859
944
  // session instead, and dirty must clear or switchBoard's save-flush loop wedges
860
945
  if (DATA) {
861
946
  sessionPins.write(get().board, get().nodes)
@@ -866,11 +951,11 @@ export const useStore = create<State>((set, get) => {
866
951
  const p = saveChain.then(async () => {
867
952
  // recheck INSIDE the serialized chain: a save queued behind an in-flight PUT
868
953
  // may only execute after a resize gesture began - the entry guard above ran
869
- // too early to see it (codex probe: torn PUT #2 behind blocked PUT #1)
954
+ // too early to see it (torn PUT #2 behind blocked PUT #1)
870
955
  if (get().gesture && resizedInGesture) { scheduleSave(); return false }
871
956
  const rev = editRev
872
957
  const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
873
- // Missing nodes persist too - only the explicit remove button drops them (spec §7).
958
+ // Missing nodes persist too - only the explicit remove button drops them.
874
959
  const board = {
875
960
  version: 1, name: boardName, auto: boardAuto,
876
961
  // deviceView and the free-form snapshot persist independently: a scoped
@@ -879,7 +964,7 @@ export const useStore = create<State>((set, get) => {
879
964
  ...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
880
965
  ...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
881
966
  // baseLayout entries for auto content nodes keep POSITIONS only - their
882
- // measured dimensions are transient and never reach the file (SPEC-026)
967
+ // measured dimensions are transient and never reach the file
883
968
  ...(baseLayout ? {
884
969
  baseLayout: Object.fromEntries(Object.entries(baseLayout).map(([k, b]) => {
885
970
  const n = nodes.find((x) => x.key === k)
@@ -887,11 +972,14 @@ export const useStore = create<State>((set, get) => {
887
972
  })),
888
973
  } : {}),
889
974
  // only PINNED themes persist - inherited values follow viewTheme at load time.
890
- // Content frames in AUTO save no dimensions (SPEC-026): measured sizes are
975
+ // Content frames in AUTO save no dimensions: measured sizes are
891
976
  // transient - a reflow-triggered save can never leak an auto height to disk.
892
- nodes: nodes.map(({ key, frame, x, y, w, h, themeUser, sizeMode }) => ({
977
+ // Guessed sizes (sizeFallback: the frame was not in the manifest yet) stay
978
+ // transient too - persisting the 390x844 guess would make it look authored
979
+ // and immortalize it across reloads.
980
+ nodes: nodes.map(({ key, frame, x, y, w, h, themeUser, sizeMode, sizeFallback }) => ({
893
981
  key, frame, x: Math.round(x), y: Math.round(y),
894
- ...(sizeMode === 'auto' ? {} : { w: Math.round(w), h: Math.round(h) }),
982
+ ...(sizeMode === 'auto' || sizeFallback ? {} : { w: Math.round(w), h: Math.round(h) }),
895
983
  ...(sizeMode === 'manual' || sizeMode === 'device' ? { sizeMode } : {}),
896
984
  ...(themeUser ? { themeUser } : {}),
897
985
  })),