@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.
- package/CHANGELOG.md +97 -5
- package/README.md +72 -16
- package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
- package/dist/{build-B8z902TW.mjs → build-BBVQRetk.mjs} +5 -5
- package/dist/cli.mjs +16 -7
- package/dist/{collab-pLGzSvm5.mjs → collab-s3k5byM1.mjs} +3 -3
- package/dist/{comments-DSwMhdbZ.mjs → comments-BZBKhKRO.mjs} +2 -2
- package/dist/{comments-BrpC86Kf.mjs → comments-J06jqCVV.mjs} +3 -3
- package/dist/{daemon-C-huGHAM.mjs → daemon-DkyNOwIt.mjs} +30 -51
- package/dist/{dev-BYm9ebfN.mjs → dev-BTAhTie-.mjs} +19 -5
- package/dist/{init-DrLomSWq.mjs → init-DWdhjJD5.mjs} +16 -3
- package/dist/{ledger-wFvEIEGi.mjs → ledger-CbzTJrV2.mjs} +1 -1
- package/dist/{manifest-D3eaARf4.mjs → manifest-B4zcDGBf.mjs} +3 -3
- package/dist/{plugin-BVFuRfEo.mjs → plugin-BdQEeTLg.mjs} +41 -9
- package/dist/{serve-CwAfayJk.mjs → serve-CZqPnj19.mjs} +32 -8
- package/dist/{sync-Ch4Bymb1.mjs → sync-BJKKmy1n.mjs} +2 -2
- package/dist/work-CLrmY-vQ.mjs +97 -0
- package/dist/work-lzC-lPY0.mjs +76 -0
- package/package.json +16 -1
- package/src/client/const.ts +1 -1
- package/src/client/content/diagram.tsx +1 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +1 -1
- package/src/client/content/palette.ts +2 -2
- package/src/client/frame-host/bridge.js +1 -1
- package/src/client/frame-host/inspect.js +1 -1
- package/src/client/frame-host/serialize.ts +2 -2
- package/src/client/shell/App.tsx +79 -22
- package/src/client/shell/Comments.tsx +24 -18
- package/src/client/shell/Play.tsx +2 -2
- package/src/client/shell/canvas/Canvas.tsx +1 -1
- package/src/client/shell/canvas/FrameNode.tsx +16 -11
- package/src/client/shell/canvas/snapshots.ts +3 -3
- package/src/client/shell/comments-store.ts +115 -42
- package/src/client/shell/hash.ts +2 -2
- package/src/client/shell/icons.tsx +1 -1
- package/src/client/shell/keys.ts +39 -0
- package/src/client/shell/mentions.ts +1 -1
- package/src/client/shell/perf.ts +1 -1
- package/src/client/shell/store.ts +99 -39
- package/src/client/shell/styles.css +12 -12
- package/src/client/shell/tidy.ts +5 -5
- package/src/client/stage/main.tsx +2 -2
- package/src/shared/events.ts +2 -2
- package/src/shared/utm.ts +22 -0
- package/templates/AGENTS-embedded.md +48 -1
- package/templates/AGENTS-studio.md +48 -1
- package/templates/instructions/publish.md +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Comment state for the shell
|
|
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[] //
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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(
|
|
86
|
-
|
|
87
|
-
|
|
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 ||
|
|
97
|
-
void api(`comments/${b}`).then((r) => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
138
|
-
if (!
|
|
139
|
-
|
|
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
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
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
|
-
|
|
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) {
|
package/src/client/shell/hash.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deep links
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/client/shell/perf.ts
CHANGED
|
@@ -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
|
|
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
|
*
|