@marver-design/marver 0.3.1 → 0.5.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 (50) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +2 -0
  3. package/dist/auth-B36fMCM3.mjs +245 -0
  4. package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
  5. package/dist/cli.mjs +15 -6
  6. package/dist/collab-CXy8gqoz.mjs +297 -0
  7. package/dist/comments-Ba8mU600.mjs +90 -0
  8. package/dist/comments-odHzYdO3.mjs +179 -0
  9. package/dist/dev-DdeU-Jst.mjs +162 -0
  10. package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
  11. package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
  12. package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
  13. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  14. package/dist/serve-D_KBK7Oy.mjs +431 -0
  15. package/dist/sync-CkBk-tUk.mjs +248 -0
  16. package/package.json +3 -1
  17. package/src/client/content/diagram.tsx +46 -2
  18. package/src/client/content/index.tsx +6 -2
  19. package/src/client/content/md.ts +29 -0
  20. package/src/client/content/palette.ts +6 -0
  21. package/src/client/frame-host/bridge.js +269 -3
  22. package/src/client/frame-host/serialize.ts +195 -0
  23. package/src/client/shell/App.tsx +131 -17
  24. package/src/client/shell/Comments.tsx +464 -0
  25. package/src/client/shell/Play.tsx +24 -0
  26. package/src/client/shell/canvas/Canvas.tsx +66 -29
  27. package/src/client/shell/canvas/FrameNode.tsx +130 -10
  28. package/src/client/shell/canvas/frame-registry.ts +18 -0
  29. package/src/client/shell/canvas/snapshots.ts +233 -0
  30. package/src/client/shell/comments-store.ts +180 -0
  31. package/src/client/shell/cursor-arrow-dark.svg +1 -0
  32. package/src/client/shell/cursor-arrow.svg +1 -0
  33. package/src/client/shell/hash.ts +14 -3
  34. package/src/client/shell/icons.tsx +6 -0
  35. package/src/client/shell/labels.ts +10 -0
  36. package/src/client/shell/perf.ts +92 -0
  37. package/src/client/shell/store.ts +101 -6
  38. package/src/client/shell/styles.css +196 -7
  39. package/src/client/stage/main.tsx +2 -0
  40. package/src/shared/events.ts +103 -0
  41. package/templates/AGENTS-embedded.md +13 -1
  42. package/templates/AGENTS-studio.md +13 -1
  43. package/templates/instructions/boards.md +6 -3
  44. package/templates/instructions/configure.md +28 -0
  45. package/templates/instructions/iterate.md +38 -0
  46. package/templates/instructions/publish.md +129 -0
  47. package/templates/instructions/reference/color.md +22 -1
  48. package/templates/instructions/shape.md +23 -16
  49. package/dist/dev-C2oTKuXe.mjs +0 -97
  50. package/dist/serve-OtA9Nlow.mjs +0 -207
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Comment state for the shell (SPEC-M3 §6) - its own small store beside the canvas
3
+ * store. One API client for both worlds: `marver dev` (api.ts mirror, local profile,
4
+ * poll) and the published serve (collab.ts, sessions, SSE). The shell never branches
5
+ * on which one it is beyond capability flags the endpoints themselves report.
6
+ */
7
+ import { create } from 'zustand'
8
+ import { replay, type CommentEvent, type Thread } from '../../shared/events.ts'
9
+ import { ROUTE } from '../const.ts'
10
+
11
+ export interface Me { email: string; name: string; avatar?: string }
12
+
13
+ interface CommentsState {
14
+ events: CommentEvent[] // the active board's log
15
+ threads: Thread[] // derived on every change
16
+ board: string | null
17
+ me: Me | null // null on published until signed in
18
+ local: boolean // dev mirror (identity is the local profile)
19
+ commentMode: boolean // C - picking + composing
20
+ show: boolean // Shift+C - pins visible at all
21
+ active: string | null // open thread id
22
+ draft: { nodeKey: string; frame: string; anchor: unknown } | null // picked, composing
23
+ needsIdentity: boolean // published viewer tried to comment while signed out
24
+ inviteToken: string | null // arrived via an invite link - claim flow, token known
25
+
26
+ load(board: string): Promise<void>
27
+ live(board: string): () => void
28
+ send(events: CommentEvent[]): Promise<boolean>
29
+ create(body: string): Promise<void>
30
+ reply(threadId: string, body: string): Promise<void>
31
+ replyOk(threadId: string, body: string): Promise<boolean>
32
+ resolve(threadId: string, reopen?: boolean): Promise<void>
33
+ setMode(on: boolean): void
34
+ setShow(show: boolean): void
35
+ setActive(id: string | null): void
36
+ setDraft(d: CommentsState['draft']): void
37
+ signIn(email: string, password: string): Promise<string | null>
38
+ claim(token: string, password: string, name: string, avatar?: string): Promise<string | null>
39
+ saveProfile(patch: Partial<Me>): Promise<void>
40
+ dismissIdentity(): void
41
+ }
42
+
43
+ const uuid = () => crypto.randomUUID()
44
+ const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
45
+ const api = async (path: string, body?: unknown) => {
46
+ const res = await fetch(`${ROUTE}/api/${path}`, body === undefined ? undefined : {
47
+ method: 'POST', headers: { 'content-type': 'application/json', 'x-mv-c': csrf() },
48
+ body: JSON.stringify(body),
49
+ })
50
+ return { ok: res.ok, status: res.status, data: await res.json().catch(() => ({})) }
51
+ }
52
+
53
+ export const useComments = create<CommentsState>((set, get) => {
54
+ const derive = (events: CommentEvent[]) => ({ events, threads: replay(events) })
55
+ const union = (events: CommentEvent[]) => {
56
+ const have = new Set(get().events.map((e) => e.id))
57
+ const fresh = events.filter((e) => !have.has(e.id))
58
+ if (fresh.length) set(derive([...get().events, ...fresh]))
59
+ }
60
+
61
+ return {
62
+ events: [], threads: [], board: null, me: null, local: false,
63
+ commentMode: false, show: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
64
+
65
+ async load(board) {
66
+ set({ board })
67
+ const me = await api('me')
68
+ if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local })
69
+ const res = await api(`comments/${board}`)
70
+ if (res.ok && get().board === board) set(derive(res.data.events ?? []))
71
+ },
72
+
73
+ /** Liveness: SSE on the published serve; dev has no event rail (its sync loop
74
+ * writes files) so it polls only - an EventSource there would 404-retry forever. */
75
+ live(board) {
76
+ const stops: (() => void)[] = []
77
+ let es: EventSource | null = null
78
+ const wantSSE = () => {
79
+ if (es || get().local) return
80
+ es = new EventSource(`${ROUTE}/api/events`)
81
+ es.addEventListener('comment', (e) => {
82
+ try {
83
+ const { board: b, ev } = JSON.parse((e as MessageEvent).data)
84
+ if (b === get().board) union([ev])
85
+ } catch { /* ignore */ }
86
+ })
87
+ es.addEventListener('resync', () => { void get().load(board) })
88
+ es.onerror = () => { /* EventSource retries itself; the poll covers the gap */ }
89
+ }
90
+ // `local` is only known once load() has answered - defer the SSE decision past it
91
+ const t = setTimeout(wantSSE, 1500)
92
+ stops.push(() => { clearTimeout(t); es?.close() })
93
+ const poll = () => { if (get().board === board) void api(`comments/${board}`).then((r) => r.ok && union(r.data.events ?? [])) }
94
+ const iv = setInterval(poll, 30_000)
95
+ const onFocus = () => poll()
96
+ window.addEventListener('focus', onFocus)
97
+ stops.push(() => { clearInterval(iv); window.removeEventListener('focus', onFocus) })
98
+ return () => stops.forEach((f) => f())
99
+ },
100
+
101
+ async send(events) {
102
+ const { board } = get()
103
+ if (!board) return false
104
+ const res = await api(`comments/${board}`, { events })
105
+ if (res.status === 401) { set({ needsIdentity: true }); return false }
106
+ // any other refusal gets said out loud - a silent dead Enter key reads as a bug
107
+ // (the canonical case: a signed-in viewer on a read-only board)
108
+ if (!res.ok) {
109
+ const { useStore } = await import('./store.ts')
110
+ useStore.getState().toast(String((res.data as any)?.error ?? 'comment rejected'))
111
+ }
112
+ // union only what the server took - a rejected send must not leave phantoms -
113
+ // and only if the user is still LOOKING at that board (a slow response after a
114
+ // board switch must not leak events into the wrong client state); client ids
115
+ // keep the eventual SSE/poll echo idempotent either way
116
+ if (res.ok && get().board === board) union(events)
117
+ return res.ok
118
+ },
119
+
120
+ async create(body) {
121
+ const { draft, me } = get()
122
+ if (!draft || !body.trim()) return
123
+ const id = uuid()
124
+ const ok = await get().send([{
125
+ id: uuid(), ts: Date.now(), type: 'create', commentId: id,
126
+ nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
127
+ author: me ?? undefined, body: body.trim(),
128
+ }])
129
+ if (ok) set({ draft: null, active: id, commentMode: false })
130
+ },
131
+
132
+ async reply(threadId, body) { await get().replyOk(threadId, body) },
133
+ async replyOk(threadId, body) {
134
+ if (!body.trim()) return false
135
+ return get().send([{
136
+ id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
137
+ author: get().me ?? undefined, body: body.trim(),
138
+ }])
139
+ },
140
+
141
+ async resolve(threadId, reopen = false) {
142
+ await get().send([{ id: uuid(), ts: Date.now(), type: reopen ? 'reopen' : 'resolve', commentId: threadId }])
143
+ },
144
+
145
+ setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
146
+ setShow(show) { set({ show }) },
147
+ setActive(active) { set({ active }) },
148
+ setDraft(draft) { set({ draft }) },
149
+ dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
150
+
151
+ async signIn(email, password) {
152
+ const res = await api('auth/signin', { email, password })
153
+ if (!res.ok) return res.data?.error ?? 'sign-in failed'
154
+ set({ me: res.data.user, needsIdentity: false })
155
+ return null
156
+ },
157
+ async claim(token, password, name, avatar) {
158
+ const res = await api('auth/claim', { token, password, name, avatar })
159
+ if (!res.ok) return res.data?.error ?? 'claim failed'
160
+ set({ me: res.data.user, needsIdentity: false, inviteToken: null })
161
+ return null
162
+ },
163
+ async saveProfile(patch) {
164
+ const res = await api('profile', patch)
165
+ if (res.ok) set({ me: res.data.user })
166
+ },
167
+ }
168
+ })
169
+
170
+ // dev-only debug handle - the canvas store exposes the same
171
+ if (typeof window !== 'undefined' && (import.meta as any).env?.DEV) (window as any).__mvComments = useComments
172
+
173
+ /** Initials + deterministic hue for avatarless authors - the whole fallback ladder. */
174
+ export const avatarFallback = (author?: { email?: string; name?: string }) => {
175
+ const name = author?.name || author?.email || '?'
176
+ const initials = name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase()
177
+ let h = 0
178
+ for (const c of (author?.email || name)) h = (h * 31 + c.charCodeAt(0)) % 360
179
+ return { initials, hue: h }
180
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 26 26"><defs><filter id="s" x="-40%" y="-40%" width="180%" height="180%"><feDropShadow dx="0.4" dy="0.9" stdDeviation="0.7" flood-color="#000000" flood-opacity="0.4"/></filter></defs><g filter="url(#s)"><g transform="rotate(-20 6 3)"><path fill="#FFFFFF" stroke="#18181b" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a1 1 0 0 1 .7-.3h6.87a1 1 0 0 0 .7-1.7L6.35 2.85a.5.5 0 0 0-.85.35Z"/></g></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 26 26"><defs><filter id="s" x="-40%" y="-40%" width="180%" height="180%"><feDropShadow dx="0.4" dy="0.9" stdDeviation="0.7" flood-color="#000000" flood-opacity="0.4"/></filter></defs><g filter="url(#s)"><g transform="rotate(-20 6 3)"><path fill="#000000" stroke="#FFFFFF" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a1 1 0 0 1 .7-.3h6.87a1 1 0 0 0 .7-1.7L6.35 2.85a.5.5 0 0 0-.85.35Z"/></g></g></svg>
@@ -7,12 +7,16 @@
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)
11
+ * #/i/<token> invite link - opens the claim dialog with the token
10
12
  * #/p/<board>?at=<frame-id>&device=<viewport>&theme=<theme> play mode
