@marver-design/marver 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +168 -5
  2. package/README.md +73 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-B8z902TW.mjs → build-D9gimz6K.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-2Lft_lVV.mjs} +60 -66
  10. package/dist/{dev-BYm9ebfN.mjs → dev-CTtkqVo_.mjs} +21 -7
  11. package/dist/{init-DrLomSWq.mjs → init-8Giknvy1.mjs} +35 -7
  12. package/dist/ledger-BgA7nQoH.mjs +103 -0
  13. package/dist/{manifest-D3eaARf4.mjs → manifest-DJHU7qfu.mjs} +144 -19
  14. package/dist/{plugin-BVFuRfEo.mjs → plugin-BrBWk2Qn.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 +17 -2
  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 +2 -2
  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 +16 -16
  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 +49 -2
  47. package/templates/AGENTS-studio.md +49 -2
  48. package/templates/instructions/configure.md +6 -1
  49. package/templates/instructions/jam.md +29 -9
  50. package/templates/instructions/publish.md +1 -1
  51. package/templates/instructions/welcome.md +7 -1
  52. package/dist/ledger-wFvEIEGi.mjs +0 -64
@@ -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,19 @@ 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
- /** A Live Jam notification (SPEC-live-jam §9): a persistent bottom-right glass pill for a Marver
42
+ /** A Live Jam notification: a persistent bottom-right glass pill for a Marver
37
43
  * reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
38
44
  export interface JamNote { threadId: string; board: string; preview: string; ts: number; frame?: string; frameTitle?: string; intent?: string }
39
45
  export interface Toast { id: number; text: string; jam?: JamNote }
@@ -58,6 +64,18 @@ export async function fetchBoardNames(): Promise<string[]> {
58
64
  /** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
59
65
  export const boardLabel = (n: string) => humanize(n)
60
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
+
61
79
  const HEADER = 28
62
80
  let toastSeq = 0
63
81
  const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
@@ -85,12 +103,12 @@ const initialViewTheme = () => {
85
103
  }
86
104
  const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
87
105
 
88
- /** Latest measured content heights, keyed frameId@width. TRANSIENT by design
89
- * (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. */
90
108
  const measuredHeights = new Map<string, number>()
91
109
 
92
110
  function defaultSize(frame: FrameEntry) {
93
- // 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
94
112
  // measurement at that width, or a placeholder until sh:measure lands.
95
113
  // meta.viewport, when declared, wins - the existing precedence.
96
114
  if (frame.contentWidth && !frame.viewport) {
@@ -103,7 +121,7 @@ function defaultSize(frame: FrameEntry) {
103
121
 
104
122
  let resizedInGesture = false
105
123
 
106
- /** sceneRows is the legacy shorthand for a plain rows layout (SPEC-024 §5). */
124
+ /** sceneRows is the legacy shorthand for a plain rows layout. */
107
125
  const effectiveLayout = (layout: BoardLayout | null, sceneRows: string[][] | null): BoardLayout | undefined =>
108
126
  layout ?? (sceneRows?.length ? { rows: sceneRows.map((r) => [...r]) } : undefined)
109
127
 
@@ -119,14 +137,14 @@ interface State {
119
137
  selection: string[] // ordered; last entry is the primary (bar anchor)
120
138
  interact: string | null
121
139
  viewTheme: string // the global theme preference; sticky across boards and reloads
122
- 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
123
141
  gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
124
- laser: boolean // laser/inspect mode (SPEC-M3 §7): frames outline their structure
142
+ laser: boolean // laser/inspect mode: frames outline their structure
125
143
  board: string // active board name; 'all-scenes' is the auto board
126
144
  boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
127
145
  deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
128
- sceneRows: string[][] | null // LEGACY scene arrangement (SPEC-023 §2); still round-trips through save
129
- 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
130
148
  layoutRaw: unknown // the author's layout VERBATIM - save round-trips this, never the parse
131
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)
132
150
  panelOpen: boolean
@@ -175,7 +193,7 @@ interface State {
175
193
  jamToast(note: JamNote): void
176
194
  dismissToast(id: number): void
177
195
  clearJamToasts(): void
178
- /** Frames Marver is editing right now (Live Jam presence, SPEC §10) - drives the working glow. */
196
+ /** Frames Marver is editing right now (Live Jam presence) - drives the working glow. */
179
197
  working: string[]
180
198
  /** When each working frame's job started (ms epoch) - phases the animations so parallel
181
199
  * frames never pulse in sync. */
@@ -203,7 +221,7 @@ export const useStore = create<State>((set, get) => {
203
221
  let switchSeq = 0 // last click wins when board switches race
204
222
  const scheduleSave = () => { editRev++; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
205
223
 
206
- // SPEC-026: one cancelable, BOARD-SCOPED reflow after content measurements settle.
224
+ // One cancelable, BOARD-SCOPED reflow after content measurements settle.
207
225
  // The captured board name is the generation guard - a debounce surviving a board
208
226
  // switch fires into a name check and dies, never touching the new board.
209
227
  let reflowTimer: ReturnType<typeof setTimeout> | undefined
@@ -237,7 +255,7 @@ export const useStore = create<State>((set, get) => {
237
255
  },
238
256
  }
239
257
 
240
- /** Published parity for content-frame sizes (SPEC-026): manual/device sizes persist
258
+ /** Published parity for content-frame sizes: manual/device sizes persist
241
259
  * to the session the same way theme pins do - a board switch or reload on a
242
260
  * published canvas must not silently drop a hand-resized spec frame. Keys are
243
261
  * frame#occurrence: node keys are minted per load on virtual boards, but the node
@@ -305,19 +323,26 @@ export const useStore = create<State>((set, get) => {
305
323
  boardHash = sha256
306
324
  if (typeof board?.auto === 'boolean') boardAuto = board.auto
307
325
  // Agent-authored boards may be just a frame list - fill sizes/keys, tidy on first load.
308
- // Keys are load-bearing (iframe identity, selection) - dupes/blanks get reminted.
309
- // 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.
310
330
  const seenKeys = new Set<string>()
331
+ const ordinals = new Map<string, number>() // per-frame keyless-occurrence counter
311
332
  nodes = (Array.isArray(board?.nodes) ? board.nodes : [])
312
333
  .filter((n: any) => n && typeof n.frame === 'string')
313
334
  .map((n: any) => {
314
335
  const f = manifest.frames.find((x) => x.id === n.frame)
315
336
  const d = f ? defaultSize(f) : { w: 390, h: 844 }
316
337
  if (typeof n.x !== 'number' || typeof n.y !== 'number') needTidy = true
317
- let key = typeof n.key === 'string' && n.key ? n.key : nodeKey()
318
- 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
+ }
319
344
  seenKeys.add(key)
320
- // content-frame size provenance round-trips (SPEC-026); auto nodes saved no
345
+ // content-frame size provenance round-trips; auto nodes saved no
321
346
  // w/h, so they fall to defaultSize (placeholder) and remeasure on mount
322
347
  const sizeMode = f?.contentWidth
323
348
  ? { sizeMode: (n.sizeMode === 'manual' || n.sizeMode === 'device' ? n.sizeMode : 'auto') as Node['sizeMode'] }
@@ -327,6 +352,7 @@ export const useStore = create<State>((set, get) => {
327
352
  frame: n.frame,
328
353
  x: typeof n.x === 'number' ? n.x : 0, y: typeof n.y === 'number' ? n.y : 0,
329
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 } : {}),
330
356
  ...sizeMode,
331
357
  // pins persist as their own field (exact round-trip). Legacy boards stored a
332
358
  // theme on EVERY node: only values differing from the frame's static default
@@ -357,7 +383,7 @@ export const useStore = create<State>((set, get) => {
357
383
  if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
358
384
  }
359
385
  // auto-managed goes both ways (friction log #15): an auto board gains new frames
360
- // 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.
361
387
  // Pruning at load DIRTIES the board (dev only) so the file on disk sheds the
362
388
  // tombstones too - otherwise a recreated frame id resurrects its stale node.
363
389
  let prunedAtLoad = false
@@ -373,6 +399,15 @@ export const useStore = create<State>((set, get) => {
373
399
  // saved piled up at the origin)
374
400
  if (boardAuto) {
375
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
+ }
376
411
  let maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0)
377
412
  for (const f of manifest.frames) {
378
413
  if (placed.has(f.id)) continue
@@ -387,7 +422,7 @@ export const useStore = create<State>((set, get) => {
387
422
  : null
388
423
  if (sibling) { x = sibling.x + sibling.w + Math.max(140, sibling.w * 0.12); y = sibling.y }
389
424
  else { x = nodes.length ? maxX + 96 : 0; y = 0 }
390
- 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'] } : {}) }
391
426
  nodes.push(node)
392
427
  maxX = Math.max(maxX, x + w)
393
428
  }
@@ -497,15 +532,23 @@ export const useStore = create<State>((set, get) => {
497
532
  const d = defaultSize(f)
498
533
  const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
499
534
  // a grouped newcomer lands BESIDE its siblings, not at the canvas bottom - the
500
- // badge/caption say "these belong together"; the position must agree (SPEC-023 §3)
535
+ // badge/caption say "these belong together"; the position must agree
501
536
  const sibs = f.variantGroup
502
537
  ? next.filter((n) => { const g = m.frames.find((x) => x.id === n.frame)?.variantGroup; return g === f.variantGroup && !n.missing })
503
538
  : []
504
539
  const right = sibs.length ? sibs.reduce((a, n) => (n.x > a.x ? n : a)) : null
505
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
+ })()
506
549
  const node = right
507
- ? { 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 }
508
- : { 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 }
509
552
  next.push(node)
510
553
  // in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
511
554
  if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
@@ -516,7 +559,21 @@ export const useStore = create<State>((set, get) => {
516
559
  let retinted = false
517
560
  for (const n of next) {
518
561
  const missing = !live.has(n.frame)
519
- 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
+ }
520
577
  // a frame's declared meta.theme may have changed - re-resolve unpinned nodes
521
578
  // (derived value: re-render yes, dirty/save no)
522
579
  const f = m.frames.find((x) => x.id === n.frame)
@@ -533,7 +590,7 @@ export const useStore = create<State>((set, get) => {
533
590
  if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
534
591
  }
535
592
  // auto boards prune deleted frames outright - "auto-managed" must manage both
536
- // directions (friction log #15). Curated boards keep the explicit card (spec §7).
593
+ // directions (friction log #15). Curated boards keep the explicit card.
537
594
  let final = next
538
595
  if (boardAuto) {
539
596
  final = next.filter((n) => !n.missing)
@@ -594,7 +651,7 @@ export const useStore = create<State>((set, get) => {
594
651
  set((s) => {
595
652
  const W = Math.max(120, w), H = Math.max(80, h)
596
653
  const cur = s.nodes.find((n) => n.key === key)
597
- // 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
598
655
  const content = !!s.manifest?.frames.find((x) => x.id === cur?.frame)?.contentWidth
599
656
  return {
600
657
  nodes: s.nodes.map((n) => (n.key === key ? { ...n, w: W, h: H, ...(content ? { sizeMode: 'manual' as const } : {}) } : n)),
@@ -610,7 +667,7 @@ export const useStore = create<State>((set, get) => {
610
667
  if (!inGesture) scheduleSave()
611
668
  },
612
669
 
613
- /** SPEC-026 sh:measure. Admission: content frames only, finite positive numbers,
670
+ /** sh:measure. Admission: content frames only, finite positive numbers,
614
671
  * clamped. A height only commits when it was measured at the width being applied;
615
672
  * auto sizes are transient - applying one never dirties the board (positions from
616
673
  * the follow-up reflow do, exactly like a human resize). */
@@ -619,7 +676,7 @@ export const useStore = create<State>((set, get) => {
619
676
  const node = s.nodes.find((n) => n.key === key)
620
677
  // Only an explicit DEVICE viewport locks a content frame's height. 'auto' and 'manual' both
621
678
  // auto-fit height: a content frame must always grow/shrink to show ALL its content, even after
622
- // 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
623
680
  // HEIGHT is still measured, so resizing width reflows and refits height (no clipped/empty frame).
624
681
  if (!node || node.sizeMode === 'device') return
625
682
  if (node.frame !== frameId) return // generation guard: a reused node key
@@ -669,7 +726,7 @@ export const useStore = create<State>((set, get) => {
669
726
  const f = s.manifest?.frames.find((x) => x.id === n.frame)
670
727
  const content = !!f?.contentWidth
671
728
  if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
672
- // 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
673
730
  const b = s.baseLayout?.[n.key]
674
731
  if (content && f) {
675
732
  const d = defaultSize(f)
@@ -714,7 +771,7 @@ export const useStore = create<State>((set, get) => {
714
771
  if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
715
772
  if (!f) return n
716
773
  const d = defaultSize(f)
717
- // 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
718
775
  return { ...n, w: d.w, h: d.h, ...(content ? { sizeMode: 'auto' as const } : {}) }
719
776
  })
720
777
  const baseLayout = s.baseLayout ? { ...s.baseLayout } : null
@@ -729,7 +786,7 @@ export const useStore = create<State>((set, get) => {
729
786
  selectAll() {
730
787
  set((s) => ({ selection: s.nodes.map((n) => n.key) }))
731
788
  },
732
- // group/scene selection from sidebar rows and canvas captions (SPEC-023 feedback)
789
+ // group/scene selection from sidebar rows and canvas captions
733
790
  selectMany(keys) {
734
791
  set((s) => {
735
792
  const valid = keys.filter((k) => s.nodes.some((n) => n.key === k))
@@ -757,7 +814,7 @@ export const useStore = create<State>((set, get) => {
757
814
  },
758
815
  setGesture(gesture) {
759
816
  set({ gesture })
760
- // 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
761
818
  // gesture ends - sizes changed, so lanes and gaps must recompute
762
819
  if (!gesture && resizedInGesture) {
763
820
  resizedInGesture = false
@@ -883,7 +940,7 @@ export const useStore = create<State>((set, get) => {
883
940
  // a resize gesture in flight = torn state (new sizes, pre-recipe positions):
884
941
  // even a PREVIOUSLY scheduled timer must defer to the gesture-end save
885
942
  if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
886
- // 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
887
944
  // session instead, and dirty must clear or switchBoard's save-flush loop wedges
888
945
  if (DATA) {
889
946
  sessionPins.write(get().board, get().nodes)
@@ -894,11 +951,11 @@ export const useStore = create<State>((set, get) => {
894
951
  const p = saveChain.then(async () => {
895
952
  // recheck INSIDE the serialized chain: a save queued behind an in-flight PUT
896
953
  // may only execute after a resize gesture began - the entry guard above ran
897
- // 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)
898
955
  if (get().gesture && resizedInGesture) { scheduleSave(); return false }
899
956
  const rev = editRev
900
957
  const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
901
- // 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.
902
959
  const board = {
903
960
  version: 1, name: boardName, auto: boardAuto,
904
961
  // deviceView and the free-form snapshot persist independently: a scoped
@@ -907,7 +964,7 @@ export const useStore = create<State>((set, get) => {
907
964
  ...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
908
965
  ...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
909
966
  // baseLayout entries for auto content nodes keep POSITIONS only - their
910
- // measured dimensions are transient and never reach the file (SPEC-026)
967
+ // measured dimensions are transient and never reach the file
911
968
  ...(baseLayout ? {
912
969
  baseLayout: Object.fromEntries(Object.entries(baseLayout).map(([k, b]) => {
913
970
  const n = nodes.find((x) => x.key === k)
@@ -915,11 +972,14 @@ export const useStore = create<State>((set, get) => {
915
972
  })),
916
973
  } : {}),
917
974
  // only PINNED themes persist - inherited values follow viewTheme at load time.
918
- // Content frames in AUTO save no dimensions (SPEC-026): measured sizes are
975
+ // Content frames in AUTO save no dimensions: measured sizes are
919
976
  // transient - a reflow-triggered save can never leak an auto height to disk.
920
- 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 }) => ({
921
981
  key, frame, x: Math.round(x), y: Math.round(y),
922
- ...(sizeMode === 'auto' ? {} : { w: Math.round(w), h: Math.round(h) }),
982
+ ...(sizeMode === 'auto' || sizeFallback ? {} : { w: Math.round(w), h: Math.round(h) }),
923
983
  ...(sizeMode === 'manual' || sizeMode === 'device' ? { sizeMode } : {}),
924
984
  ...(themeUser ? { themeUser } : {}),
925
985
  })),
@@ -28,9 +28,9 @@
28
28
  --interact: #db35f2; --interact-ring: rgba(219, 53, 242, .16);
29
29
  --interact-strong: rgba(219, 53, 242, 1); --interact-soft: rgba(234, 141, 255, .95);
30
30
  --interact-deep: rgba(176, 47, 194, .85); --interact-spark: rgba(255, 255, 255, .95);
31
- /* comments own a third mode color (Apple systemGreen): selection = blue,
32
- interact = purple, comments = green - same geometry, unique hue per mode */
33
- --comment: #34c759; --comment-ring: rgba(52, 199, 89, .22);
31
+ /* comments ride the brand blue: interact keeps purple, and a pin, a thread card
32
+ and a selected frame are told apart by shape, not by hue */
33
+ --comment: #0088ff; --comment-ring: rgba(0, 136, 255, .22);
34
34
  /* comment-card field surface: APP-scoped on purpose - the card keys to the board
35
35
  theme, and node-scoped --node-bg would flip with the frame underneath it */
36
36
  --cm-field: #fff;
@@ -67,7 +67,7 @@
67
67
  --interact: #ea8dff; --interact-ring: rgba(219, 53, 242, .3);
68
68
  --interact-strong: rgba(219, 53, 242, 1); --interact-soft: rgba(234, 141, 255, .95);
69
69
  --interact-deep: rgba(203, 48, 224, .8); --interact-spark: rgba(255, 255, 255, .9);
70
- --comment: #30d158; --comment-ring: rgba(48, 209, 88, .34);
70
+ --comment: #4da6ff; --comment-ring: rgba(77, 166, 255, .34);
71
71
  --cm-field: #0f1015;
72
72
  --cm-modal-bg: rgba(22, 22, 27, .96);
73
73
 
@@ -157,7 +157,7 @@ button svg { pointer-events: none } /* event targets stay on the button - pan
157
157
  #sh-world.sh-preset .sh-node-body,
158
158
  #sh-world.sh-preset .sh-node .sh-live { transition: width .32s cubic-bezier(.25,.46,.45,.94),
159
159
  height .32s cubic-bezier(.25,.46,.45,.94) }
160
- /* SPEC-M5 device-sweep: a frame WITH a ready lean cover jumps its LIVE iframe straight to the final
160
+ /* Device-sweep: a frame WITH a ready lean cover jumps its LIVE iframe straight to the final
161
161
  width (ONE reflow, hidden under the cover) while the lean cover - being width:100% of the animating
162
162
  body - reflows smoothly every frame (real CSS, the device-sweep fix). A frame WITHOUT a usable
163
163
  cover keeps the live width animation above. */
@@ -200,7 +200,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
200
200
  background-color: var(--head-bg); background-image: var(--head-sheen);
201
201
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
202
202
  .sh-node-head .id { font-weight: 600; color: var(--head-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
203
- /* frame icons (SPEC-026 + feedback): every sidebar row leads with one */
203
+ /* frame icons: every sidebar row leads with one */
204
204
  .sh-node-head .iicon { flex: none; color: var(--head-dim) }
205
205
  .sh-panel .sub .iicon { flex: none; margin-right: 7px; opacity: .72 }
206
206
  /* variant members sit visibly INSIDE their group: the letter chip starts
@@ -209,7 +209,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
209
209
  .sh-node-head .dim { margin-left: auto; color: var(--head-dim); flex: none; font-variant-numeric: tabular-nums }
210
210
  .sh-node-body { position: relative; background: var(--node-bg); border-radius: 0 0 var(--r-node) var(--r-node); overflow: hidden }
211
211
  .sh-node iframe { border: 0; display: block }
212
- /* SPEC-M5 LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
212
+ /* LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
213
213
  passive frame - at rest AND during pan/zoom/resize. There is NO per-gesture swap between the lean
214
214
  and the live iframe (the swap shifted text ~1-2px = "jiggle", and flashed mermaid/theme colors,
215
215
  because two documents never render pixel-identically). The live app (.sh-live) sits underneath and
@@ -290,8 +290,8 @@ body.sh-commenting .sh-lean { opacity: 0 }
290
290
  .sh-node.interact::before, .sh-node.interact::after,
291
291
  .sh-node.working::before { animation: none } }
292
292
 
293
- /* Live Jam "working" (SPEC §10): the SELECTED state's exact geometry - crisp border + 2px screen-px
294
- outline + 4px ring, edge-light off - in MARVER BLUE (Nic's pick over yellow), composed of three
293
+ /* Live Jam "working": the SELECTED state's exact geometry - crisp border + 2px screen-px
294
+ outline + 4px ring, edge-light off - in MARVER BLUE, composed of three
295
295
  layers: the border shimmer (::before orbit), the periodic content WAVE (.sh-work-wave), and the
296
296
  shimmer MATRIX of tiny marks on the left flank (.sh-work-ind). Every animated layer is phased by
297
297
  --mv-w0 (negative ms since THIS frame's job started), so parallel frames never pulse in sync.
@@ -566,7 +566,7 @@ body.sh-commenting .sh-lean { opacity: 0 }
566
566
  transition: color .15s, background .15s }
567
567
  .sh-update .x:hover { background: var(--glass-hover); color: var(--glass-ink) }
568
568
 
569
- /* bottom-RIGHT now (SPEC-live-jam §9): jam reply pills live here; plain toasts ride along */
569
+ /* bottom-RIGHT now: jam reply pills live here; plain toasts ride along */
570
570
  .sh-toasts { position: absolute; right: var(--edge); bottom: var(--edge); z-index: 32; display: flex;
571
571
  flex-direction: column; align-items: flex-end; gap: 8px; max-width: min(360px, 40vw) }
572
572
  .sh-toast { display: flex; align-items: center; gap: 6px; font: 500 12px -apple-system, system-ui, sans-serif;
@@ -624,7 +624,7 @@ body.sh-commenting .sh-lean { opacity: 0 }
624
624
  border: 0; border-radius: 50%; background: none; color: var(--glass-ink-3); cursor: pointer; transition: background .12s, color .12s }
625
625
  .sh-jam-x:hover { background: var(--glass-hover); color: var(--glass-ink) }
626
626
 
627
- /* ---- Play mode (SPEC-M2 §1): near-black stage, one centered device, auto-hiding bar.
627
+ /* ---- Play mode: near-black stage, one centered device, auto-hiding bar.
628
628
  Fixed dark palette - the backdrop never follows the board theme, so no tokens here. */
629
629
  .sh-play { position: absolute; inset: 0; z-index: 30; background: #0a0a0b; display: flex;
630
630
  align-items: center; justify-content: center; animation: sh-play-in .28s ease-out; cursor: auto }
@@ -724,8 +724,8 @@ body.sh-commenting .sh-lean { opacity: 0 }
724
724
  .sh-app :focus { outline: none }
725
725
  body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-offset: -2px }
726
726
 
727
- /* ---- Variant groups (SPEC-023 §4): badge left of grouped frames + group caption.
728
- Both live in WORLD space (scale with the canvas, per Nic's ask) with a screen-space
727
+ /* ---- Variant groups: badge left of grouped frames + group caption.
728
+ Both live in WORLD space (scale with the canvas) with a screen-space
729
729
  minimum via --sh-inv, the resize-handle trick - legible at overview zoom, proportionate
730
730
  up close. Colors are on-canvas ink (the dot-grid's world), not chrome glass. */
731
731
  /* gap to the frame is ~12px screen at working zooms, capped at 40 world px far out:
@@ -761,7 +761,7 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
761
761
  .sh-app.dark .sh-gcaption { color: rgba(245, 245, 247, .45) }
762
762
 
763
763
  /* sidebar variant rows: group header carries the experimentation mark; each variant
764
- row carries its letter CHIP (accent-filled when selected) + name (SPEC-023 §5) */
764
+ row carries its letter CHIP (accent-filled when selected) + name */
765
765
  /* gap 0: the leading .iicon's margin-right carries the spacing, so the group
766
766
  label lands on the SAME 51px text column as every sibling row */
767
767
  .sub.vgroup { display: flex; align-items: center; gap: 0 }
@@ -779,7 +779,7 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
779
779
  .sub .chip:hover { color: var(--glass-ink); background: var(--glass-hover) }
780
780
  .sub .chip.on { color: #fff; background: var(--accent); border-color: var(--accent) }
781
781
 
782
- /* play-mode variant chips (SPEC-023 §6): the SAME badge language as the sidebar's
782
+ /* play-mode variant chips: the SAME badge language as the sidebar's
783
783
  variant rows (20px, 7px-radius square, one consistent shape in every state) so the
784
784
  two surfaces read as one concept - but in the pill's own dark-glass inks, never
785
785
  theme tokens (the nav is always dark; theme ink goes invisible in light mode). */
@@ -877,7 +877,7 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
877
877
  .sub.vgroup.held { background: color-mix(in srgb, var(--glass-hover) 55%, transparent) }
878
878
  .sub.vgroup.held .gicon { color: var(--glass-ink-2) }
879
879
 
880
- /* ---- comments (SPEC-M3 §6) ----------------------------------------------------------
880
+ /* ---- comments ----------------------------------------------------------
881
881
  The marker family (pins, stacks) is SOLID with the Figma teardrop silhouette - a
882
882
  marker must hold contrast on any artwork. The surfaces (card, composer, dialog)
883
883
  are OUR glass - the shell's own token language, theme-aware where Figma is flat. */
@@ -1,7 +1,7 @@
1
1
  export interface TidyNode { key: string; frame: string; scene: string; group?: string; variant?: string; w: number; h: number }
2
2
  export interface Placed { key: string; x: number; y: number }
3
3
 
4
- // SPEC-024 lane-flow grammar: one shape at both scopes. A scope is rows XOR columns
4
+ // Lane-flow grammar: one shape at both scopes. A scope is rows XOR columns
5
5
  // of lanes; a lane is atoms + {space:n} tokens. Board atoms are scene names; a scene
6
6
  // recipe's atoms are frame basenames or a variant-group name (one indivisible run).
7
7
  export interface Space { space: number }
@@ -14,7 +14,7 @@ const isSpace = (x: unknown): x is Space => !!x && typeof x === 'object' && !Arr
14
14
  const units = (s: Space, warn: Warn): number =>
15
15
  Number.isInteger(s.space) && s.space > 0 ? s.space : (warn(`invalid space ${JSON.stringify(s.space)} - using 1`), 1)
16
16
 
17
- // Adaptive units (SPEC-024 §2): a "block" is a multiple of the proportional gutter,
17
+ // Adaptive units: a "block" is a multiple of the proportional gutter,
18
18
  // measured from the touching content's characteristic FRAME size (its largest single
19
19
  // frame) - a wide multi-frame box must not inflate its neighbors' gutters.
20
20
  const frameGapX = (w: number) => Math.max(140, w * 0.12)
@@ -251,7 +251,7 @@ function layoutScene(scene: string, members: TidyNode[], flow: Flow | undefined,
251
251
  }
252
252
 
253
253
  /**
254
- * Pure layout (spec §7 + SPEC-023 + SPEC-024). Returns positions only - the nodes
254
+ * Pure layout. Returns positions only - the nodes
255
255
  * array is never reordered (iframe law G-1). Two passes: each scene lays out its
256
256
  * frames (recipe or default lane), then the board flow places the scene boxes.
257
257
  */
@@ -272,7 +272,7 @@ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () =>
272
272
  }
273
273
 
274
274
  // pass 2: board flow over scene boxes. rows AND columns is invalid: fall back to
275
- // PLAIN tidy (default trailing lanes), never a silent pick (SPEC-024 §5)
275
+ // PLAIN tidy (default trailing lanes), never a silent pick
276
276
  let boardFlow: Flow
277
277
  if (layout && layout.rows && layout.columns) { warn('layout has rows AND columns - ignoring it (plain tidy)'); boardFlow = { rows: [] } }
278
278
  else if (layout && (layout.rows || layout.columns)) boardFlow = { rows: layout.rows, columns: layout.columns }
@@ -307,7 +307,7 @@ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () =>
307
307
  return out
308
308
  }
309
309
 
310
- /** Guarded parse of agent-authored board.layout (SPEC-024 §1). Malformed pieces warn
310
+ /** Guarded parse of agent-authored board.layout. Malformed pieces warn
311
311
  * and degrade - they never blank the board, and never vanish silently. */
312
312
  export function parseLayout(raw: unknown, warn: Warn): BoardLayout | null {
313
313
  if (raw === undefined || raw === null) return null
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The stage - play mode's single mount (SPEC-M2 §1). Unlike the frame host (one iframe
2
+ * The stage - play mode's single mount. Unlike the frame host (one iframe
3
3
  * per frame), the stage keeps ONE tree mounted - providers + layout chain - and swaps
4
4
  * only the innermost frame on data-goto, so app shells in _layout persist across
5
5
  * navigation like a real app. Swaps ride document.startViewTransition when available;
@@ -174,7 +174,7 @@ function Stage() {
174
174
  if (err) return createElement(ErrorCard, { title: 'frame failed', message: err.message, detail: err.id })
175
175
  if (!mounted) return null
176
176
  // identical wrapper references across swaps -> React preserves layout state; a chain
177
- // that differs at depth k legitimately remounts everything below (SPEC-M2 §1)
177
+ // that differs at depth k legitimately remounts everything below
178
178
  let tree: ReactNode = createElement(mounted.Frame)
179
179
  for (const W of [...mounted.wrappers].reverse()) tree = createElement(W, null, tree)
180
180
  return createElement(Boundary, { resetKey: mounted.id }, tree)
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Comment events - the PURE half of the store (SPEC-M3 §1), shared by the node side
2
+ * Comment events - the PURE half of the store, shared by the node side
3
3
  * (JSONL persistence in server/comments.ts) and the browser shell (thread state from
4
4
  * fetched events). No node imports here, ever.
5
5
  */
@@ -19,7 +19,7 @@ export interface CommentEvent {
19
19
  board?: string
20
20
  nodeKey?: string // a frame can sit on a board twice - comments are node-scoped
21
21
  frame?: string
22
- anchor?: unknown // SPEC-M3 §5 bundle; absent = frame-level comment; on 'reanchor' = the new anchor
22
+ anchor?: unknown // anchor bundle; absent = frame-level comment; on 'reanchor' = the new anchor
23
23
  author?: { email: string; name?: string; avatar?: string }
24
24
  body?: string // plain text in v1
25
25
  emoji?: string // react events
@@ -0,0 +1,22 @@
1
+ /** Automatic attribution for the powered-by links: every canvas tags its marver.design
2
+ * link so site analytics can see WHICH canvas sent a visitor - zero user setup.
3
+ * Conventional UTM semantics (stable groupings first, the variable last):
4
+ * utm_source = the surface class ('published-canvas' | 'dev-canvas')
5
+ * utm_medium = the link unit ('powered-by')
6
+ * utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
7
+ * utm_content = the placement ('gate' badge | 'shell' wordmark)
8
+ */
9
+ export function poweredByUrl(
10
+ canvasName: string | undefined,
11
+ source: 'published-canvas' | 'dev-canvas',
12
+ content: 'gate' | 'shell',
13
+ ): string {
14
+ const slug = (canvasName ?? '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
15
+ const q = new URLSearchParams({
16
+ utm_source: source,
17
+ utm_medium: 'powered-by',
18
+ ...(slug ? { utm_campaign: slug } : {}),
19
+ utm_content: content,
20
+ })
21
+ return `https://marver.design/?${q.toString()}`
22
+ }