@marver-design/marver 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +1 -0
  3. package/dist/bake-jr38C_pX.mjs +747 -0
  4. package/dist/{build-D_g53Bp2.mjs → build-ER_7T6Cw.mjs} +179 -10
  5. package/dist/cli.mjs +19 -11
  6. package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
  7. package/dist/{daemon-Bbh_jmui.mjs → daemon-CKhg0zuT.mjs} +1 -1
  8. package/dist/{dev-LnIISva5.mjs → dev-BvLbY98O.mjs} +204 -10
  9. package/dist/{init-B7YhcN2o.mjs → init-BWbHqVng.mjs} +2 -2
  10. package/dist/{manifest-CaslQIAO.mjs → manifest-DAnEL8_a.mjs} +1 -1
  11. package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
  12. package/dist/{plugin-D2msH1cj.mjs → plugin-Cai5C1WV.mjs} +107 -28
  13. package/dist/{poster-BEjUcQP3.mjs → poster-CIuz_PwH.mjs} +1 -1
  14. package/dist/publish-bakes-D0LhbQQ3.mjs +216 -0
  15. package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
  16. package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
  17. package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
  18. package/dist/{shot-DlmTO8AF.mjs → shot-iicees2e.mjs} +14 -7
  19. package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
  20. package/docs/publish.md +27 -5
  21. package/package.json +1 -1
  22. package/src/client/frame-host/bridge.js +4 -9
  23. package/src/client/frame-host/main.tsx +20 -6
  24. package/src/client/shell/App.tsx +7 -0
  25. package/src/client/shell/Comments.tsx +2 -2
  26. package/src/client/shell/canvas/Canvas.tsx +2 -2
  27. package/src/client/shell/canvas/FrameNode.tsx +82 -89
  28. package/src/client/shell/canvas/admission.ts +70 -0
  29. package/src/client/shell/canvas/sleep.ts +194 -0
  30. package/src/client/shell/store.ts +14 -0
  31. package/src/client/shell/styles.css +9 -25
  32. package/src/shared/sleep-rule.ts +41 -0
  33. package/templates/instructions/craft.md +9 -0
  34. package/templates/instructions/publish.md +62 -9
  35. package/src/client/frame-host/serialize.ts +0 -195
  36. package/src/client/shell/canvas/snapshots.ts +0 -233
@@ -3,7 +3,7 @@
3
3
  * Boot failures (theme, providers, layouts, the frame itself) render a plain-DOM error card
4
4
  * and post sh:error; an ErrorBoundary catches render-time throws the same way.
5
5
  */
6
- import { Component, createElement, type ReactNode } from 'react'
6
+ import { Component, createElement, useLayoutEffect, type ReactNode } from 'react'
7
7
  import { createRoot } from 'react-dom/client'
8
8
 
9
9
  import './bridge.js'
@@ -53,9 +53,18 @@ class Boundary extends Component<{ children: ReactNode }, { err: Error | null }>
53
53
  }
54
54
  }
55
55
 