11
13
  */
12
14
 
13
15
  export interface HashState {
14
16
  board?: string
15
17
  n?: string[]
18
+ c?: string
19
+ invite?: string
16
20
  play?: { at?: string; device?: string; theme?: string }
17
21
  }
18
22
 
@@ -25,13 +29,16 @@ export function parseHash(hash: string = location.hash): HashState {
25
29
  const q = raw.indexOf('?')
26
30
  const path = q === -1 ? raw : raw.slice(0, q)
27
31
  const params = new URLSearchParams(q === -1 ? '' : raw.slice(q + 1))
32
+ const mi = path.match(/^\/i\/([\w-]{8,128})$/)
33
+ if (mi) return { invite: mi[1] }
28
34
  const m = path.match(/^\/(b|p)\/([^/?]+)$/)
29
35
  if (!m) return {}
30
36
  const board = decodeURIComponent(m[2])
31
37
  if (!BOARD_RE.test(board)) return {}
32
38
  if (m[1] === 'b') {
33
39
  const n = (params.get('n') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
34
- return { board, ...(n.length ? { n } : {}) }
40
+ const c = params.get('c') ?? undefined
41
+ return { board, ...(n.length ? { n } : {}), ...(c && /^[\w-]+$/.test(c) ? { c } : {}) }
35
42
  }
36
43
  return {
37
44
  board,
@@ -51,8 +58,12 @@ export function buildHash(s: HashState): string {
51
58
  if (s.play.theme) p.set('theme', s.play.theme)
52
59
  return `#/p/${s.board}?${p}`
53
60
  }
54
- if (!s.board || (s.board === 'all-scenes' && !s.n?.length)) return '#/'
55
- return `#/b/${s.board}${s.n?.length ? `?n=${s.n.join(',')}` : ''}`
61
+ if (!s.board || (s.board === 'all-scenes' && !s.n?.length && !s.c)) return '#/'
62
+ const p = new URLSearchParams()
63
+ if (s.n?.length) p.set('n', s.n.join(','))
64
+ if (s.c) p.set('c', s.c)
65
+ const q = p.toString()
66
+ return `#/b/${s.board}${q ? `?${decodeURIComponent(q)}` : ''}`
56
67
  }
57
68
 
58
69
  /** Write the hash; identical URLs are skipped so restore paths never loop. */
@@ -27,6 +27,10 @@ export const PlusIcon = icon(<path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16
27
27
  export const XIcon = icon(<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />)
28
28
  export const ReloadIcon = icon(<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z" />)
29
29
  export const CheckIcon = icon(<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z" />)
30
+ export const ColumnsIcon = icon(<path d="M104,32H64A16,16,0,0,0,48,48V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32Zm0,176H64V48h40ZM192,32H152a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,192,32Zm0,176H152V48h40Z" />)
31
+ export const ArrowUpIcon = icon(<path d="M205.66,117.66a8,8,0,0,1-11.32,0L136,59.31V216a8,8,0,0,1-16,0V59.31L61.66,117.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0l72,72A8,8,0,0,1,205.66,117.66Z" />)
32
+ export const CommentIcon = icon(<path d="M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Zm0,184H48V124a84,84,0,1,1,84,84Z" />)
33
+ export const CheckSquareOffsetIcon = icon(<path d="M224,48V208a16,16,0,0,1-16,16H136a8,8,0,0,1,0-16h72V48H48v96a8,8,0,0,1-16,0V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM125.66,154.34a8,8,0,0,0-11.32,0L64,204.69,45.66,186.34a8,8,0,0,0-11.32,11.32l24,24a8,8,0,0,0,11.32,0l56-56A8,8,0,0,0,125.66,154.34Z" />)
30
34
  export const DevicesIcon = icon(<path d="M224,72H208V64a24,24,0,0,0-24-24H40A24,24,0,0,0,16,64v96a24,24,0,0,0,24,24H152v8a24,24,0,0,0,24,24h48a24,24,0,0,0,24-24V96A24,24,0,0,0,224,72ZM40,168a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H184a8,8,0,0,1,8,8v8H176a24,24,0,0,0-24,24v72Zm192,24a8,8,0,0,1-8,8H176a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8Zm-96,16a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h40A8,8,0,0,1,136,208Zm80-96a8,8,0,0,1-8,8H192a8,8,0,0,1,0-16h16A8,8,0,0,1,216,112Z" />)
31
35
  export const DeviceMobileIcon = icon(<path d="M176,16H80A24,24,0,0,0,56,40V216a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V40A24,24,0,0,0,176,16ZM72,64H184V192H72Zm8-32h96a8,8,0,0,1,8,8v8H72V40A8,8,0,0,1,80,32Zm96,192H80a8,8,0,0,1-8-8v-8H184v8A8,8,0,0,1,176,224Z" />)
32
36
  export const DeviceTabletIcon = icon(<path d="M192,24H64A24,24,0,0,0,40,48V208a24,24,0,0,0,24,24H192a24,24,0,0,0,24-24V48A24,24,0,0,0,192,24ZM56,72H200V184H56Zm8-32H192a8,8,0,0,1,8,8v8H56V48A8,8,0,0,1,64,40ZM192,216H64a8,8,0,0,1-8-8v-8H200v8A8,8,0,0,1,192,216Z" />)
@@ -41,6 +45,8 @@ export const PanelFilledIcon = icon(<><rect x="32" y="48" width="192" height="16
41
45
 
42
46
  /** Plain UI frame - the rectangle every non-content sidebar row leads with. */
43
47
  export const FrameRectIcon = icon(<rect x="40" y="52" width="176" height="152" rx="20" fill="none" stroke="currentColor" strokeWidth="16" />)
48
+ 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" />)
49
+ 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" /></>)
44
50
 
45
51
  /* Content-frame intent glyphs (SPEC-026), custom in the same 256-space. */
46
52
  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" /></>)
@@ -0,0 +1,10 @@
1
+ /** Pure display-label helpers. Standalone (no virtual-module imports) so they are unit-testable
2
+ * and cheap to import anywhere. Re-exported from store.ts for existing call sites. */
3
+
4
+ export const cap = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s)
5
+
6
+ /** D5: derive a display label from a kebab slug - drop the dashes, Title Case each word.
7
+ * For board/scene NAMES only (device/theme names are single words, cap is right there).
8
+ * Acronyms ("tms" -> "Tms") await the board `title` override (C3); an explicit title,
9
+ * once present, is shown verbatim and never passes through here. */
10
+ export const humanize = (s: string): string => s.replace(/-/g, ' ').replace(/(^|\s)\S/g, (c) => c.toUpperCase())
@@ -0,0 +1,92 @@
1
+ /**
2
+ * B0.4: canvas performance instrumentation. Zero cost unless explicitly enabled.
3
+ *
4
+ * Enabled in dev, or in ANY build (incl. packed/published - the SPEC-M4 gate is dev AND
5
+ * publish) via `?mvperf` in the URL or `localStorage.mvPerf==='1'`. So publish-side gate
6
+ * measurement is possible without shipping an always-on probe.
7
+ *
8
+ * Two honest signals, sampled ONLY while a gesture is active (the `#sh-world.sh-gesturing`
9
+ * class every gesture path already sets):
10
+ * - frame INTERVALS (rAF deltas). A healthy 60Hz frame is ~16.7ms, so the interval is NOT
11
+ * "main-thread work" - we report its p50/p95/max and, as the jank signal, `dropped` =
12
+ * intervals over 32ms (a missed refresh).
13
+ * - long-task durations (PerformanceObserver 'longtask', >50ms by spec) landing during a
14
+ * gesture: actual main-thread work that blew the budget. 0 long-tasks = smooth.
15
+ *
16
+ * Read it live: __mvPerf.report() / __mvPerf.reset()
17
+ * perfMark(name) + counters are the plug points Stages 2-3 use (blank-frame / warm-latency).
18
+ */
19
+
20
+ // "in motion" = a pan/zoom/drag gesture OR a device-sweep/tidy preset animation - both are the
21
+ // windows where jank matters and the perf gate is measured.
22
+ const GESTURING = () => {
23
+ const w = document.getElementById('sh-world')
24
+ return !!w && (w.classList.contains('sh-camera') || w.classList.contains('sh-preset'))
25
+ }
26
+
27
+ const frames: number[] = [] // inter-frame intervals (ms) during gestures
28
+ const tasks: number[] = [] // long-task durations (ms) during gestures
29
+ const MAX = 1200
30
+ let last = 0
31
+ let raf = 0
32
+
33
+ const counters: Record<string, number> = { blankFrames: 0 }
34
+ const marks: { name: string; t: number }[] = []
35
+
36
+ const enabled = (): boolean => {
37
+ if (import.meta.env.DEV) return true
38
+ try {
39
+ if (typeof location !== 'undefined' && /[?&]mvperf\b/.test(location.search)) return true
40
+ return localStorage.getItem('mvPerf') === '1'
41
+ } catch { return false }
42
+ }
43
+
44
+ const tick = (t: number) => {
45
+ if (last && GESTURING()) { const dt = t - last; frames.push(dt); if (frames.length > MAX) frames.shift() }
46
+ last = t
47
+ raf = requestAnimationFrame(tick)
48
+ }
49
+
50
+ const pct = (sorted: number[], p: number): number => {
51
+ if (!sorted.length) return 0
52
+ const i = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))
53
+ return Math.round(sorted[i] * 100) / 100
54
+ }
55
+ const top = (sorted: number[]): number => (sorted.length ? Math.round(sorted[sorted.length - 1] * 100) / 100 : 0)
56
+ const frac = (n: number, d: number): number => (d ? Math.round((n / d) * 1000) / 1000 : 0)
57
+
58
+ /** One-shot event later stages record (e.g. a blank frame, or warm-promotion latency). */
59
+ export const perfMark = (name: string): void => {
60
+ counters[name] = (counters[name] ?? 0) + 1
61
+ marks.push({ name, t: last })
62
+ if (marks.length > 400) marks.shift()
63
+ }
64
+
65
+ /** Start the sampler. Idempotent; a no-op unless enabled() (dev, ?mvperf, or localStorage). */
66
+ export function startPerf(): void {
67
+ if (raf || !enabled()) return
68
+ raf = requestAnimationFrame(tick)
69
+ try {
70
+ const obs = new PerformanceObserver((list) => {
71
+ for (const e of list.getEntries()) if (GESTURING()) { tasks.push(e.duration); if (tasks.length > MAX) tasks.shift() }
72
+ })
73
+ obs.observe({ type: 'longtask', buffered: false })
74
+ } catch { /* longtask entry type unsupported (e.g. Safari) - frame intervals still sample */ }
75
+ ;(window as unknown as { __mvPerf: unknown }).__mvPerf = {
76
+ report() {
77
+ const fi = [...frames].sort((a, b) => a - b)
78
+ const tw = [...tasks].sort((a, b) => a - b)
79
+ const dropped = fi.filter((d) => d > 32).length
80
+ return {
81
+ gestureFrames: fi.length,
82
+ frameP50: pct(fi, 50), frameP95: pct(fi, 95), frameMax: top(fi),
83
+ dropped, droppedFrac: frac(dropped, fi.length),
84
+ // actual main-thread work during gestures (long tasks >50ms). 0 = smooth.
85
+ longTasks: tw.length, longTaskP95: pct(tw, 95), longTaskMax: top(tw),
86
+ counters: { ...counters },
87
+ }
88
+ },
89
+ reset() { frames.length = 0; tasks.length = 0; marks.length = 0; for (const k in counters) counters[k] = 0 },
90
+ marks: () => [...marks],
91
+ }
92
+ }
@@ -35,9 +35,10 @@ export interface Node {
35
35
  }
36
36
  export interface Toast { id: number; text: string }
37
37
 
38
- export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean } = shConfig
38
+ export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string } = shConfig
39
39
 
40
- export const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
40
+ export { cap, humanize } from './labels.ts'
41
+ import { cap, humanize } from './labels.ts'
41
42
 
42
43
  /** Board names for switchers: all-scenes first, the rest sorted. Throws on transport
43
44
  * failure - callers keep their last known list. */
@@ -47,7 +48,7 @@ export async function fetchBoardNames(): Promise<string[]> {
47
48
  return ['all-scenes', ...list.map((b) => b.name).filter((n) => n !== 'all-scenes').sort()]
48
49
  }
49
50
  /** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
50
- export const boardLabel = (n: string) => (n === 'all-scenes' ? 'All scenes' : cap(n))
51
+ export const boardLabel = (n: string) => humanize(n)
51
52
 
52
53
  const HEADER = 28
53
54
  let toastSeq = 0
@@ -112,6 +113,7 @@ interface State {
112
113
  viewTheme: string // the global theme preference; sticky across boards and reloads
113
114
  play: { at: string; device: string; theme: string } | null // play mode (SPEC-M2 §1); at = current frame id
114
115
  gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
116
+ laser: boolean // laser/inspect mode (SPEC-M3 §7): frames outline their structure
115
117
  board: string // active board name; 'all-scenes' is the auto board
116
118
  boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
117
119
  deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
@@ -124,6 +126,14 @@ interface State {
124
126
  toasts: Toast[]
125
127
  boardHash: string | null // sha256 of the board file on disk, when materialized
126
128
  dirty: boolean
129
+ // A6/A7 controlled HMR (transient, never persisted): a frame the user is actively using
130
+ // (interact/play target, mid-gesture, laser/comment engaged) is "leased" - a hot update to
131
+ // it is DEFERRED (coalesced to the latest revision) until a safe point, so an agent edit
132
+ // never yanks the user out of their flow.
133
+ pendingFrameRevisions: Record<string, string> // frameId -> latest deferred revision
134
+ externalLeases: Record<string, { laser?: true; comment?: true }> // nodeKey -> transient engagement
135
+ playUpdateRevision: string | null // a revision arrived while play is open
136
+ playNav: number // bumps to reload the play stage on demand
127
137
 
128
138
  boot(): Promise<boolean>
129
139
  applyManifest(m: Manifest): void
@@ -139,6 +149,11 @@ interface State {
139
149
  setInteract(key: string | null): void
140
150
  setPlay(p: State['play']): void
141
151
  setGesture(g: boolean): void
152
+ setLaser(on: boolean): void
153
+ invalidateFrames(frameIds: string[], revision: string): void
154
+ setExternalLease(nodeKey: string, reason: 'laser' | 'comment', on: boolean): void
155
+ flushFrameUpdates(frameIds?: string[]): void
156
+ applyPlayUpdate(): void
142
157
  moveSelectedBy(dx: number, dy: number, starts: Record<string, { x: number; y: number }>): void
143
158
  setSelectedTheme(theme: string): void
144
159
  setDeviceView(name: string | null): void
@@ -153,6 +168,16 @@ interface State {
153
168
  save(): Promise<boolean>
154
169
  }
155
170
 
171
+ /** A6: is any node of this frame in active use, so a hot update must DEFER? Frame-scoped -
172
+ * if the same frame is placed twice and one copy is active, both stay on the same revision. */
173
+ function frameIsLeased(frameId: string, s: State): boolean {
174
+ return s.nodes.some((n) =>
175
+ n.frame === frameId && n.status !== 'error' && (
176
+ s.interact === n.key ||
177
+ (s.gesture && s.selection.includes(n.key)) ||
178
+ !!s.externalLeases[n.key]))
179
+ }
180
+
156
181
  export const useStore = create<State>((set, get) => {
157
182
  let saveTimer: ReturnType<typeof setTimeout> | undefined
158
183
  let saveChain: Promise<boolean> = Promise.resolve(true) // saves are serialized; responses only commit if the board is still active
@@ -388,9 +413,10 @@ export const useStore = create<State>((set, get) => {
388
413
  }
389
414
 
390
415
  return {
391
- manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false,
416
+ manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
392
417
  board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
393
418
  panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
419
+ pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0,
394
420
 
395
421
  async boot() {
396
422
  const seq = ++loadSeq
@@ -685,8 +711,22 @@ export const useStore = create<State>((set, get) => {
685
711
  // interact is a ONE-frame mode: entering it collapses any multi-selection to the
686
712
  // interacted frame (a 4-frame selection double-clicked otherwise leaves all four
687
713
  // painted as "interactive"). Exiting keeps the frame selected for continuity.
688
- setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) },
714
+ setInteract(key) {
715
+ set((s) => ({ interact: key, selection: key ? [key] : s.selection }))
716
+ get().flushFrameUpdates() // A6: the frame just left interact is no longer leased
717
+ },
689
718
  setPlay(play) { set({ play }) },
719
+ setLaser(laser) {
720
+ if (laser) { set({ laser }); return }
721
+ // leaving laser mode drops every laser engagement lease (the bridge stops reporting), but
722
+ // keeps comment engagement; the freed frames may now flush a deferred update
723
+ set((s) => ({
724
+ laser,
725
+ externalLeases: Object.fromEntries(
726
+ Object.entries(s.externalLeases).filter(([, v]) => v.comment).map(([k]) => [k, { comment: true as const }])),
727
+ }))
728
+ get().flushFrameUpdates()
729
+ },
690
730
  setGesture(gesture) {
691
731
  set({ gesture })
692
732
  // SPEC-024 §4: a board WITH a layout recipe re-applies it when a resize
@@ -696,6 +736,49 @@ export const useStore = create<State>((set, get) => {
696
736
  if (get().layout || get().sceneRows?.length) get().runTidy() // schedules the save
697
737
  else scheduleSave()
698
738
  }
739
+ if (!gesture) get().flushFrameUpdates() // A6: gesture end is a safe point for deferred updates
740
+ },
741
+ // A6/A7: apply a controlled hot update. A leased frame's revision is deferred (coalesced to
742
+ // the latest) and applied at the next safe point; an idle frame reloads now via its nav nonce.
743
+ invalidateFrames(frameIds, revision) {
744
+ const ids = new Set(frameIds.filter((x) => typeof x === 'string'))
745
+ if (!ids.size) return
746
+ bumpManifestRev()
747
+ const s = get()
748
+ const pending = { ...s.pendingFrameRevisions }
749
+ const nodes = s.nodes.map((n) => {
750
+ if (!ids.has(n.frame)) return n
751
+ if (frameIsLeased(n.frame, s)) { pending[n.frame] = revision; return n }
752
+ return { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 }
753
+ })
754
+ set({ nodes, pendingFrameRevisions: pending, ...(s.play ? { playUpdateRevision: revision } : {}) })
755
+ },
756
+ flushFrameUpdates(frameIds) {
757
+ const s = get()
758
+ const ids = (frameIds ?? Object.keys(s.pendingFrameRevisions)).filter((id) => s.pendingFrameRevisions[id] && !frameIsLeased(id, s))
759
+ if (!ids.length) return
760
+ bumpManifestRev()
761
+ const apply = new Set(ids)
762
+ const pending = { ...s.pendingFrameRevisions }
763
+ for (const id of ids) delete pending[id]
764
+ const nodes = s.nodes.map((n) => apply.has(n.frame)
765
+ ? { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 } : n)
766
+ set({ nodes, pendingFrameRevisions: pending })
767
+ },
768
+ setExternalLease(nodeKey, reason, on) {
769
+ set((s) => {
770
+ const cur = { ...(s.externalLeases[nodeKey] ?? {}) }
771
+ if (on) cur[reason] = true; else delete cur[reason]
772
+ const leases = { ...s.externalLeases }
773
+ if (Object.keys(cur).length) leases[nodeKey] = cur; else delete leases[nodeKey]
774
+ return { externalLeases: leases }
775
+ })
776
+ if (!on) get().flushFrameUpdates()
777
+ },
778
+ applyPlayUpdate() {
779
+ if (!get().playUpdateRevision) return
780
+ bumpManifestRev()
781
+ set((s) => ({ playUpdateRevision: null, playNav: s.playNav + 1 }))
699
782
  },
700
783
  setScale(scale) { set({ scale }) },
701
784
  togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
@@ -800,10 +883,19 @@ export const useStore = create<State>((set, get) => {
800
883
  try {
801
884
  const res = await fetch(`${ROUTE}/api/boards/${boardName}`, {
802
885
  method: 'PUT', headers: { 'content-type': 'application/json' },
803
- body: JSON.stringify({ board, baseHash: boardHash }),
886
+ // A9: a board loaded from disk (boardHash set) autosaves with mustExist so a
887
+ // rename/delete out from under us returns 409 instead of resurrecting a ghost.
888
+ body: JSON.stringify({ board, baseHash: boardHash, mustExist: !!boardHash }),
804
889
  })
805
890
  if (get().board !== boardName) return true // switched boards mid-flight; stale response must not touch state
806
891
  if (res.status === 409) {
892
+ const info = await res.json().catch(() => ({} as { gone?: boolean }))
893
+ if (info?.gone) {
894
+ // renamed/deleted externally - drop the write, don't recreate it or loop
895
+ set({ dirty: false })
896
+ get().toast('this board was renamed or removed - not recreating it')
897
+ return true
898
+ }
807
899
  const reloaded = await get().boot() // hash advances only when the authoritative reload commits
808
900
  if (reloaded) get().toast('board changed on disk - canvas layout reloaded')
809
901
  return reloaded // false keeps dirty set; the next debounce retries once edits settle
@@ -822,3 +914,6 @@ export const useStore = create<State>((set, get) => {
822
914
  },
823
915
  }
824
916
  })
917
+
918
+ // dev-only inspection hook for tests/debugging (never installed in published builds)
919
+ if (import.meta.env.DEV) (window as unknown as { __mvStore: unknown }).__mvStore = useStore