@marver-design/marver 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +97 -5
  2. package/README.md +72 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-B8z902TW.mjs → build-BBVQRetk.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-pLGzSvm5.mjs → collab-s3k5byM1.mjs} +3 -3
  7. package/dist/{comments-DSwMhdbZ.mjs → comments-BZBKhKRO.mjs} +2 -2
  8. package/dist/{comments-BrpC86Kf.mjs → comments-J06jqCVV.mjs} +3 -3
  9. package/dist/{daemon-C-huGHAM.mjs → daemon-DkyNOwIt.mjs} +30 -51
  10. package/dist/{dev-BYm9ebfN.mjs → dev-BTAhTie-.mjs} +19 -5
  11. package/dist/{init-DrLomSWq.mjs → init-DWdhjJD5.mjs} +16 -3
  12. package/dist/{ledger-wFvEIEGi.mjs → ledger-CbzTJrV2.mjs} +1 -1
  13. package/dist/{manifest-D3eaARf4.mjs → manifest-B4zcDGBf.mjs} +3 -3
  14. package/dist/{plugin-BVFuRfEo.mjs → plugin-BdQEeTLg.mjs} +41 -9
  15. package/dist/{serve-CwAfayJk.mjs → serve-CZqPnj19.mjs} +32 -8
  16. package/dist/{sync-Ch4Bymb1.mjs → sync-BJKKmy1n.mjs} +2 -2
  17. package/dist/work-CLrmY-vQ.mjs +97 -0
  18. package/dist/work-lzC-lPY0.mjs +76 -0
  19. package/package.json +16 -1
  20. package/src/client/const.ts +1 -1
  21. package/src/client/content/diagram.tsx +1 -1
  22. package/src/client/content/index.tsx +2 -2
  23. package/src/client/content/md.ts +1 -1
  24. package/src/client/content/palette.ts +2 -2
  25. package/src/client/frame-host/bridge.js +1 -1
  26. package/src/client/frame-host/inspect.js +1 -1
  27. package/src/client/frame-host/serialize.ts +2 -2
  28. package/src/client/shell/App.tsx +79 -22
  29. package/src/client/shell/Comments.tsx +24 -18
  30. package/src/client/shell/Play.tsx +2 -2
  31. package/src/client/shell/canvas/Canvas.tsx +1 -1
  32. package/src/client/shell/canvas/FrameNode.tsx +16 -11
  33. package/src/client/shell/canvas/snapshots.ts +3 -3
  34. package/src/client/shell/comments-store.ts +115 -42
  35. package/src/client/shell/hash.ts +2 -2
  36. package/src/client/shell/icons.tsx +1 -1
  37. package/src/client/shell/keys.ts +39 -0
  38. package/src/client/shell/mentions.ts +1 -1
  39. package/src/client/shell/perf.ts +1 -1
  40. package/src/client/shell/store.ts +99 -39
  41. package/src/client/shell/styles.css +12 -12
  42. package/src/client/shell/tidy.ts +5 -5
  43. package/src/client/stage/main.tsx +2 -2
  44. package/src/shared/events.ts +2 -2
  45. package/src/shared/utm.ts +22 -0
  46. package/templates/AGENTS-embedded.md +48 -1
  47. package/templates/AGENTS-studio.md +48 -1
  48. package/templates/instructions/publish.md +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
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,8 +11,8 @@ 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)
@@ -21,16 +21,20 @@ interface CommentsState {
21
21
  show: boolean // Shift+C - pins visible at all
22
22
  showAnchor: boolean // Shift+L - light the tagged ELEMENT while its thread is open
23
23
  active: string | null // open thread id
24
- 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
25
27
  needsIdentity: boolean // published viewer tried to comment while signed out
26
28
  inviteToken: string | null // arrived via an invite link - claim flow, token known
27
29
 
28
30
  load(board: string): Promise<void>
29
31
  live(board: string): () => void
30
- /** Fetch the active board's log NOW (Live Jam: the daemon just wrote a reply) - same union path
31
- * as the poll, so dedup + the reply notification behave identically. */
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. */
32
35
  poke(board?: string): void
33
- send(events: CommentEvent[]): Promise<boolean>
36
+ /** `board` routes the write to a thread's ORIGIN log; default = the active board. */
37
+ send(events: CommentEvent[], board?: string): Promise<boolean>
34
38
  create(body: string): Promise<void>
35
39
  reply(threadId: string, body: string): Promise<void>
36
40
  replyOk(threadId: string, body: string): Promise<boolean>
@@ -57,34 +61,87 @@ const api = async (path: string, body?: unknown) => {
57
61
  }
58
62
 
59
63
  export const useComments = create<CommentsState>((set, get) => {
60
- const derive = (events: CommentEvent[]) => ({ events, threads: replay(events) })
61
- // Live Jam (SPEC §9): a Marver reply that arrives AFTER the initial load (load() baselines via
62
- // derive, so pre-existing replies never notify) raises a persistent bottom-right pill. Keyed on
63
- // the event id via `union`'s fresh filter, so it fires exactly once; active-board only by
64
- // construction (union runs against the active board's poll).
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.
65
86
  const notifyAgent = (fresh: CommentEvent[]) => {
66
87
  const replies = fresh.filter((e) => e.agent && e.type === 'reply')
67
88
  if (!replies.length) return
68
89
  void import('./store.ts').then(({ useStore }) => {
69
- const board = get().board ?? ''
70
90
  const s = useStore.getState()
71
91
  for (const e of replies) {
72
92
  const threadId = e.parentId ?? e.commentId ?? ''
73
93
  // the FRAME is the news: resolve the reply's thread -> frame -> manifest title + intent
74
- const frame = get().threads.find((t) => t.id === threadId)?.frame
75
- const entry = frame ? s.manifest?.frames.find((f) => f.id === frame) : undefined
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
76
96
  s.jamToast({
77
- threadId, board, ts: e.ts,
97
+ threadId, board: t?.board ?? e.board ?? get().board ?? '', ts: e.ts,
78
98
  preview: (e.body ?? '').replace(/\s+/g, ' ').slice(0, 90),
79
- frame, frameTitle: entry?.title ?? frame, intent: entry?.intent,
99
+ frame: t?.frame, frameTitle: entry?.title ?? t?.frame, intent: entry?.intent,
80
100
  })
81
101
  }
82
102
  })
83
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}`
84
111
  const union = (events: CommentEvent[]) => {
85
- const have = new Set(get().events.map((e) => e.id))
86
- const fresh = events.filter((e) => !have.has(e.id))
87
- if (fresh.length) { set(derive([...get().events, ...fresh])); notifyAgent(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) })
88
145
  }
89
146
 
90
147
  return {
@@ -92,17 +149,24 @@ export const useComments = create<CommentsState>((set, get) => {
92
149
  commentMode: false, show: true, showAnchor: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
93
150
 
94
151
  poke(board) {
95
- const b = get().board
96
- if (!b || (board && board !== b)) return // only the active board renders; others load on switch
97
- void api(`comments/${b}`).then((r) => { if (r.ok && get().board === b) union(r.data.events ?? []) })
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
+ })
98
159
  },
99
160
 
100
161
  async load(board) {
101
- 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 })
102
166
  const me = await api('me')
167
+ if (me.status === 404) { apiOff = true; return } // static serve: no comments API at all
103
168
  if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local, connected: !!me.data.connected })
104
- const res = await api(`comments/${board}`)
105
- if (res.ok && get().board === board) set(derive(res.data.events ?? []))
169
+ await fetchAll()
106
170
  },
107
171
 
108
172
  /** Liveness: SSE on the published serve; dev has no event rail (its sync loop
@@ -111,12 +175,13 @@ export const useComments = create<CommentsState>((set, get) => {
111
175
  const stops: (() => void)[] = []
112
176
  let es: EventSource | null = null
113
177
  const wantSSE = () => {
114
- if (es || get().local) return
178
+ if (es || get().local || apiOff) return
115
179
  es = new EventSource(`${ROUTE}/api/events`)
116
180
  es.addEventListener('comment', (e) => {
117
181
  try {
118
182
  const { board: b, ev } = JSON.parse((e as MessageEvent).data)
119
- 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 : ''))
120
185
  } catch { /* ignore */ }
121
186
  })
122
187
  es.addEventListener('resync', () => { void get().load(board) })
@@ -125,7 +190,7 @@ export const useComments = create<CommentsState>((set, get) => {
125
190
  // `local` is only known once load() has answered - defer the SSE decision past it
126
191
  const t = setTimeout(wantSSE, 1500)
127
192
  stops.push(() => { clearTimeout(t); es?.close() })
128
- 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() }
129
194
  const iv = setInterval(poll, 30_000)
130
195
  const onFocus = () => poll()
131
196
  window.addEventListener('focus', onFocus)
@@ -133,10 +198,15 @@ export const useComments = create<CommentsState>((set, get) => {
133
198
  return () => stops.forEach((f) => f())
134
199
  },
135
200
 
136
- async send(events) {
137
- const { board } = get()
138
- if (!board) return false
139
- 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 })
140
210
  if (res.status === 401) { set({ needsIdentity: true }); return false }
141
211
  // any other refusal gets said out loud - a silent dead Enter key reads as a bug
142
212
  // (the canonical case: a signed-in viewer on a read-only board)
@@ -144,11 +214,10 @@ export const useComments = create<CommentsState>((set, get) => {
144
214
  const { useStore } = await import('./store.ts')
145
215
  useStore.getState().toast(String((res.data as any)?.error ?? 'comment rejected'))
146
216
  }
147
- // union only what the server took - a rejected send must not leave phantoms -
148
- // and only if the user is still LOOKING at that board (a slow response after a
149
- // board switch must not leak events into the wrong client state); client ids
150
- // keep the eventual SSE/poll echo idempotent either way
151
- 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)
152
221
  return res.ok
153
222
  },
154
223
 
@@ -160,28 +229,32 @@ export const useComments = create<CommentsState>((set, get) => {
160
229
  id: uuid(), ts: Date.now(), type: 'create', commentId: id,
161
230
  nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
162
231
  author: me ?? undefined, body: body.trim(),
163
- }])
232
+ }], draft.board)
164
233
  if (ok) set({ draft: null, active: id, commentMode: false })
165
234
  },
166
235
 
167
236
  async reply(threadId, body) { await get().replyOk(threadId, body) },
168
237
  async replyOk(threadId, body) {
169
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
170
242
  return get().send([{
171
243
  id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
172
244
  author: get().me ?? undefined, body: body.trim(),
173
- }])
245
+ }], origin)
174
246
  },
175
247
 
176
248
  async resolve(threadId, reopen = false) {
177
- 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)
178
251
  },
179
252
 
180
253
  setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
181
254
  setShow(show) { set({ show }) },
182
255
  setShowAnchor(showAnchor) { set({ showAnchor }) },
183
256
  setActive(active) { set({ active }) },
184
- setDraft(draft) { set({ draft }) },
257
+ setDraft(draft) { set({ draft: draft ? { ...draft, board: draft.board ?? get().board ?? undefined } : null }) },
185
258
  dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
186
259
 
187
260
  async signIn(email, password) {
@@ -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
  */
@@ -53,7 +53,7 @@ export const PencilSimpleIcon = icon(<path d="M227.31,73.37,182.63,28.68a16,16,0
53
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" />)
54
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" /></>)
55
55
 
56
- /* Content-frame intent glyphs (SPEC-026), custom in the same 256-space. */
56
+ /* Content-frame intent glyphs, custom in the same 256-space. */
57
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" /></>)
58
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" /></>)
59
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
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @marver mention parsing (SPEC-live-jam §1) - pure, so the rendering is unit-testable without
2
+ * @marver mention parsing - pure, so the rendering is unit-testable without
3
3
  * pulling the whole comment UI (and its virtual:sh-config) into the test.
4
4
  */
5
5
 
@@ -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
  *