@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.
- package/CHANGELOG.md +68 -0
- package/README.md +1 -0
- package/dist/bake-jr38C_pX.mjs +747 -0
- package/dist/{build-D_g53Bp2.mjs → build-ER_7T6Cw.mjs} +179 -10
- package/dist/cli.mjs +19 -11
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-CKhg0zuT.mjs} +1 -1
- package/dist/{dev-LnIISva5.mjs → dev-BvLbY98O.mjs} +204 -10
- package/dist/{init-B7YhcN2o.mjs → init-BWbHqVng.mjs} +2 -2
- package/dist/{manifest-CaslQIAO.mjs → manifest-DAnEL8_a.mjs} +1 -1
- package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
- package/dist/{plugin-D2msH1cj.mjs → plugin-Cai5C1WV.mjs} +107 -28
- package/dist/{poster-BEjUcQP3.mjs → poster-CIuz_PwH.mjs} +1 -1
- package/dist/publish-bakes-D0LhbQQ3.mjs +216 -0
- package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
- package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
- package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
- package/dist/{shot-DlmTO8AF.mjs → shot-iicees2e.mjs} +14 -7
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/publish.md +27 -5
- package/package.json +1 -1
- package/src/client/frame-host/bridge.js +4 -9
- package/src/client/frame-host/main.tsx +20 -6
- package/src/client/shell/App.tsx +7 -0
- package/src/client/shell/Comments.tsx +2 -2
- package/src/client/shell/canvas/Canvas.tsx +2 -2
- package/src/client/shell/canvas/FrameNode.tsx +82 -89
- package/src/client/shell/canvas/admission.ts +70 -0
- package/src/client/shell/canvas/sleep.ts +194 -0
- package/src/client/shell/store.ts +14 -0
- package/src/client/shell/styles.css +9 -25
- package/src/shared/sleep-rule.ts +41 -0
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/publish.md +62 -9
- package/src/client/frame-host/serialize.ts +0 -195
- 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
|
-
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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 }))
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -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
|
|
939
|
-
//
|
|
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 (
|
|
250
|
-
// pointer-events. A frame click/drag sets only sh-gesturing
|
|
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 {
|
|
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
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
219
|
-
|
|
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 (!
|
|
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
|
|
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={
|
|
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) }
|