@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.
- package/CHANGELOG.md +93 -0
- package/README.md +3 -2
- package/dist/bake-jr38C_pX.mjs +747 -0
- package/dist/{build-B4yPgFNF.mjs → build-ER_7T6Cw.mjs} +179 -10
- package/dist/cli.mjs +21 -13
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-CKhg0zuT.mjs} +1 -1
- package/dist/{dev-DH2W7Ffw.mjs → dev-BvLbY98O.mjs} +208 -9
- 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-BeBGu3gH.mjs → plugin-Cai5C1WV.mjs} +244 -47
- package/dist/{poster-CoyobbGW.mjs → poster-CIuz_PwH.mjs} +13 -5
- 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-BFEuYbaz.mjs +86 -0
- package/dist/shot-iicees2e.mjs +933 -0
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/live-jam.md +20 -8
- 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/AGENTS-embedded.md +4 -1
- package/templates/AGENTS-studio.md +4 -1
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/jam.md +15 -3
- package/templates/instructions/publish.md +62 -9
- package/templates/instructions/shape.md +2 -1
- package/dist/shot-By1AItpD.mjs +0 -30
- package/dist/shot-z-d-zMzf.mjs +0 -528
- package/src/client/frame-host/serialize.ts +0 -195
- package/src/client/shell/canvas/snapshots.ts +0 -233
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
|
|
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>"}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
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
|
-
|
|
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) }
|