56
+ /** Posts sh:ready once the scene has COMMITTED (render() only schedules; a layout effect on the
57
+ * outermost element runs after every child's, before the first paint of the tree). */
58
+ function Committed({ onCommit, children }: { onCommit: () => void; children?: ReactNode }) {
59
+ useLayoutEffect(onCommit, [])
60
+ return children
61
+ }
62
+
56
63
  async function boot() {
64
+ const phases: Record<string, number> = { boot: Math.round(performance.now()) } // ms since navigation: where a boot spends its time (research/hifi/bootscale.ts)
57
65
  try {
58
66
  await import('virtual:sh-theme' as string)
67
+ phases.theme = Math.round(performance.now())
59
68
 
60
69
  const fileKey = frameFile(id)
61
70
  // Honest copy: the id usually IS valid on disk - this document's frame registry is
@@ -63,6 +72,7 @@ async function boot() {
63
72
  if (!fileKey) return fail(`frame "${id}" is not in this canvas's registry yet - the file was likely just added or renamed. The canvas should recover on its own; if this card persists, reload it.`)
64
73
 
65
74
  const frameMod: any = await frames[fileKey]()
75
+ phases.scene = Math.round(performance.now())
66
76
  const Frame = frameMod.default
67
77
  // No typeof gate: memo()/forwardRef() components are objects, not functions.
68
78
  // React + the ErrorBoundary validate the element type better than we can.
@@ -73,17 +83,21 @@ async function boot() {
73
83
  if (providerKey) wrappers.push((await providers[providerKey]() as any).default)
74
84
  for (const lk of layoutChain(fileKey)) wrappers.push((await layouts[lk]() as any).default)
75
85
 
86
+ phases.wrappers = Math.round(performance.now())
76
87
  let tree: ReactNode = createElement(Frame)
77
88
  for (const W of wrappers.reverse()) if (W != null) tree = createElement(W, null, tree)
78
89
 
79
- createRoot(document.getElementById('root')!).render(createElement(Boundary, null, tree))
80
- // stamp the URL revision so the shell can drop a ready queued by a superseded document (one it
81
- // auto-renavigated past) - a WindowProxy survives navigation, so a stale ready could otherwise
82
- // mark a reloading frame ready. Mirrors the sh:measure generation guard.
83
- post({ type: 'sh:ready', id, gen: params.get('r') ?? '', meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined })
90
+ // sh:ready on the first COMMIT, stamped with the URL revision so the shell can drop a ready queued
91
+ // by a superseded document (one it auto-renavigated past) - a WindowProxy survives navigation, so
92
+ // a stale ready could otherwise mark a reloading frame ready. Mirrors the sh:measure generation guard.
93
+ const ready = () => { phases.commit = Math.round(performance.now()); post({ type: 'sh:ready', id, gen: params.get('r') ?? '', meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined, phases }) }
94
+ createRoot(document.getElementById('root')!).render(createElement(Boundary, null, createElement(Committed, { onCommit: ready }, tree)))
84
95
  } catch (err) {
85
96
  fail((err as Error).message)
86
97
  }
87
98
  }
88
99
 
89
100
  boot()
101
+ // Fast Refresh keeps this document alive across edits: tell the shell its source changed, so a
102
+ // sleeping frame wakes and compiles again (a texture describes a source revision).
103
+ if (import.meta.hot) import.meta.hot.on('vite:afterUpdate', () => post({ type: 'sh:hmr', id }))
@@ -550,11 +550,18 @@ export function App() {
550
550
  // sh:measure does, so a stale ready never marks a reloading frame ready.
551
551
  const gen = el.src.match(/[?&]r=(\d+)/)?.[1] ?? ''
552
552
  if (String(data.gen ?? '') !== gen) return
553
+ // a document that loaded again on its own (a full reload) is a new source revision: the
554
+ // sleeping override of the previous document must not be trusted for this one
555
+ if (s.nodes.find((n) => n.key === nodeKey)?.status === 'ready') s.bumpRev(nodeKey)
553
556
  s.setStatus(nodeKey, 'ready')
554
557
  } else if (data.type === 'sh:error') {
555
558
  s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
556
559
  } else if (data.type === 'sh:exit-interact') {
557
560
  if (s.interact === nodeKey) setInteract(null)
561
+ } else if (data.type === 'sh:theme-applied') {
562
+ if (typeof data.theme === 'string') s.setThemeOn(nodeKey, data.theme)
563
+ } else if (data.type === 'sh:hmr') {
564
+ s.bumpRev(nodeKey)
558
565
  } else if (data.type === 'sh:measure') {
559
566
  // Generation guard: the sender echoes ITS document's URL rev; a
560
567
  // WindowProxy survives navigation, so a stale pre-navigation message would
@@ -935,8 +935,8 @@ export function CommentsController() {
935
935
  return () => window.removeEventListener('hashchange', onHash)
936
936
  }, [])
937
937
 
938
- // broadcast pick mode to every LIVE frame (laser rides along inside the bridge). Scoped to
939
- // .sh-live so the lean cover (.sh-lean, a scriptless snapshot) is never messaged.
938
+ // broadcast pick mode to every frame (laser rides along inside the bridge); a sleeping frame
939
+ // is the live document itself, so it is messaged like any other
940
940
  useEffect(() => {
941
941
  for (const f of document.querySelectorAll('iframe.sh-live'))
942
942
  (f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !ctlShowAnchor }, location.origin)
@@ -246,8 +246,8 @@ export function Canvas() {
246
246
  if (!app || !canvas) return
247
247
  let settle = 0
248
248
  const beginGesture = (panning: boolean) => {
249
- // sh-camera = a CANVAS pan/zoom (drives the snapshot cover); sh-gesturing also drops iframe
250
- // pointer-events. A frame click/drag sets only sh-gesturing, so it never flashes a snapshot.
249
+ // sh-camera = a CANVAS pan/zoom (the image-LOD freeze signal); sh-gesturing also drops iframe
250
+ // pointer-events. A frame click/drag sets only sh-gesturing.
251
251
  document.getElementById('sh-world')?.classList.add('sh-gesturing', 'sh-camera')
252
252
  document.body.classList.toggle('sh-panning', panning)
253
253
  clearTimeout(settle)
@@ -1,12 +1,13 @@
1
- import { memo, useCallback, useEffect, useRef } from 'react'
1
+ import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
+ import { admit, release } from './admission.ts'
3
4
  import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, SlideFrameIcon, XIcon } from '../icons.tsx'
4
5
  import { CommentLayer } from '../Comments.tsx'
5
6
  import { useComments } from '../comments-store.ts'
6
7
  import { threadHostKey } from '../keys.ts'
7
8
  import { registerFrame, unregisterFrame } from './frame-registry.ts'
8
9
  import { primeCameraFor } from './camera-broadcast.ts'
9
- import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
10
+ import { sleep, wake } from './sleep.ts'
10
11
  import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
11
12
 
12
13
  export const HEADER = 28
@@ -34,6 +35,12 @@ function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
34
35
  * One frame on the canvas. Iframe laws: the iframe element is created once per node key
35
36
  * and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
36
37
  *
38
+ * At rest the frame SLEEPS in place (sleep.ts, spec 16): the same live document, its animations
39
+ * paused and its backdrop-filters replaced by certified textures. Interact mode wakes it; laser,
40
+ * comment pins and selection act on the sleeping document as it is. A frame the human has
41
+ * interacted with is a state the compiler cannot reproduce from its URL: it stays awake until
42
+ * it is reloaded. Any change of theme, size or source wakes first and sleeps again once settled.
43
+ *
37
44
  * Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
38
45
  * TARGET's classList, nothing else), and drags additionally raise the store gesture flag,
39
46
  * which hard-disables canvas panning for the duration. Both are needed: the class stops the
@@ -51,21 +58,46 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
51
58
  const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, reloadFrame, setGesture, toast } = useStore.getState()
52
59
  const iframeRef = useRef<HTMLIFrameElement>(null)
53
60
  const themeRef = useRef(node.theme)
54
- // src is frozen at mount: theme changes ride sh:set-theme (never navigation), so
55
- // frame state (forms, scroll, dialogs) survives a theme flip. Real file changes below.
56
- const initialSrc = useRef<string | null>(null)
57
- if (frame && initialSrc.current === null) initialSrc.current = frameUrl(frame, node.theme)
61
+ // The JSX src is set ONCE, at ADMISSION, with the theme and revision of that moment, and never
62
+ // changes again (React would write it back after any later navigation and load the frame twice):
63
+ // theme changes ride sh:set-theme (never navigation), so frame state (forms, scroll, dialogs)
64
+ // survives a theme flip; file changes and reloads navigate imperatively below, only once
65
+ // admitted - a queued frame takes the fresh revision when its turn comes.
66
+ const [src, setSrc] = useState<string>()
67
+ const admitted = src !== undefined
68
+ const admittedRef = useRef(false)
58
69
  const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
59
70
 
60
- // theme switch without remount: the live iframe flips via message (no navigation). The lean is
61
- // INVALIDATED (not mutated) - a baked mermaid SVG can't be re-themed in place, so we drop it, show
62
- // live while it re-renders in the new theme, and the capture effect (theme is a dep) rebuilds a
63
- // fresh lean that is only shown once ready. No light-on-dark flash.
71
+ // admission (admission.ts): the iframe gets its src when a boot slot is free, nearest the centre
72
+ // of the view first; the slot goes back when the frame is ready, errored, gone, or its watchdog acts
73
+ const navigate = (url: string) => { if (admittedRef.current && iframeRef.current) iframeRef.current.src = url }
74
+ useEffect(() => {
75
+ if (!frame || node.missing) { admittedRef.current = false; setSrc(undefined); return } // gone: the card replaces the iframe; a return is a new document, admitted anew
76
+ if (admittedRef.current) return // the document exists (the frame's id changed under it): no slot
77
+ const rank = () => { // visible first, then by distance to the centre of the canvas (the panel offsets the window's)
78
+ const r = iframeRef.current?.getBoundingClientRect(), c = document.querySelector('.sh-canvas')?.getBoundingClientRect()
79
+ if (!r || !c) return Infinity
80
+ const visible = r.right > c.left && r.left < c.right && r.bottom > c.top && r.top < c.bottom
81
+ return (visible ? 0 : 1e7) + Math.hypot(r.x + r.width / 2 - c.x - c.width / 2, r.y + r.height / 2 - c.y - c.height / 2)
82
+ }
83
+ const start = () => {
84
+ const s = useStore.getState(), n = s.nodes.find((x) => x.key === node.key), f = n && s.frameFor(n)
85
+ if (!n || !f) { release(node.key); return }
86
+ if (n.status !== 'loading') setStatus(node.key, 'loading') // a returning frame boots under the watchdog like any other
87
+ admittedRef.current = true
88
+ setSrc(frameUrl(f, n.theme))
89
+ }
90
+ admit({ key: node.key, rank, start })
91
+ return () => release(node.key)
92
+ }, [frame?.id, node.key, node.missing])
93
+ useEffect(() => { if (node.status !== 'loading') release(node.key) }, [node.status, node.key])
94
+
95
+ // theme switch without remount: the live iframe flips via message (no navigation); the frame
96
+ // reports sh:theme-applied and only then sleeps again under the new theme (node.themeOn)
64
97
  useEffect(() => {
65
98
  if (themeRef.current !== node.theme) {
66
99
  themeRef.current = node.theme
67
100
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
68
- invalidateLean(node.key)
69
101
  }
70
102
  }, [node.theme, node.key])
71
103
 
@@ -90,53 +122,6 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
90
122
  registerWin()
91
123
  }, [node.key])
92
124
 
93
- // Register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
94
- const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
95
- // capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
96
- // (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
97
- // so neither is a dep - keeping captures rare. Never during a gesture; the coordinator serialises.
98
- useEffect(() => {
99
- // capture reads the live iframe's same-origin document - true in dev AND publish (published frames
100
- // are bundled same-origin and served by `marver serve`), so the lean tier works in both via this
101
- // client-side capture. Fail-soft: a frame that can't serialise stays live (publish == today's
102
- // behaviour in the worst case). No headless build step / heavy dependency needed.
103
- if (node.status !== 'ready' || node.missing) return
104
- const iframe = iframeRef.current
105
- if (!iframe) return
106
- const t = setTimeout(() => {
107
- // never re-admit a cover while this frame hosts an open thread / draft: the live app
108
- // must stay visible (its highlight updates in real time). A status/theme change would
109
- // otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
110
- // hostsCard rail recaptures a clean lean once the card closes.
111
- const c = useComments.getState()
112
- // hosting goes through the resolver (keys.ts): an adopted thread's card renders
113
- // here even though its stored nodeKey names a node that no longer exists
114
- const hosting = (!!c.active && c.threads.some((th) =>
115
- th.id === c.active && !th.resolved && threadHostKey(th, useStore.getState().nodes) === node.key)) || c.draft?.nodeKey === node.key
116
- if (hosting) return
117
- scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
118
- }, 450)
119
- return () => clearTimeout(t)
120
- // node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
121
- // flip, so a theme change re-captures after the live frame re-renders (key includes theme).
122
- }, [node.status, node.nav, node.key, node.missing, node.theme])
123
- useEffect(() => () => dropSnapshot(node.key), [node.key]) // drop the snapshot on unmount
124
- // a reload / file-swap / error takes the frame out of 'ready': drop its cover so a stale picture
125
- // never lingers (nav may not bump on a same-file reload). The next 'ready' re-captures.
126
- useEffect(() => { if (node.status !== 'ready') dropSnapshot(node.key) }, [node.status, node.key])
127
- // LEAN-PRIMARY focus handoff: entering interact shows the live app (drop the now-stale lean at
128
- // once); leaving it recaptures the live frame's CURRENT state (the user may have typed/toggled)
129
- // and only swaps back to lean once that fresh capture is admitted. force=true: same nav/theme.
130
- const prevInteract = useRef(interact)
131
- useEffect(() => {
132
- if (prevInteract.current === interact) return
133
- const wasInteract = prevInteract.current
134
- prevInteract.current = interact
135
- if (interact) invalidateLean(node.key)
136
- else if (wasInteract && node.status === 'ready' && iframeRef.current)
137
- scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
138
- }, [interact, node.key, node.nav, node.theme, node.status])
139
-
140
125
  // laser mode rides the same rail; re-sent when a frame becomes ready
141
126
  // so late loaders join an already-lasered board
142
127
  const laser = useStore((s) => s.laser)
@@ -147,21 +132,6 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
147
132
  const hostsCard = useComments((s) =>
148
133
  (!!s.active && s.threads.some((t) =>
149
134
  t.id === s.active && !t.resolved && threadHostKey(t, useStore.getState().nodes) === node.key)) || s.draft?.nodeKey === node.key)
150
- // a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
151
- // cover: the active-element highlight lives in the live DOM and updates in real time
152
- // (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
153
- // mode ends and either bakes a stale highlight or hides the live one. Mirror the interact
154
- // rail: drop the cover while hosting, rebuild a fresh lean once the card closes (the
155
- // highlight is cleared by then, so the recapture is clean).
156
- const prevHostsCard = useRef(hostsCard)
157
- useEffect(() => {
158
- if (prevHostsCard.current === hostsCard) return
159
- const wasHosting = prevHostsCard.current
160
- prevHostsCard.current = hostsCard
161
- if (hostsCard) invalidateLean(node.key)
162
- else if (wasHosting && node.status === 'ready' && iframeRef.current)
163
- scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
164
- }, [hostsCard, node.key, node.nav, node.theme, node.status])
165
135
  useEffect(() => {
166
136
  if (node.status === 'ready' || !laser)
167
137
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
@@ -179,6 +149,32 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
179
149
  if (node.status === 'ready' || !interact)
180
150
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
181
151
  }, [interact, node.status])
152
+ // SLEEP lifecycle. Interact = awake and DIRTY (the app's state is now its own); a resize drag =
153
+ // awake for the whole drag; any other change of the key (theme once applied, size, source revision,
154
+ // navigation) wakes first - the old override describes another state - and sleeps again once the
155
+ // new one has settled. Laser and comment mode need nothing: the sleeping document IS the live one.
156
+ const dirty = useRef(false)
157
+ const lastDoc = useRef<Document | null>(null) // a new document (reload, self-reload) is pristine again
158
+ const resizing = useRef(false)
159
+ const [resizeTick, setResizeTick] = useState(0)
160
+ useEffect(() => { dirty.current = false }, [node.nav]) // a fresh document is pristine again
161
+ const w = Math.round(node.w), h = Math.round(node.h)
162
+ // a layout effect: the wake lands BEFORE the first paint of the new state (a stretched texture
163
+ // must never be painted at a new size)
164
+ useLayoutEffect(() => {
165
+ const iframe = iframeRef.current
166
+ if (!iframe || !frame || node.missing) { wake(node.key, null); return } // nothing to keep (a deleted frame's card must not retain its old document)
167
+ const doc = iframe.contentDocument
168
+ if (doc && doc !== lastDoc.current) { lastDoc.current = doc; dirty.current = false }
169
+ if (interact) dirty.current = true
170
+ wake(node.key, iframe)
171
+ if (interact || dirty.current || resizing.current || node.status !== 'ready') return
172
+ if (node.themeOn !== undefined && node.themeOn !== node.theme) return // the frame has not painted the new theme yet
173
+ const t = setTimeout(() => { void sleep(node.key, iframe, { frame: frame.id, theme: node.theme, w, h }) }, 250)
174
+ return () => clearTimeout(t)
175
+ }, [interact, node.status, node.theme, node.themeOn, node.rev, node.nav, w, h, node.missing, frame?.id, node.key, resizeTick])
176
+ useEffect(() => () => wake(node.key, iframeRef.current), [node.key])
177
+
182
178
  // image-LOD: once the frame is ready its content listener is live, so send the settled zoom - a static
183
179
  // board that never gets a gesture then still sharpens its images from the cheap low-res first paint.
184
180
  useEffect(() => {
@@ -191,7 +187,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
191
187
  const sig = `${frame.kind}:${frame.file}`
192
188
  if (fileRef.current !== null && fileRef.current !== sig && iframeRef.current) {
193
189
  setStatus(node.key, 'loading')
194
- iframeRef.current.src = frameUrl(frame, node.theme)
190
+ navigate(frameUrl(frame, node.theme))
195
191
  }
196
192
  fileRef.current = sig
197
193
  }, [frame?.kind, frame?.file])
@@ -202,7 +198,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
202
198
  useEffect(() => {
203
199
  if ((node.nav ?? 0) === navRef.current) return
204
200
  navRef.current = node.nav ?? 0
205
- if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
201
+ if (frame) navigate(frameUrl(frame, node.theme))
206
202
  }, [node.nav])
207
203
 
208
204
  // Reload the frame, assigning the fresh URL SYNCHRONOUSLY so the live iframe's src is current the
@@ -215,8 +211,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
215
211
  reloadFrame(node.key, automatic)
216
212
  const after = useStore.getState().nodes.find((x) => x.key === node.key)
217
213
  const f = useStore.getState().frameFor(node)
218
- if (after && f && iframeRef.current) {
219
- iframeRef.current.src = frameUrl(f, node.theme)
214
+ if (after && f) {
215
+ navigate(frameUrl(f, node.theme))
220
216
  navRef.current = after.nav ?? 0
221
217
  }
222
218
  }
@@ -231,14 +227,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
231
227
  // auto-renavigates ONCE on a fresh rev; a second silence stays 'loading' (never a red error card).
232
228
  // node.nav is a dep so a fresh navigation restarts the full budget; readyRetried flips true on the
233
229
  // retry and bounds it to exactly one.
230
+ // The budget counts from ADMISSION (a queued frame is not silent). The retried document keeps its
231
+ // boot slot - its boot is still running - and gives it back after a second silence.
234
232
  useEffect(() => {
235
- if (!shouldArmReadyWatch(node, !!frame)) return
236
- const t = setTimeout(() => reloadRef.current(true), 10_000)
233
+ if (!admitted || node.status !== 'loading' || !frame || node.missing) return
234
+ const t = setTimeout(() => { if (shouldArmReadyWatch(node, true)) reloadRef.current(true); else release(node.key) }, 10_000)
237
235
  return () => clearTimeout(t)
238
236
  // depend on the frame's stable SIGNATURE, not the manifest object - that object is replaced on
239
237
  // every manifest reconcile, so depending on it would reset the budget on unrelated frames during
240
238
  // heavy Live Jam churn and starve the retry. kind/file still re-arm on a real file swap.
241
- }, [node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
239
+ }, [admitted, node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
242
240
 
243
241
  const drag = (e: React.PointerEvent, mode: 'move' | 'e' | 's' | 'se') => {
244
242
  e.stopPropagation()
@@ -268,8 +266,9 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
268
266
  const begin = () => {
269
267
  if (gesturing) return
270
268
  gesturing = true
271
- world.classList.add('sh-gesturing') // drops iframe pointer-events (sh-camera is NOT set, so no cover)
269
+ world.classList.add('sh-gesturing') // drops iframe pointer-events
272
270
  setGesture(true)
271
+ if (mode !== 'move') { resizing.current = true; wake(node.key, iframeRef.current); setResizeTick((t) => t + 1) } // awake before the first resized paint, for the whole resize
273
272
  }
274
273
  const MOVE_THRESHOLD = 3 // px in screen space before a press counts as a drag
275
274
 
@@ -297,6 +296,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
297
296
  try { el.releasePointerCapture(e.pointerId) } catch { /* already released */ }
298
297
  world.classList.remove('sh-gesturing')
299
298
  setGesture(false)
299
+ if (resizing.current) { resizing.current = false; setResizeTick((t) => t + 1) } // settle, then sleep again
300
300
  el.removeEventListener('pointermove', onMove)
301
301
  el.removeEventListener('pointerup', done)
302
302
  el.removeEventListener('pointercancel', done)
@@ -398,18 +398,11 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
398
398
  <iframe
399
399
  ref={bindIframe}
400
400
  className="sh-live"
401
- src={initialSrc.current ?? frameUrl(frame, node.theme)}
401
+ src={src}
402
402
  title={frame.id}
403
403
  onLoad={registerWin}
404
404
  style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
405
405
  />
406
- {/* Lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
407
- the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
408
- device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
409
- so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
410
- never messaged, pointer-events:none - it is NOT the live iframe (role: .sh-lean).
411
- Rendered in dev AND publish (runtime client-side capture; frames are same-origin in both). */}
412
- <iframe ref={bindLean} className="sh-lean" sandbox="allow-same-origin" title="" aria-hidden tabIndex={-1} />
413
406
  {/* the overlay eats mouse events for drag-by-body; laser and comment mode both
414
407
  need the mouse INSIDE the frame for hover highlights, so it steps aside
415
408
  (drag still works via the header) */}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Frame admission: how many iframes boot at once.
3
+ *
4
+ * Every frame on a board is a document booting on the ONE renderer main thread (~400 ms each for a
5
+ * lo-fi React frame), and a batch that starts together finishes together: 64 started at once, the
6
+ * first is ready at 25.6 s and the last at 26.0 s - the board is blank until the end. Admitted a few
7
+ * at a time, the first is ready at 1.8 s and the board fills in view order for the same total
8
+ * (research/hifi/bootscale.ts). The ready watchdog counts from admission, not from mount, so a
9
+ * queued frame is never mistaken for a stalled one.
10
+ *
11
+ * A new batch admits ONE frame first (the quickest first content: one boot alone is ~550 ms, four
12
+ * together ~1.8 s), then up to SLOTS at a time. Pure, apart from its timing: a board that mounts
13
+ * registers every node, the shell fits the camera (App.tsx, a few tens of ms later), then the first
14
+ * pump ranks them (visible first, nearest the centre of the canvas first); a freed slot pumps on
15
+ * the next microtask, ranking again by the view of that moment.
16
+ */
17
+ export const SLOTS = 4
18
+ /** A new batch's head start: one frame alone until it is done, or until this long - one stalled
19
+ * first frame must not hold the whole board. */
20
+ export const HEAD_MS = 2000
21
+ let primed = false // the batch may use every slot
22
+ let gen = 0 // the batch: a pump scheduled for an earlier one is void
23
+ let timer: ReturnType<typeof setTimeout> | undefined
24
+
25
+ export interface Admission { key: string; rank: () => number; start: () => void }
26
+
27
+ const waiting = new Map<string, Admission>()
28
+ const active = new Set<string>()
29
+ let scheduled = false
30
+
31
+ /** Ask for a slot. Starts once the view has settled (SETTLE ms) when one is free; else queued by rank. */
32
+ export function admit(a: Admission): void {
33
+ if (active.has(a.key)) return
34
+ if (!waiting.size && !active.size) { // a new batch
35
+ primed = false; gen++; scheduled = false; clearTimeout(timer)
36
+ timer = setTimeout(() => { primed = true; schedule(0) }, HEAD_MS)
37
+ }
38
+ waiting.set(a.key, a)
39
+ schedule(SETTLE)
40
+ }
41
+ const SETTLE = 350 // the board fit animates ~250 ms from 60 ms after mount: rank the first pick on the settled view
42
+
43
+ /** The frame is done booting (ready, error, gone, or its watchdog took over): free the slot, or
44
+ * leave the queue if it never started. */
45
+ export function release(key: string): void {
46
+ waiting.delete(key)
47
+ if (active.delete(key)) { primed = true; schedule(0) }
48
+ }
49
+
50
+ function schedule(ms: number): void {
51
+ if (scheduled) return
52
+ scheduled = true
53
+ const g = gen, run = () => { if (g === gen) pump() }
54
+ if (ms) setTimeout(run, ms); else queueMicrotask(run)
55
+ }
56
+
57
+ function pump(): void {
58
+ scheduled = false
59
+ while (active.size < (primed ? SLOTS : 1) && waiting.size) {
60
+ let best: Admission | undefined, bestRank = Infinity
61
+ for (const a of waiting.values()) { const r = a.rank(); if (r < bestRank || !best) { best = a; bestRank = r } }
62
+ waiting.delete(best!.key)
63
+ active.add(best!.key)
64
+ ;(globalThis as { __mvAdmitted?: string[] }).__mvAdmitted?.push(best!.key) // diagnostic: the order, when a probe asks for it
65
+ best!.start()
66
+ }
67
+ }
68
+
69
+ /** For tests: nothing queued, nothing active. */
70
+ export function resetAdmission(): void { waiting.clear(); active.clear(); primed = false; scheduled = false; gen++; clearTimeout(timer) }