@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.
- package/CHANGELOG.md +151 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/dev-DdeU-Jst.mjs +162 -0
- package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +3 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/index.tsx +6 -2
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +269 -3
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +131 -17
- package/src/client/shell/Comments.tsx +464 -0
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +130 -10
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +101 -6
- package/src/client/shell/styles.css +196 -7
- package/src/client/stage/main.tsx +2 -0
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
- package/dist/dev-C2oTKuXe.mjs +0 -97
- 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>
|
package/src/client/shell/hash.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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
|