@marver-design/marver 0.16.1 → 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 (43) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +3 -2
  3. package/dist/bake-jr38C_pX.mjs +747 -0
  4. package/dist/{build-B4yPgFNF.mjs → build-ER_7T6Cw.mjs} +179 -10
  5. package/dist/cli.mjs +21 -13
  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-DH2W7Ffw.mjs → dev-BvLbY98O.mjs} +208 -9
  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-BeBGu3gH.mjs → plugin-Cai5C1WV.mjs} +244 -47
  13. package/dist/{poster-CoyobbGW.mjs → poster-CIuz_PwH.mjs} +13 -5
  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-BFEuYbaz.mjs +86 -0
  18. package/dist/shot-iicees2e.mjs +933 -0
  19. package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
  20. package/docs/live-jam.md +20 -8
  21. package/docs/publish.md +27 -5
  22. package/package.json +1 -1
  23. package/src/client/frame-host/bridge.js +4 -9
  24. package/src/client/frame-host/main.tsx +20 -6
  25. package/src/client/shell/App.tsx +7 -0
  26. package/src/client/shell/Comments.tsx +2 -2
  27. package/src/client/shell/canvas/Canvas.tsx +2 -2
  28. package/src/client/shell/canvas/FrameNode.tsx +82 -89
  29. package/src/client/shell/canvas/admission.ts +70 -0
  30. package/src/client/shell/canvas/sleep.ts +194 -0
  31. package/src/client/shell/store.ts +14 -0
  32. package/src/client/shell/styles.css +9 -25
  33. package/src/shared/sleep-rule.ts +41 -0
  34. package/templates/AGENTS-embedded.md +4 -1
  35. package/templates/AGENTS-studio.md +4 -1
  36. package/templates/instructions/craft.md +9 -0
  37. package/templates/instructions/jam.md +15 -3
  38. package/templates/instructions/publish.md +62 -9
  39. package/templates/instructions/shape.md +2 -1
  40. package/dist/shot-By1AItpD.mjs +0 -30
  41. package/dist/shot-z-d-zMzf.mjs +0 -528
  42. package/src/client/frame-host/serialize.ts +0 -195
  43. package/src/client/shell/canvas/snapshots.ts +0 -233
@@ -1,4 +1,4 @@
1
- import { n as NAME } from "./cli.mjs";
1
+ import { r as NAME } from "./cli.mjs";
2
2
  import { WORK_TTL_DEFAULT, WORK_TTL_MAX, readDevInfo } from "./work-CLrmY-vQ.mjs";
3
3
  //#region src/cli/work.ts
