@marver-design/marver 0.4.0 → 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.
@@ -1,8 +1,10 @@
1
- import { memo, useEffect, useRef } from 'react'
1
+ import { memo, useCallback, useEffect, useRef } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
3
  import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
4
4
  import { CommentLayer } from '../Comments.tsx'
5
5
  import { useComments } from '../comments-store.ts'
6
+ import { registerFrame, unregisterFrame } from './frame-registry.ts'
7
+ import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
6
8
 
7
9
  export const HEADER = 28
8
10
  const SNAP = 12
@@ -20,7 +22,9 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
20
22
  const frame = useStore((s) => s.frameFor(node))
21
23
  const selected = useStore((s) => s.selection.includes(node.key))
22
24
  const interact = useStore((s) => s.interact === node.key)
23
- const scale = useStore((s) => s.scale)
25
+ // B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
26
+ // pan/zoom tick. gestureScale below measures the world rect (the canonical source,
27
+ // Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
24
28
  const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, setGesture, toast } = useStore.getState()
25
29
  const iframeRef = useRef<HTMLIFrameElement>(null)
26
30
  const themeRef = useRef(node.theme)
@@ -30,13 +34,73 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
30
34
  if (frame && initialSrc.current === null) initialSrc.current = frameUrl(frame, node.theme)
31
35
  const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
32
36
 
33
- // theme switch without remount
37
+ // theme switch without remount: the live iframe flips via message (no navigation). The lean is
38
+ // INVALIDATED (not mutated) - a baked mermaid SVG can't be re-themed in place, so we drop it, show
39
+ // live while it re-renders in the new theme, and the capture effect (theme is a dep) rebuilds a
40
+ // fresh lean that is only shown once ready. No light-on-dark flash.
34
41
  useEffect(() => {
35
42
  if (themeRef.current !== node.theme) {
36
43
  themeRef.current = node.theme
37
44
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
45
+ invalidateLean(node.key)
38
46
  }
39
- }, [node.theme])
47
+ }, [node.theme, node.key])
48
+
49
+ // B0.3: register this frame's WindowProxy so the shell routes its messages in O(1)
50
+ // (source window -> node), instead of rescanning every iframe + walking the DOM per
51
+ // message. Registration runs SYNCHRONOUSLY through the ref callback (P1): a fast static
52
+ // HTML frame or an immediate boot failure can post sh:ready/sh:error before a passive
53
+ // effect would run, and the registry is the security gate that would otherwise drop it
54
+ // as an unknown source (misleading 10s timeout). The WindowProxy is stable across
55
+ // navigations; onLoad re-asserts it (idempotent) after each navigation.
56
+ const regWin = useRef<WindowProxy | null>(null)
57
+ const registerWin = () => {
58
+ const iframe = iframeRef.current
59
+ const win = iframe?.contentWindow
60
+ if (!iframe || !win || regWin.current === win) return
61
+ regWin.current = win
62
+ registerFrame(win, { key: node.key, iframe })
63
+ }
64
+ const bindIframe = useCallback((el: HTMLIFrameElement | null) => {
65
+ if (regWin.current && (!el || el.contentWindow !== regWin.current)) { unregisterFrame(regWin.current); regWin.current = null }
66
+ iframeRef.current = el
67
+ registerWin()
68
+ }, [node.key])
69
+
70
+ // SPEC-M5: register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
71
+ const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
72
+ // capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
73
+ // (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
74
+ // so neither is a dep - keeping captures rare. Never during a gesture; the coordinator serialises.
75
+ useEffect(() => {
76
+ // capture reads the live iframe's same-origin document - true in dev AND publish (published frames
77
+ // are bundled same-origin and served by `marver serve`), so the lean tier works in both via this
78
+ // client-side capture. Fail-soft: a frame that can't serialise stays live (publish == today's
79
+ // behaviour in the worst case). No headless build step / heavy dependency needed.
80
+ if (node.status !== 'ready' || node.missing) return
81
+ const iframe = iframeRef.current
82
+ if (!iframe) return
83
+ const t = setTimeout(() => scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme }), 450)
84
+ return () => clearTimeout(t)
85
+ // node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
86
+ // flip, so a theme change re-captures after the live frame re-renders (key includes theme).
87
+ }, [node.status, node.nav, node.key, node.missing, node.theme])
88
+ useEffect(() => () => dropSnapshot(node.key), [node.key]) // drop the snapshot on unmount
89
+ // a reload / file-swap / error takes the frame out of 'ready': drop its cover so a stale picture
90
+ // never lingers (nav may not bump on a same-file reload). The next 'ready' re-captures.
91
+ useEffect(() => { if (node.status !== 'ready') dropSnapshot(node.key) }, [node.status, node.key])
92
+ // LEAN-PRIMARY focus handoff: entering interact shows the live app (drop the now-stale lean at
93
+ // once); leaving it recaptures the live frame's CURRENT state (the user may have typed/toggled)
94
+ // and only swaps back to lean once that fresh capture is admitted. force=true: same nav/theme.
95
+ const prevInteract = useRef(interact)
96
+ useEffect(() => {
97
+ if (prevInteract.current === interact) return
98
+ const wasInteract = prevInteract.current
99
+ prevInteract.current = interact
100
+ if (interact) invalidateLean(node.key)
101
+ else if (wasInteract && node.status === 'ready' && iframeRef.current)
102
+ scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
103
+ }, [interact, node.key, node.nav, node.theme, node.status])
40
104
 
