@marver-design/marver 0.4.0 → 0.6.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 +107 -0
- package/dist/{build-p3xmXU3b.mjs → build-BZaPa2DS.mjs} +11 -5
- package/dist/cli.mjs +3 -3
- package/dist/{dev-DZi1yRhn.mjs → dev-DaPQ9xA5.mjs} +57 -8
- package/dist/{init-Ck8z-HiD.mjs → init-DsCUmlCW.mjs} +1 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-CtQqO5ZZ.mjs → plugin-wMY9lNf3.mjs} +67 -20
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/img-lod.ts +107 -0
- package/src/client/content/index.tsx +37 -9
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +40 -5
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +75 -18
- package/src/client/shell/Comments.tsx +5 -4
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +69 -30
- package/src/client/shell/canvas/FrameNode.tsx +104 -8
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +130 -22
- package/src/client/shell/styles.css +37 -7
- package/src/client/stage/main.tsx +2 -0
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +44 -35
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B0.3: O(1) source-window -> node lookup for the frame->shell message protocol.
|
|
3
|
+
*
|
|
4
|
+
* Every postMessage from a frame previously triggered a full `document.querySelectorAll
|
|
5
|
+
* ('iframe')` scan plus a `closest('[data-node]')` DOM walk to find the sender's node.
|
|
6
|
+
* A frame's WindowProxy is a stable object for the life of its iframe element (it survives
|
|
7
|
+
* same-element navigations - the iframe law keeps one element per node key), so one
|
|
8
|
+
* registration holds for the frame's whole life. The registry doubles as the security
|
|
9
|
+
* gate: an event whose source is not registered is not a known frame and is dropped.
|
|
10
|
+
*/
|
|
11
|
+
export interface FrameReg { key: string; iframe: HTMLIFrameElement }
|
|
12
|
+
|
|
13
|
+
const byWindow = new WeakMap<WindowProxy, FrameReg>()
|
|
14
|
+
|
|
15
|
+
export const registerFrame = (win: WindowProxy, reg: FrameReg): void => { byWindow.set(win, reg) }
|
|
16
|
+
export const unregisterFrame = (win: WindowProxy): void => { byWindow.delete(win) }
|
|
17
|
+
export const frameByWindow = (win: unknown): FrameReg | undefined =>
|
|
18
|
+
win ? byWindow.get(win as WindowProxy) : undefined
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPEC-M5 slice 1: the lean-frame facade coordinator. Imperative on purpose - the facade is a
|
|
3
|
+
* `<iframe class="sh-lean" sandbox="allow-same-origin">` driven by setting .srcdoc directly, so no
|
|
4
|
+
* FrameNode subscribes to snapshot state and a pan/zoom tick triggers zero React renders.
|
|
5
|
+
*
|
|
6
|
+
* The lean tier is a DOM SNAPSHOT, not a bitmap: the shell serialises the live frame's document
|
|
7
|
+
* (same origin) into self-contained static html (post-render DOM + full inlined CSS, JS stripped).
|
|
8
|
+
* LEAN-PRIMARY: this snapshot is what you SEE for a passive frame - at rest AND during pan/zoom - so
|
|
9
|
+
* there is NO per-gesture swap between the lean and the live iframe (two documents never render
|
|
10
|
+
* pixel-identically; the swap shifted text ~1-2px = jiggle, and flashed mermaid/theme). The live app
|
|
11
|
+
* shows underneath only when the frame is interacted, in laser/comment mode, or while the lean is
|
|
12
|
+
* being (re)built. Real DOM + real CSS: exact color, native reflow on resize. Theme change / focus
|
|
13
|
+
* INVALIDATE the lean (never mutate a displayed one - baked mermaid can't re-theme in place) and a
|
|
14
|
+
* fresh capture is admitted before it is shown again. Captures run bounded-parallel, viewport-first, at idle, never while busy.
|
|
15
|
+
*
|
|
16
|
+
* Correctness beats the flash-guard (codex): a frame the serialiser cannot render faithfully
|
|
17
|
+
* (canvas/video/shadow-dom/cross-origin-css, or an unrestorable scroller) is left DEGRADED - no
|
|
18
|
+
* `data-ready`, no cover, live pixels stay. Every install is guarded by a per-node GENERATION token
|
|
19
|
+
* so an in-flight capture can never paint a stale/wrong-node cover after a reload or unmount.
|
|
20
|
+
*/
|
|
21
|
+
import { serializeDoc, type SerializeResult } from '../../frame-host/serialize.ts'
|
|
22
|
+
|
|
23
|
+
export interface SnapMeta { sourceRevision: string; theme: string }
|
|
24
|
+
|
|
25
|
+
interface Entry { key: string; html: string; scrollMap: SerializeResult['scrollMap']; degraded: string[]; theme: string; gen: number; live: HTMLIFrameElement; meta: SnapMeta }
|
|
26
|
+
const byNode = new Map<string, Entry>() // nodeKey -> current lean snapshot
|
|
27
|
+
const frames = new Map<string, HTMLIFrameElement>() // nodeKey -> the facade <iframe> element
|
|
28
|
+
const gen = new Map<string, number>() // nodeKey -> generation (bumped on drop/reload)
|
|
29
|
+
const recheck = new Map<string, number>() // nodeKey -> a one-shot re-capture timer (slow async)
|
|
30
|
+
const rechecked = new Set<string>() // nodes whose one-shot recheck already fired (no re-arm loop)
|
|
31
|
+
const MAX_LEAN_BYTES = 4 * 1024 * 1024 // over this a frame is too heavy to inline - stay live
|
|
32
|
+
|
|
33
|
+
// content identity INCLUDES theme: content whose colors are baked into the DOM at render time
|
|
34
|
+
// (mermaid SVG) cannot be re-themed by the cover's attribute mutation, so a theme change must
|
|
35
|
+
// re-capture after the live frame re-renders. Size still needs no re-capture (the lean doc reflows).
|
|
36
|
+
const keyOf = (m: SnapMeta) => `${m.sourceRevision}|${m.theme}`
|
|
37
|
+
const genOf = (k: string) => gen.get(k) ?? 0
|
|
38
|
+
const bumpGen = (k: string) => gen.set(k, genOf(k) + 1) // invalidate any in-flight capture/onload for k
|
|
39
|
+
|
|
40
|
+
const inMotion = (): boolean => {
|
|
41
|
+
const w = document.getElementById('sh-world')
|
|
42
|
+
return !!w && (w.classList.contains('sh-gesturing') || w.classList.contains('sh-preset'))
|
|
43
|
+
}
|
|
44
|
+
// never serialise while laser/comment mode is on: those inject outline styles + hover chrome into the
|
|
45
|
+
// live doc, which the shell-side clone would bake into the lean (visible after the mode ends).
|
|
46
|
+
const modeActive = (): boolean => document.body.classList.contains('sh-laser') || document.body.classList.contains('sh-commenting')
|
|
47
|
+
const busy = (): boolean => inMotion() || modeActive()
|
|
48
|
+
|
|
49
|
+
/** Apply a node's current theme to its lean doc via attribute mutation (allow-same-origin lets the
|
|
50
|
+
* shell touch the doc; the full CSS is inlined so only which rules match changes - no re-capture). */
|
|
51
|
+
function applyTheme(doc: Document, theme: string): void {
|
|
52
|
+
doc.documentElement.dataset.theme = theme
|
|
53
|
+
doc.documentElement.classList.toggle('dark', theme === 'dark')
|
|
54
|
+
// pin the cover's color-scheme to the FRAME theme, not the viewer's OS. A srcdoc doc otherwise
|
|
55
|
+
// follows the OS: on a dark-mode Mac the UA canvas + any prefers-color-scheme rules go dark and
|
|
56
|
+
// bleed into the cover (dark mermaid boxes) while data-theme says light. This holds it to light.
|
|
57
|
+
doc.documentElement.style.colorScheme = theme
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Restore captured scroll offsets shell-side (the lean doc runs no JS). Returns false if any mapped
|
|
61
|
+
* scroller cannot be resolved OR the offset did not stick (clamped by a differently-reflowed lean
|
|
62
|
+
* doc) - the frame then degrades to live rather than showing a mis-scrolled cover. */
|
|
63
|
+
function restoreScroll(doc: Document, scrollMap: Entry['scrollMap']): boolean {
|
|
64
|
+
for (const s of scrollMap) {
|
|
65
|
+
const el = s.sel === ':root' ? doc.documentElement : doc.querySelector<HTMLElement>(s.sel)
|
|
66
|
+
if (!el) return false
|
|
67
|
+
el.scrollTop = s.top; el.scrollLeft = s.left
|
|
68
|
+
if (Math.abs(el.scrollTop - s.top) > 2 || Math.abs(el.scrollLeft - s.left) > 2) return false
|
|
69
|
+
}
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Install the stored snapshot into a node's lean iframe: parse srcdoc, then on load restore scroll,
|
|
74
|
+
* apply theme, and (only if nothing degraded AND fonts+paint have settled) mark ready. Every step is
|
|
75
|
+
* generation-guarded so a superseded capture or an about:blank reset never re-admits a cover. */
|
|
76
|
+
function install(nodeKey: string): void {
|
|
77
|
+
const iframe = frames.get(nodeKey)
|
|
78
|
+
if (!iframe) return
|
|
79
|
+
iframe.onload = null // detach any prior handler (about:blank reset can't re-admit)
|
|
80
|
+
delete iframe.dataset.ready
|
|
81
|
+
const e = byNode.get(nodeKey)
|
|
82
|
+
if (!e || e.degraded.length) { iframe.removeAttribute('srcdoc'); return } // degraded = no cover, keep live
|
|
83
|
+
const myGen = e.gen
|
|
84
|
+
iframe.onload = () => {
|
|
85
|
+
if (genOf(nodeKey) !== myGen || frames.get(nodeKey) !== iframe) return // superseded / remounted
|
|
86
|
+
const cur = byNode.get(nodeKey)
|
|
87
|
+
const doc = iframe.contentDocument
|
|
88
|
+
if (!cur || !doc) return
|
|
89
|
+
if (!restoreScroll(doc, cur.scrollMap)) { cur.degraded = [...cur.degraded, 'scroll']; iframe.removeAttribute('srcdoc'); return }
|
|
90
|
+
// CSP guard (codex): if a hardened host blocked the inline <style> (style-src 'self'), the lean is
|
|
91
|
+
// unstyled - the sentinel custom prop won't resolve. Stay live rather than show an unstyled cover.
|
|
92
|
+
if (getComputedStyle(doc.documentElement).getPropertyValue('--mv-lean-ok').trim() !== '1') { iframe.removeAttribute('srcdoc'); return }
|
|
93
|
+
applyTheme(doc, cur.theme)
|
|
94
|
+
// font+paint readiness gate (F3): the srcdoc doc reloads fonts independently, so mark ready only
|
|
95
|
+
// after its fonts settle + two paints, else a fallback-font seam shows on the swap.
|
|
96
|
+
const markReady = () => {
|
|
97
|
+
if (genOf(nodeKey) !== myGen || frames.get(nodeKey) !== iframe) return
|
|
98
|
+
iframe.dataset.ready = '1'
|
|
99
|
+
// slow-async guard: a data fetch / route change that lands AFTER the capture window would leave
|
|
100
|
+
// the lean frozen on a loading state. ONE bounded re-capture ~3s after admit catches it; later
|
|
101
|
+
// changes self-heal on focus. `rechecked` makes it truly one-shot - the recheck's own recapture
|
|
102
|
+
// must not re-arm the timer (that was an endless ~3s loop), so a long-poll can't thrash.
|
|
103
|
+
clearTimeout(recheck.get(nodeKey))
|
|
104
|
+
if (cur.live && !rechecked.has(nodeKey)) recheck.set(nodeKey, window.setTimeout(() => {
|
|
105
|
+
recheck.delete(nodeKey); rechecked.add(nodeKey)
|
|
106
|
+
if (genOf(nodeKey) === myGen) scheduleCapture(nodeKey, cur.live, cur.meta, true)
|
|
107
|
+
}, 3000))
|
|
108
|
+
}
|
|
109
|
+
void (doc.fonts?.ready ?? Promise.resolve()).then(() => requestAnimationFrame(() => requestAnimationFrame(markReady)))
|
|
110
|
+
}
|
|
111
|
+
iframe.srcdoc = e.html
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** FrameNode registers its facade <iframe> on mount so the coordinator can drive it imperatively. */
|
|
115
|
+
export function registerLeanFrame(nodeKey: string, iframe: HTMLIFrameElement | null): void {
|
|
116
|
+
if (!iframe) { frames.delete(nodeKey); return }
|
|
117
|
+
frames.set(nodeKey, iframe)
|
|
118
|
+
if (byNode.has(nodeKey)) install(nodeKey)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Hide the lean at once (show the live app underneath) and cancel in-flight work. Used when a frame
|
|
122
|
+
* is focused/interacted, or its theme changes: a baked mermaid SVG cannot be re-themed in place, so
|
|
123
|
+
* we never mutate a DISPLAYED snapshot across themes - we drop it and rebuild a fresh one, which is
|
|
124
|
+
* only shown once it passes admission. Live stays visible in the meantime (live-fallback). */
|
|
125
|
+
export function invalidateLean(nodeKey: string): void {
|
|
126
|
+
bumpGen(nodeKey)
|
|
127
|
+
pending.delete(nodeKey)
|
|
128
|
+
clearTimeout(recheck.get(nodeKey)); recheck.delete(nodeKey); rechecked.delete(nodeKey)
|
|
129
|
+
const iframe = frames.get(nodeKey)
|
|
130
|
+
if (iframe) delete iframe.dataset.ready
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Drop a node's snapshot (unmount, or content changed / frame reloaded). Cancels queued + in-flight
|
|
134
|
+
* work via the generation bump and clears the cover. */
|
|
135
|
+
export function dropSnapshot(nodeKey: string): void {
|
|
136
|
+
bumpGen(nodeKey)
|
|
137
|
+
byNode.delete(nodeKey)
|
|
138
|
+
pending.delete(nodeKey)
|
|
139
|
+
clearTimeout(recheck.get(nodeKey)); recheck.delete(nodeKey); rechecked.delete(nodeKey)
|
|
140
|
+
const iframe = frames.get(nodeKey)
|
|
141
|
+
if (iframe) { iframe.onload = null; delete iframe.dataset.ready; iframe.removeAttribute('srcdoc') }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- capture coordinator: bounded-parallel, viewport-first, idle-scheduled, never while busy --------
|
|
145
|
+
const pending = new Map<string, { live: HTMLIFrameElement; meta: SnapMeta }>() // nodeKey -> latest request
|
|
146
|
+
const inflightNodes = new Set<string>() // nodes currently being captured - never capture one twice at once
|
|
147
|
+
const MAX_CONCURRENT = 3 // overlap the per-frame settle waits (mostly timers) so a big board's leans
|
|
148
|
+
let inflight = 0 // land in ~1/3 the wall-clock of strictly-serial, without janking the loop
|
|
149
|
+
const idle = (fn: () => void) =>
|
|
150
|
+
(window as unknown as { requestIdleCallback?: (f: () => void, o?: object) => void }).requestIdleCallback?.(fn, { timeout: 600 }) ?? setTimeout(fn, 80)
|
|
151
|
+
const rafSettle = () => new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
|
152
|
+
const withDeadline = <T,>(p: Promise<T>, ms: number) => Promise.race([p, new Promise<void>((r) => setTimeout(r, ms))])
|
|
153
|
+
|
|
154
|
+
/** Wait until the frame's DOM stops mutating for `quietMs` (bounded by `maxMs`). Async content -
|
|
155
|
+
* lazily-imported mermaid renders its SVG well after the frame reports 'ready', late images/webfont
|
|
156
|
+
* swaps, entrance animations - all land here. Capturing before this quiet window yields a cover
|
|
157
|
+
* missing the diagram (the mermaid pop-in/out bug). Same-origin, so the shell can observe the doc. */
|
|
158
|
+
function domQuiet(doc: Document, quietMs: number, maxMs: number): Promise<void> {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
let timer = 0, done = false
|
|
161
|
+
const finish = () => { if (done) return; done = true; clearTimeout(timer); clearTimeout(hard); mo.disconnect(); resolve() }
|
|
162
|
+
const mo = new MutationObserver(() => { clearTimeout(timer); timer = window.setTimeout(finish, quietMs) })
|
|
163
|
+
try { mo.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }) }
|
|
164
|
+
catch { return resolve() }
|
|
165
|
+
timer = window.setTimeout(finish, quietMs)
|
|
166
|
+
const hard = window.setTimeout(finish, maxMs)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Request a fresh lean snapshot for a ready/quiet frame. Coalesces to the latest per node. `force`
|
|
171
|
+
* recaptures even when the key is unchanged - used on blur, where the live state changed under the
|
|
172
|
+
* same revision/theme (typed input, toggled UI) and the old lean is now wrong. */
|
|
173
|
+
export function scheduleCapture(nodeKey: string, live: HTMLIFrameElement, meta: SnapMeta, force = false): void {
|
|
174
|
+
if (!force && byNode.get(nodeKey)?.key === keyOf(meta)) return // already have this content revision
|
|
175
|
+
pending.set(nodeKey, { live, meta })
|
|
176
|
+
pump()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The next node to capture: the pending frame nearest the viewport centre, on-screen before off. So
|
|
180
|
+
* the frames the user is actually looking at get their lean first (perceived-instant), and the rest
|
|
181
|
+
* fill in behind - instead of registration order, which pops in arbitrary corners of a big board. */
|
|
182
|
+
function pickNext(): string | null {
|
|
183
|
+
let best: string | null = null, bestScore = Infinity
|
|
184
|
+
const vw = window.innerWidth, vh = window.innerHeight
|
|
185
|
+
for (const [k, req] of pending) {
|
|
186
|
+
if (inflightNodes.has(k)) continue // that node is mid-capture; its re-request waits
|
|
187
|
+
const r = req.live.getBoundingClientRect()
|
|
188
|
+
const off = r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw
|
|
189
|
+
const dx = r.left + r.width / 2 - vw / 2, dy = r.top + r.height / 2 - vh / 2
|
|
190
|
+
const score = (off ? 1e7 : 0) + Math.hypot(dx, dy) // on-screen first, then by distance to centre
|
|
191
|
+
if (score < bestScore) { bestScore = score; best = k }
|
|
192
|
+
}
|
|
193
|
+
return best
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pump(): void {
|
|
197
|
+
if (busy()) { if (pending.size) idle(pump); return } // never serialise mid-gesture or during laser/comment
|
|
198
|
+
while (inflight < MAX_CONCURRENT) {
|
|
199
|
+
const nodeKey = pickNext()
|
|
200
|
+
if (!nodeKey) break
|
|
201
|
+
const req = pending.get(nodeKey)!
|
|
202
|
+
pending.delete(nodeKey)
|
|
203
|
+
inflightNodes.add(nodeKey); inflight++
|
|
204
|
+
const done = () => { inflightNodes.delete(nodeKey); inflight--; idle(pump) }
|
|
205
|
+
void capture(nodeKey, req.live, req.meta).then(done, done)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function capture(nodeKey: string, live: HTMLIFrameElement, meta: SnapMeta): Promise<void> {
|
|
210
|
+
const myGen = genOf(nodeKey)
|
|
211
|
+
const doc = live.contentDocument
|
|
212
|
+
if (!doc) return
|
|
213
|
+
// settle: fonts, two stable paints, THEN a DOM-quiet window so async content (mermaid renders its
|
|
214
|
+
// SVG after 'ready', late images) is captured - not a diagram-less frame. All bounded.
|
|
215
|
+
await withDeadline(doc.fonts?.ready ?? Promise.resolve(), 400).catch(() => {})
|
|
216
|
+
await rafSettle()
|
|
217
|
+
await domQuiet(doc, 180, 2500) // still-loading frames wait longer so async data lands in-capture
|
|
218
|
+
// bail if the world changed under us during settle: superseded (drop/reload), a newer request
|
|
219
|
+
// landed, the frame renavigated, or a gesture/preset started (serialising+parsing now would jank).
|
|
220
|
+
if (genOf(nodeKey) !== myGen || pending.has(nodeKey) || live.contentDocument !== doc) return
|
|
221
|
+
if (busy()) { pending.set(nodeKey, { live, meta }); return } // requeue for the next idle tick
|
|
222
|
+
let result: SerializeResult
|
|
223
|
+
try { result = serializeDoc(doc, doc.URL) } // <base> = the frame's own URL, so relative url()/img/font resolve
|
|
224
|
+
catch { return } // fail soft: keep live pixels
|
|
225
|
+
// budget: a pathologically heavy frame (huge inlined CSS/DOM) would multiply memory across the board
|
|
226
|
+
// and jank the main thread parsing it - over the cap, degrade (stay live) AND drop the html so the
|
|
227
|
+
// giant string is not retained in byNode (keeping it would defeat the memory bound).
|
|
228
|
+
const oversized = result.html.length > MAX_LEAN_BYTES
|
|
229
|
+
const degraded = oversized ? [...result.degraded, 'oversized'] : result.degraded
|
|
230
|
+
const theme = doc.documentElement.dataset.theme || meta.theme // theme AT capture, not a stale closure
|
|
231
|
+
byNode.set(nodeKey, { key: keyOf(meta), html: oversized ? '' : result.html, scrollMap: oversized ? [] : result.scrollMap, degraded, theme, gen: myGen, live, meta })
|
|
232
|
+
install(nodeKey)
|
|
233
|
+
}
|
|
@@ -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>
|
|
@@ -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,19 +35,25 @@ 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
|
-
/** Board names for switchers:
|
|
43
|
-
*
|
|
43
|
+
/** Board names for switchers: the agent's curated boards FIRST (ranked by each board's `order`, then
|
|
44
|
+
* name), and the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
45
|
+
* Throws on transport failure - callers keep their last known list. */
|
|
44
46
|
export async function fetchBoardNames(): Promise<string[]> {
|
|
45
47
|
if (DATA) return DATA.names
|
|
46
|
-
const list: { name: string }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
|
|
47
|
-
|
|
48
|
+
const list: { name: string; order?: number }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
|
|
49
|
+
const curated = list
|
|
50
|
+
.filter((b) => b.name !== 'all-scenes')
|
|
51
|
+
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name))
|
|
52
|
+
.map((b) => b.name)
|
|
53
|
+
return [...curated, 'all-scenes']
|
|
48
54
|
}
|
|
49
55
|
/** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
|
|
50
|
-
export const boardLabel = (n: string) => (n
|
|
56
|
+
export const boardLabel = (n: string) => humanize(n)
|
|
51
57
|
|
|
52
58
|
const HEADER = 28
|
|
53
59
|
let toastSeq = 0
|
|
@@ -125,6 +131,14 @@ interface State {
|
|
|
125
131
|
toasts: Toast[]
|
|
126
132
|
boardHash: string | null // sha256 of the board file on disk, when materialized
|
|
127
133
|
dirty: boolean
|
|
134
|
+
// A6/A7 controlled HMR (transient, never persisted): a frame the user is actively using
|
|
135
|
+
// (interact/play target, mid-gesture, laser/comment engaged) is "leased" - a hot update to
|
|
136
|
+
// it is DEFERRED (coalesced to the latest revision) until a safe point, so an agent edit
|
|
137
|
+
// never yanks the user out of their flow.
|
|
138
|
+
pendingFrameRevisions: Record<string, string> // frameId -> latest deferred revision
|
|
139
|
+
externalLeases: Record<string, { laser?: true; comment?: true }> // nodeKey -> transient engagement
|
|
140
|
+
playUpdateRevision: string | null // a revision arrived while play is open
|
|
141
|
+
playNav: number // bumps to reload the play stage on demand
|
|
128
142
|
|
|
129
143
|
boot(): Promise<boolean>
|
|
130
144
|
applyManifest(m: Manifest): void
|
|
@@ -141,6 +155,10 @@ interface State {
|
|
|
141
155
|
setPlay(p: State['play']): void
|
|
142
156
|
setGesture(g: boolean): void
|
|
143
157
|
setLaser(on: boolean): void
|
|
158
|
+
invalidateFrames(frameIds: string[], revision: string): void
|
|
159
|
+
setExternalLease(nodeKey: string, reason: 'laser' | 'comment', on: boolean): void
|
|
160
|
+
flushFrameUpdates(frameIds?: string[]): void
|
|
161
|
+
applyPlayUpdate(): void
|
|
144
162
|
moveSelectedBy(dx: number, dy: number, starts: Record<string, { x: number; y: number }>): void
|
|
145
163
|
setSelectedTheme(theme: string): void
|
|
146
164
|
setDeviceView(name: string | null): void
|
|
@@ -155,6 +173,16 @@ interface State {
|
|
|
155
173
|
save(): Promise<boolean>
|
|
156
174
|
}
|
|
157
175
|
|
|
176
|
+
/** A6: is any node of this frame in active use, so a hot update must DEFER? Frame-scoped -
|
|
177
|
+
* if the same frame is placed twice and one copy is active, both stay on the same revision. */
|
|
178
|
+
function frameIsLeased(frameId: string, s: State): boolean {
|
|
179
|
+
return s.nodes.some((n) =>
|
|
180
|
+
n.frame === frameId && n.status !== 'error' && (
|
|
181
|
+
s.interact === n.key ||
|
|
182
|
+
(s.gesture && s.selection.includes(n.key)) ||
|
|
183
|
+
!!s.externalLeases[n.key]))
|
|
184
|
+
}
|
|
185
|
+
|
|
158
186
|
export const useStore = create<State>((set, get) => {
|
|
159
187
|
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
|
160
188
|
let saveChain: Promise<boolean> = Promise.resolve(true) // saves are serialized; responses only commit if the board is still active
|
|
@@ -393,6 +421,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
393
421
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
394
422
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
395
423
|
panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
|
|
424
|
+
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0,
|
|
396
425
|
|
|
397
426
|
async boot() {
|
|
398
427
|
const seq = ++loadSeq
|
|
@@ -576,27 +605,38 @@ export const useStore = create<State>((set, get) => {
|
|
|
576
605
|
measureNode(key, frameId, ownWidth, measuredWidth, height) {
|
|
577
606
|
const s = get()
|
|
578
607
|
const node = s.nodes.find((n) => n.key === key)
|
|
579
|
-
|
|
608
|
+
// Only an explicit DEVICE viewport locks a content frame's height. 'auto' and 'manual' both
|
|
609
|
+
// auto-fit height: a content frame must always grow/shrink to show ALL its content, even after
|
|
610
|
+
// the human drags its WIDTH (SPEC-026 r4). 'manual' just means the human owns the WIDTH; the
|
|
611
|
+
// HEIGHT is still measured, so resizing width reflows and refits height (no clipped/empty frame).
|
|
612
|
+
if (!node || node.sizeMode === 'device') return
|
|
580
613
|
if (node.frame !== frameId) return // generation guard: a reused node key
|
|
581
614
|
// across a board switch never mis-attributes
|
|
582
615
|
const f = s.manifest?.frames.find((x) => x.id === node.frame)
|
|
583
616
|
if (!f?.contentWidth) return // not a content frame - spoof-proofing
|
|
584
617
|
if (![ownWidth, measuredWidth, height].every((v) => Number.isFinite(v) && v > 0)) return
|
|
585
|
-
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
const
|
|
618
|
+
// Generous cap: a reference doc with many screenshots is legitimately very tall and must fit in
|
|
619
|
+
// FULL (this was 2.5x a viewport ~= 2700px, which clipped image-heavy docs). Still bounded so a
|
|
620
|
+
// broken measurement can't mint an infinite frame.
|
|
621
|
+
const maxH = 40000
|
|
589
622
|
const H = Math.min(maxH, Math.max(80, Math.round(height)))
|
|
590
623
|
const curW = Math.round(node.w)
|
|
591
624
|
measuredHeights.set(`${node.frame}@${Math.round(measuredWidth)}`, H)
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
625
|
+
// AUTO owns the width too - adopt the Doc's declared/own width. MANUAL keeps the human's width
|
|
626
|
+
// and only fits the height.
|
|
627
|
+
if (node.sizeMode !== 'manual') {
|
|
628
|
+
// declared meta.viewport WINS over the Doc layout width - the existing precedence
|
|
629
|
+
const vpw = CONFIG.viewports[f.viewport ?? '']?.width
|
|
630
|
+
const W = vpw ?? Math.min(1600, Math.max(320, Math.round(ownWidth)))
|
|
631
|
+
if (W !== curW) {
|
|
632
|
+
// Doc layout changed (document<->wide): adopt the new own width first; the
|
|
633
|
+
// iframe resizes, remeasures, and the height commits on the next message
|
|
634
|
+
set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, w: W } : n)) }))
|
|
635
|
+
scheduleReflow()
|
|
636
|
+
return
|
|
637
|
+
}
|
|
599
638
|
}
|
|
639
|
+
if (Math.round(measuredWidth) !== curW) return // height only true at the width it was measured at
|
|
600
640
|
if (Math.round(node.h) === H) return
|
|
601
641
|
set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, h: H } : n)) }))
|
|
602
642
|
scheduleReflow()
|
|
@@ -687,9 +727,22 @@ export const useStore = create<State>((set, get) => {
|
|
|
687
727
|
// interact is a ONE-frame mode: entering it collapses any multi-selection to the
|
|
688
728
|
// interacted frame (a 4-frame selection double-clicked otherwise leaves all four
|
|
689
729
|
// painted as "interactive"). Exiting keeps the frame selected for continuity.
|
|
690
|
-
setInteract(key) {
|
|
730
|
+
setInteract(key) {
|
|
731
|
+
set((s) => ({ interact: key, selection: key ? [key] : s.selection }))
|
|
732
|
+
get().flushFrameUpdates() // A6: the frame just left interact is no longer leased
|
|
733
|
+
},
|
|
691
734
|
setPlay(play) { set({ play }) },
|
|
692
|
-
setLaser(laser) {
|
|
735
|
+
setLaser(laser) {
|
|
736
|
+
if (laser) { set({ laser }); return }
|
|
737
|
+
// leaving laser mode drops every laser engagement lease (the bridge stops reporting), but
|
|
738
|
+
// keeps comment engagement; the freed frames may now flush a deferred update
|
|
739
|
+
set((s) => ({
|
|
740
|
+
laser,
|
|
741
|
+
externalLeases: Object.fromEntries(
|
|
742
|
+
Object.entries(s.externalLeases).filter(([, v]) => v.comment).map(([k]) => [k, { comment: true as const }])),
|
|
743
|
+
}))
|
|
744
|
+
get().flushFrameUpdates()
|
|
745
|
+
},
|
|
693
746
|
setGesture(gesture) {
|
|
694
747
|
set({ gesture })
|
|
695
748
|
// SPEC-024 §4: a board WITH a layout recipe re-applies it when a resize
|
|
@@ -699,6 +752,49 @@ export const useStore = create<State>((set, get) => {
|
|
|
699
752
|
if (get().layout || get().sceneRows?.length) get().runTidy() // schedules the save
|
|
700
753
|
else scheduleSave()
|
|
701
754
|
}
|
|
755
|
+
if (!gesture) get().flushFrameUpdates() // A6: gesture end is a safe point for deferred updates
|
|
756
|
+
},
|
|
757
|
+
// A6/A7: apply a controlled hot update. A leased frame's revision is deferred (coalesced to
|
|
758
|
+
// the latest) and applied at the next safe point; an idle frame reloads now via its nav nonce.
|
|
759
|
+
invalidateFrames(frameIds, revision) {
|
|
760
|
+
const ids = new Set(frameIds.filter((x) => typeof x === 'string'))
|
|
761
|
+
if (!ids.size) return
|
|
762
|
+
bumpManifestRev()
|
|
763
|
+
const s = get()
|
|
764
|
+
const pending = { ...s.pendingFrameRevisions }
|
|
765
|
+
const nodes = s.nodes.map((n) => {
|
|
766
|
+
if (!ids.has(n.frame)) return n
|
|
767
|
+
if (frameIsLeased(n.frame, s)) { pending[n.frame] = revision; return n }
|
|
768
|
+
return { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 }
|
|
769
|
+
})
|
|
770
|
+
set({ nodes, pendingFrameRevisions: pending, ...(s.play ? { playUpdateRevision: revision } : {}) })
|
|
771
|
+
},
|
|
772
|
+
flushFrameUpdates(frameIds) {
|
|
773
|
+
const s = get()
|
|
774
|
+
const ids = (frameIds ?? Object.keys(s.pendingFrameRevisions)).filter((id) => s.pendingFrameRevisions[id] && !frameIsLeased(id, s))
|
|
775
|
+
if (!ids.length) return
|
|
776
|
+
bumpManifestRev()
|
|
777
|
+
const apply = new Set(ids)
|
|
778
|
+
const pending = { ...s.pendingFrameRevisions }
|
|
779
|
+
for (const id of ids) delete pending[id]
|
|
780
|
+
const nodes = s.nodes.map((n) => apply.has(n.frame)
|
|
781
|
+
? { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 } : n)
|
|
782
|
+
set({ nodes, pendingFrameRevisions: pending })
|
|
783
|
+
},
|
|
784
|
+
setExternalLease(nodeKey, reason, on) {
|
|
785
|
+
set((s) => {
|
|
786
|
+
const cur = { ...(s.externalLeases[nodeKey] ?? {}) }
|
|
787
|
+
if (on) cur[reason] = true; else delete cur[reason]
|
|
788
|
+
const leases = { ...s.externalLeases }
|
|
789
|
+
if (Object.keys(cur).length) leases[nodeKey] = cur; else delete leases[nodeKey]
|
|
790
|
+
return { externalLeases: leases }
|
|
791
|
+
})
|
|
792
|
+
if (!on) get().flushFrameUpdates()
|
|
793
|
+
},
|
|
794
|
+
applyPlayUpdate() {
|
|
795
|
+
if (!get().playUpdateRevision) return
|
|
796
|
+
bumpManifestRev()
|
|
797
|
+
set((s) => ({ playUpdateRevision: null, playNav: s.playNav + 1 }))
|
|
702
798
|
},
|
|
703
799
|
setScale(scale) { set({ scale }) },
|
|
704
800
|
togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
|
|
@@ -803,10 +899,19 @@ export const useStore = create<State>((set, get) => {
|
|
|
803
899
|
try {
|
|
804
900
|
const res = await fetch(`${ROUTE}/api/boards/${boardName}`, {
|
|
805
901
|
method: 'PUT', headers: { 'content-type': 'application/json' },
|
|
806
|
-
|
|
902
|
+
// A9: a board loaded from disk (boardHash set) autosaves with mustExist so a
|
|
903
|
+
// rename/delete out from under us returns 409 instead of resurrecting a ghost.
|
|
904
|
+
body: JSON.stringify({ board, baseHash: boardHash, mustExist: !!boardHash }),
|
|
807
905
|
})
|
|
808
906
|
if (get().board !== boardName) return true // switched boards mid-flight; stale response must not touch state
|
|
809
907
|
if (res.status === 409) {
|
|
908
|
+
const info = await res.json().catch(() => ({} as { gone?: boolean }))
|
|
909
|
+
if (info?.gone) {
|
|
910
|
+
// renamed/deleted externally - drop the write, don't recreate it or loop
|
|
911
|
+
set({ dirty: false })
|
|
912
|
+
get().toast('this board was renamed or removed - not recreating it')
|
|
913
|
+
return true
|
|
914
|
+
}
|
|
810
915
|
const reloaded = await get().boot() // hash advances only when the authoritative reload commits
|
|
811
916
|
if (reloaded) get().toast('board changed on disk - canvas layout reloaded')
|
|
812
917
|
return reloaded // false keeps dirty set; the next debounce retries once edits settle
|
|
@@ -825,3 +930,6 @@ export const useStore = create<State>((set, get) => {
|
|
|
825
930
|
},
|
|
826
931
|
}
|
|
827
932
|
})
|
|
933
|
+
|
|
934
|
+
// dev-only inspection hook for tests/debugging (never installed in published builds)
|
|
935
|
+
if (import.meta.env.DEV) (window as unknown as { __mvStore: unknown }).__mvStore = useStore
|