4
4
  /**
package/docs/live-jam.md CHANGED
@@ -89,19 +89,31 @@ resolves a thread; you do that after reviewing.
89
89
 
90
90
  The missing sense that no-shell used to cost - "does my frame actually RENDER?" - is a
91
91
  server capability instead, rendered in the machine's own headless Chrome (no bundled
92
- browser, CDP over Node's built-in WebSocket) and written as a PNG under
92
+ browser; CDP over Chrome's debugging pipe, so the browser lives exactly as long as the
93
+ shot) and written as a PNG under
93
94
  `design/.local/shots/`. Two transports reach it, because the no-shell jail rules out the
94
95
  obvious one:
95
96
 
96
97
  - **The file-drop inbox** (works for every agent, including Claude Code, which has no shell
97
98
  and whose WebFetch refuses localhost). The agent writes
98
- `design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}`; the dev
99
- server renders and writes `<slug>.result.json` with the PNG path or an error, which the
100
- agent Reads.
101
- - **`npx marver shot <frame> [--scale 1-4]`** / `GET /api/shot?frame=<id>&theme=<t>&scale=<n>`
102
- for humans and shell-ful agents - the same renderer, one line. Default 2x; `--scale 4` for a
103
- print-quality still (a slide comes back 5120×2880). A frame too tall for the asked scale steps
104
- down and says so in `note`; the file name carries the scale actually used (`…@4x.png`).
99
+ `design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}` - or
100
+ `{"scene":"<name>"}`, `{"frames":[...]}`, `{"all":true}` for a batch; the dev server renders
101
+ and writes `<slug>.result.json` with the PNG path or an error (a batch: `results`, one entry
102
+ per frame), which the agent Reads.
103
+ - **`npx marver shot <frame ...> | --scene <name> | --all [--scale 1-4] [--json]`** /
104
+ `GET /api/shot?frame=<id>&theme=<t>&scale=<n>` / `POST /api/shots {frames|scene|all, theme,
105
+ scale}` for humans and shell-ful agents - the same renderer, one line. A batch is ONE
106
+ operation: one headless browser, `MARVER_SHOT_CONCURRENCY` frames at a time inside it
107
+ (default up to 6, sized to the machine), so a scene costs about what a frame does. Default
108
+ 2x; `--scale 4` for a print-quality still (a slide comes back 5120×2880). A frame too tall
109
+ for the asked scale steps down and says so in `note`; the file name carries the scale
110
+ actually used (`…@4x.png`). A frame that ran out of settle budget still ships, marked
111
+ `unsettled` with a note.
112
+ - **The browser's life.** The headless Chrome exists only while an operation runs - it is
113
+ driven over Chrome's own debugging pipe, so it dies with the dev server however the server
114
+ dies (Ctrl-C, a closed terminal, `kill -9`), and none is kept between shots. `MARVER_CHROME`
115
+ picks the binary; pointing it at a Chrome for Testing or Chromium build makes the shot
116
+ browser a different app from your own, which some people prefer on macOS.
105
117
  The canvas's **copy as image** (`i` / `⇧i`, the images-square toolbar button) is this same
106
118
  renderer with `format=png`, so what a designer pastes and what an agent shoots is one picture.
107
119
 
package/docs/publish.md CHANGED
@@ -36,6 +36,20 @@ only around published boards (a folder with nothing published never reaches the
36
36
  and `title`s and `description`s ship only for published things - the project's, the
37
37
  published boards', their folders', scenes' and frames'.
38
38
 
39
+ **Glass at rest.** `marver build` compiles the textures a hi-fi frame rests under (the same
40
+ certified compile as the dev canvas, spec 16) against the site it just built - every published
41
+ node, at its size on its board, in every theme - and ships them with it under `__mv/bakes/`. A
42
+ visitor's browser reads one static index; the shell is the same. Whatever the compiler cannot
43
+ certify (glass inside glass, blend modes, a frame whose paint is not a function of its URL) rests
44
+ with its glass live, as before. The compile needs Chrome on the machine that builds: without one
45
+ the build says so and ships without textures; `--no-textures` (or `MARVER_NO_TEXTURES=1` in CI)
46
+ skips it on purpose. A visitor who resizes a frame to a device preset sees it live at that size
47
+ (no texture was compiled for it). The textures are certified as the build machine renders the
48
+ frame, so bundle the fonts your frames use (`@fontsource-*`, or files under `public/`): a font
49
+ that exists only on a designer's laptop renders differently in a build container, and the
50
+ visitor's browser then refuses those textures and rests the frame live. A republish mints new
51
+ textures; a tab already open keeps the old shell and rests its glass live until it reloads.
52
+
39
53
  ## Who can open your canvas
40
54
 
41
55
  Three choices, and the canvas is public until you make one.
@@ -246,10 +260,13 @@ containerised canvas reports as one campaign.
246
260
 
247
261
  ## Railway (the one-pager)
248
262
 
249
- 1. Push your repo to GitHub and create a Railway service from it.
250
- 2. Build command: `npm ci && npx marver build`
251
- 3. Start command: `npx marver serve` (Railway's `$PORT` is picked up automatically)
252
- 4. Variables: `MARVER_PASSWORD=<your password>`
263
+ 1. Push your repo to GitHub with the Dockerfile below at its root, and create a Railway
264
+ service from it (Railway detects the Dockerfile; a Nixpacks build has no browser and ships
265
+ the hi-fi frames with live glass).
266
+ 2. Start command: `npx marver serve` (Railway's `$PORT` is picked up automatically)
267
+ 3. Variables: `MARVER_PASSWORD=<your password>`, or the identity gate from the table above.
268
+ 4. Read the build log: `textures: N frame views asleep under certified glass ...` is the line
269
+ that says the glass compiled; `textures: none - no Chrome` means the image has no browser.
253
270
 
254
271
  Deploy. The repo itself is the deployable - nothing to export, nothing to sync.
255
272
 
@@ -257,14 +274,19 @@ Deploy. The repo itself is the deployable - nothing to export, nothing to sync.
257
274
 
258
275
  ```dockerfile
259
276
  FROM node:22-slim
277
+ RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
278
+ && rm -rf /var/lib/apt/lists/* # the browser `marver build` compiles the glass textures with
260
279
  WORKDIR /app # set share.name in design/config.ts - the fallback name is this directory
261
280
  COPY . .
262
- RUN npm ci && npx marver build
281
+ RUN npm ci && npx marver build # as root in a container: marver adds Chrome's --no-sandbox itself
263
282
  ENV PORT=8080
264
283
  EXPOSE 8080
265
284
  CMD ["npx", "marver", "serve"]
266
285
  ```
267
286
 
287
+ Without a browser in the image the build still succeeds, says so, and ships every feature but the
288
+ textures; `npx marver build --no-textures` skips the compile on purpose.
289
+
268
290
  ## Cloudflare Pages + Access (email/domain allowlists)
269
291
 
270
292
  For teams that want per-email policies instead of one password: build in CI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -12,20 +12,15 @@ const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
12
12
  const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) }
13
13
  const id = new URLSearchParams(location.search).get('id') ?? location.pathname
14
14
 
15
- // The shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
16
- // are walkable, but a CLOSED root is invisible after the fact - flag it at creation so the serialiser
17
- // degrades the frame (keeps it live) instead of shipping a lean copy missing its shadow content.
18
- const _attachShadow = Element.prototype.attachShadow
19
- if (_attachShadow) Element.prototype.attachShadow = function (init) {
20
- if (init && init.mode === 'closed') window.__mvClosedShadow = true
21
- return _attachShadow.call(this, init)
22
- }
23
-
24
15
  // theme lands as BOTH signals: [data-theme] plus the `dark` class Tailwind/shadcn key on
25
16
  const setTheme = (theme) => {
26
17
  document.documentElement.dataset.theme = theme
27
18
  document.documentElement.classList.toggle('dark', theme === 'dark')
19
+ // the shell sleeps a frame only under the theme it has actually painted: report it, two frames later
20
+ requestAnimationFrame(() => requestAnimationFrame(() => post({ type: 'sh:theme-applied', id, theme })))
28
21
  }
22
+ // the theme this document booted with (the URL's) counts as applied
23
+ requestAnimationFrame(() => requestAnimationFrame(() => post({ type: 'sh:theme-applied', id, theme: new URLSearchParams(location.search).get('theme') ?? 'light' })))
29
24
 
30
25
  if (isHtmlFrame) {
31
26
  const theme = new URLSearchParams(location.search).get('theme')
@@ -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) }