41
105
  // laser mode (SPEC-M3 §7) rides the same rail; re-sent when a frame becomes ready
42
106
  // so late loaders join an already-lasered board
@@ -56,6 +120,12 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
56
120
  if (node.status === 'ready' || !commentMode)
57
121
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
58
122
  }, [commentMode, node.status])
123
+ // B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
124
+ // wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
125
+ useEffect(() => {
126
+ if (node.status === 'ready' || !interact)
127
+ iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
128
+ }, [interact, node.status])
59
129
 
60
130
  // a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
61
131
  useEffect(() => {
@@ -94,7 +164,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
94
164
  const world = document.getElementById('sh-world')!
95
165
  // Law G-5: measured scale, never stored zoom state. #sh-world is 1px wide by design,
96
166
  // so its rendered rect width IS the scale (survives browser page-zoom too).
97
- const gestureScale = world.getBoundingClientRect().width || scale || 1
167
+ const gestureScale = world.getBoundingClientRect().width || useStore.getState().scale || 1
98
168
  const start = { x: e.clientX, y: e.clientY, nx: node.x, ny: node.y, nw: node.w, nh: node.h }
99
169
  // group drag: moving any member moves the whole selection by the same delta
100
170
  const st = useStore.getState()
@@ -105,10 +175,21 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
105
175
  if (n) groupStarts[k] = { x: n.x, y: n.y }
106
176
  }
107
177
  }
108
- world.classList.add('sh-gesturing')
109
- setGesture(true)
178
+ // Defer sh-gesturing (and its `will-change: transform` on .sh-content) until an ACTUAL drag
179
+ // begins - a bare click otherwise promotes then demotes the compositor layer, re-rasterising
180
+ // the frame's text at a fractional zoom = the "jiggle". A pure click now never toggles it.
181
+ let gesturing = false
182
+ const begin = () => {
183
+ if (gesturing) return
184
+ gesturing = true
185
+ world.classList.add('sh-gesturing') // drops iframe pointer-events (sh-camera is NOT set, so no cover)
186
+ setGesture(true)
187
+ }
188
+ const MOVE_THRESHOLD = 3 // px in screen space before a press counts as a drag
110
189
 
111
190
  const onMove = (ev: PointerEvent) => {
191
+ if (!gesturing && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < MOVE_THRESHOLD) return
192
+ begin()
112
193
  const dx = (ev.clientX - start.x) / gestureScale
113
194
  const dy = (ev.clientY - start.y) / gestureScale
114
195
  if (mode === 'move') {
@@ -213,11 +294,20 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
213
294
  </div>
214
295
  ) : null}
215
296
  <iframe
216
- ref={iframeRef}
297
+ ref={bindIframe}
298
+ className="sh-live"
217
299
  src={initialSrc.current ?? frameUrl(frame, node.theme)}
218
300
  title={frame.id}
301
+ onLoad={registerWin}
219
302
  style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
220
303
  />
304
+ {/* SPEC-M5 lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
305
+ the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
306
+ device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
307
+ so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
308
+ never messaged, pointer-events:none - it is NOT the live iframe (role: .sh-lean).
309
+ Rendered in dev AND publish (runtime client-side capture; frames are same-origin in both). */}
310
+ <iframe ref={bindLean} className="sh-lean" sandbox="allow-same-origin" title="" aria-hidden tabIndex={-1} />
221
311
  {/* the overlay eats mouse events for drag-by-body; laser and comment mode both
222
312
  need the mouse INSIDE the frame for hover highlights, so it steps aside
223
313
  (drag still works via the header) */}
@@ -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
+ }