@marver-design/marver 0.10.1 → 0.10.2
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 +33 -0
- package/dist/{build-BkckEcZd.mjs → build-zUf_kwKp.mjs} +1 -1
- package/dist/cli.mjs +3 -3
- package/dist/{daemon-B_8BmmSJ.mjs → daemon-BpKBJZee.mjs} +1 -1
- package/dist/{dev-Bm-R_-gs.mjs → dev-D2PG6igI.mjs} +12 -2
- package/dist/{init-D2EQEbV6.mjs → init-DwV0_d9G.mjs} +7 -28
- package/dist/{ledger-BgA7nQoH.mjs → ledger-Bu0BjqIe.mjs} +1 -12
- package/dist/managed-HwHVNI3h.mjs +94 -0
- package/dist/{plugin-DhkR3NdW.mjs → plugin-C1sDewZ3.mjs} +15 -5
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/{shot-BRgPaFCs.mjs → shot-D7I0MCLu.mjs} +139 -12
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +6 -0
- package/src/client/frame-host/bridge.js +3 -1
- package/src/client/frame-host/main.tsx +4 -1
- package/src/client/shell/App.tsx +5 -0
- package/src/client/shell/canvas/FrameNode.tsx +45 -6
- package/src/client/shell/canvas/ready-watch.ts +28 -0
- package/src/client/shell/store.ts +27 -6
- package/src/client/shell/styles.css +8 -0
- package/templates/instructions/jam.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.10.2 - 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`marver shot` renders content frames at their real size.** The verify-loop screenshot
|
|
10
|
+
(and the file-drop `design/.local/shots/*.request.json` transport) used to capture every
|
|
11
|
+
frame at the mobile viewport - a 780x1688 crop - so a `Doc layout="wide"` frame came back
|
|
12
|
+
squeezed and anything past ~1688px tall was invisible to the verifying agent. A shot now
|
|
13
|
+
sizes a content frame the way the canvas does: its `contentWidth` (760 document / 1280 wide,
|
|
14
|
+
or a declared `meta.viewport`'s width) at full measured height. Long frames render in full;
|
|
15
|
+
past a capture-safe height the shot drops to 1x pixel density and, if still enormous, reports
|
|
16
|
+
`truncated` with a note rather than producing a gigapixel PNG. The `/api/shot` and file-drop
|
|
17
|
+
results now include `scale` and, when clipped, `truncated`/`note`.
|
|
18
|
+
- **A slow dev server no longer fails a healthy frame.** A frame that hadn't reported ready
|
|
19
|
+
within 10s was marked `frame failed` / `frame never reported ready (10s)`, hidden behind a red
|
|
20
|
+
card that only a per-frame click could clear - but the frame was usually fine, just waiting on
|
|
21
|
+
a slow module response (Vite re-optimizing dependencies, or the box saturated by parallel Live
|
|
22
|
+
Jam agents). A genuine failure already reports itself immediately, so silence past the deadline
|
|
23
|
+
now auto-renavigates the frame once on a fresh revision; if it's still loading it stays visible
|
|
24
|
+
behind a quiet "still loading" pill (with its own reload) instead of a failure. A late ready
|
|
25
|
+
clears it, and a stale ready from the superseded document is dropped by generation so it can't
|
|
26
|
+
mark a reloading frame ready.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **`marver dev` warns when your instructions predate the installed package.** Upgrading the
|
|
31
|
+
package doesn't refresh the managed files in `design/` - so a workspace scaffolded on an
|
|
32
|
+
older version could run the new daemon against an agent contract that predated it (for
|
|
33
|
+
example, Live Jam armed with no `jam.md` on disk). Dev now prints one line at boot when a
|
|
34
|
+
managed instruction file is behind the installed version - `instructions predate marver
|
|
35
|
+
<version> (jam.md, ...) - run: npx marver init` - comparing the on-disk `marver:managed`
|
|
36
|
+
hashes against what this version ships. Files you edited or detached are never flagged.
|
|
37
|
+
|
|
5
38
|
## 0.10.1 - 2026-08-21
|
|
6
39
|
|
|
7
40
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { i as ROUTE, n as NAME } from "./cli.mjs";
|
|
2
2
|
import { l as detectHost, r as scanFrames, s as loadConfig } from "./manifest-DIsp3ldB.mjs";
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-C1sDewZ3.mjs";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
package/dist/cli.mjs
CHANGED
|
@@ -39,14 +39,14 @@ function version() {
|
|
|
39
39
|
}
|
|
40
40
|
const cli = cac(NAME);
|
|
41
41
|
cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
42
|
-
const { init } = await import("./init-
|
|
42
|
+
const { init } = await import("./init-DwV0_d9G.mjs");
|
|
43
43
|
init(resolve(opts.root), {
|
|
44
44
|
mode: opts.mode === "embedded" ? "embedded" : "studio",
|
|
45
45
|
demo: opts.demo !== false
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
48
|
for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot reload, comments, Live Jam)"], ["canvas", "Start the local canvas - same as dev"]]) cli.command(name, desc).option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
|
|
49
|
-
const { dev } = await import("./dev-
|
|
49
|
+
const { dev } = await import("./dev-D2PG6igI.mjs");
|
|
50
50
|
let port;
|
|
51
51
|
if (opts.port !== void 0) {
|
|
52
52
|
const n = Number(opts.port);
|
|
@@ -56,7 +56,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
|
|
|
56
56
|
await dev(resolve(opts.root), port);
|
|
57
57
|
});
|
|
58
58
|
cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
59
|
-
const { buildSite } = await import("./build-
|
|
59
|
+
const { buildSite } = await import("./build-zUf_kwKp.mjs");
|
|
60
60
|
try {
|
|
61
61
|
const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
|
|
62
62
|
await buildSite(resolve(opts.root), boards, opts.allBoards === true);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { r as deviceId, t as has } from "./ledger-BgA7nQoH.mjs";
|
|
2
1
|
import { n as replay } from "./events-BMtBvvgU.mjs";
|
|
3
2
|
import { appendEvents, listBoards, readLog } from "./comments-BZBKhKRO.mjs";
|
|
4
3
|
import { n as localProfile } from "./profile-BkiWglVE.mjs";
|
|
5
4
|
import { i as toFrameId } from "./manifest-DIsp3ldB.mjs";
|
|
6
5
|
import { workActivity } from "./work-CLrmY-vQ.mjs";
|
|
6
|
+
import { r as deviceId, t as has } from "./ledger-Bu0BjqIe.mjs";
|
|
7
7
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as NAME, r as PKG } from "./cli.mjs";
|
|
2
2
|
import { l as detectHost, s as loadConfig } from "./manifest-DIsp3ldB.mjs";
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-C1sDewZ3.mjs";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
|
|
@@ -141,6 +141,16 @@ async function dev(root, portFlag) {
|
|
|
141
141
|
const port = typeof addr === "object" && addr ? addr.port : picked.port;
|
|
142
142
|
if (picked.fellBack) console.log(`\n port ${desiredPort} is in use - serving "${projectName}" on ${port} instead`);
|
|
143
143
|
console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
|
|
144
|
+
try {
|
|
145
|
+
const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
|
|
146
|
+
const { installedVersion } = await import("./plugin-C1sDewZ3.mjs").then((n) => n.i);
|
|
147
|
+
const stale = staleManagedInstructions(root);
|
|
148
|
+
if (stale.length) {
|
|
149
|
+
const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
|
|
150
|
+
const v = installedVersion();
|
|
151
|
+
console.log(` instructions predate ${v ? NAME + " " + v : "the installed version"} (${shown}) - run: npx ${NAME} init\n`);
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
144
154
|
const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
|
|
145
155
|
if (loadCollab(root)) console.log(` comments: syncing with the published canvas (design/.local/collab.json)\n`);
|
|
146
156
|
let syncing = false;
|
|
@@ -171,7 +181,7 @@ async function dev(root, portFlag) {
|
|
|
171
181
|
});
|
|
172
182
|
}
|
|
173
183
|
if (config.jam) {
|
|
174
|
-
const { startJam } = await import("./daemon-
|
|
184
|
+
const { startJam } = await import("./daemon-BpKBJZee.mjs");
|
|
175
185
|
const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
|
|
176
186
|
if (jam) {
|
|
177
187
|
const close = server.close.bind(server);
|
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
import { n as NAME } from "./cli.mjs";
|
|
2
2
|
import { a as writeManifest, c as detectAgent, l as detectHost, o as DEFAULTS, r as scanFrames, u as readJson } from "./manifest-DIsp3ldB.mjs";
|
|
3
|
+
import { MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir } from "./managed-HwHVNI3h.mjs";
|
|
3
4
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { dirname, join, relative } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
6
|
//#region src/cli/init.ts
|
|
8
|
-
/** Package root = the nearest ancestor holding templates/ (one hop from dist/, two
|
|
9
|
-
* from src/cli/ - the walk serves both, and tests run init from source). */
|
|
10
|
-
const pkgDir = () => {
|
|
11
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
for (let i = 0; i < 4; i++) {
|
|
13
|
-
if (existsSync(join(dir, "templates"))) return dir;
|
|
14
|
-
dir = dirname(dir);
|
|
15
|
-
}
|
|
16
|
-
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
-
};
|
|
18
7
|
/** Idempotent scaffolder: never overwrites existing files; every host-repo touch prints a diff. */
|
|
19
8
|
function init(root, opts) {
|
|
20
9
|
const host = detectHost(root);
|
|
@@ -57,7 +46,7 @@ function init(root, opts) {
|
|
|
57
46
|
rmSync(latest, { force: true });
|
|
58
47
|
return;
|
|
59
48
|
}
|
|
60
|
-
if (current.startsWith(
|
|
49
|
+
if (current.startsWith("<!-- marver:managed ")) {
|
|
61
50
|
const recorded = current.slice(MANAGED_PREFIX.length).split(" ")[0];
|
|
62
51
|
const nl = current.indexOf("\n");
|
|
63
52
|
const currentBody = nl >= 0 ? current.slice(nl + 1) : "";
|
|
@@ -75,7 +64,7 @@ function init(root, opts) {
|
|
|
75
64
|
}
|
|
76
65
|
console.warn(` ~ design/${rel}: you customized it and a newer version exists - your edits are untouched. Merge what you want from design/.local/latest/${rel}`);
|
|
77
66
|
}
|
|
78
|
-
} else if (current.startsWith(
|
|
67
|
+
} else if (current.startsWith("<!-- generated by marver init")) {
|
|
79
68
|
writeFileSync(file, next);
|
|
80
69
|
rmSync(latest, { force: true });
|
|
81
70
|
created.push(`design/${rel} (updated)`);
|
|
@@ -93,7 +82,7 @@ function init(root, opts) {
|
|
|
93
82
|
write("providers.tsx", providersTemplate(host.router, host.toaster, host.routerPkg));
|
|
94
83
|
writeManaged("AGENTS.md", readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_GUIDANCE}}", uiGuidance(host, noApp(host))).replace(/\{\{NEXT_NOTES\}\}\n?/, host.router === "next" ? NEXT_NOTES : ""));
|
|
95
84
|
const agentsNow = readFileSync(join(design, "AGENTS.md"), "utf8");
|
|
96
|
-
if (!agentsNow.startsWith(
|
|
85
|
+
if (!agentsNow.startsWith("<!-- marver:managed ") && !agentsNow.startsWith("<!-- generated by marver init") && agentsNow.includes("# Design canvas - agent contract") && !agentsNow.includes("## The method (binding)")) console.warn(` note: design/AGENTS.md predates managed regeneration - if you never edited it, delete it and re-run init to get the current contract (incl. the design/instructions routing).`);
|
|
97
86
|
const claudeMd = join(root, "CLAUDE.md");
|
|
98
87
|
const importLine = "@design/AGENTS.md";
|
|
99
88
|
if (!existsSync(claudeMd)) {
|
|
@@ -118,15 +107,12 @@ function init(root, opts) {
|
|
|
118
107
|
created.push("AGENTS.md (updated)");
|
|
119
108
|
}
|
|
120
109
|
}
|
|
121
|
-
const
|
|
122
|
-
for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
|
|
123
|
-
for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) writeManaged(`instructions/${e.name}/${f}`, readFileSync(join(instrRoot, e.name, f), "utf8"));
|
|
124
|
-
} else if (e.name.endsWith(".md")) writeManaged(`instructions/${e.name}`, readFileSync(join(instrRoot, e.name), "utf8"));
|
|
110
|
+
for (const { rel, body } of enumerateInstructionTemplates(templates)) writeManaged(rel, body);
|
|
125
111
|
const setupPath = join(design, "instructions", "setup.md");
|
|
126
112
|
const setupState = () => {
|
|
127
113
|
if (!existsSync(setupPath)) return "absent";
|
|
128
114
|
const s = readFileSync(setupPath, "utf8");
|
|
129
|
-
if (s.startsWith(
|
|
115
|
+
if (s.startsWith("<!-- marver:managed ")) {
|
|
130
116
|
const recorded = s.slice(MANAGED_PREFIX.length).split(" ")[0];
|
|
131
117
|
const nl = s.indexOf("\n");
|
|
132
118
|
return nl >= 0 && hashBody(s.slice(nl + 1)) === recorded ? "ours-pristine" : "ours-edited";
|
|
@@ -136,7 +122,7 @@ function init(root, opts) {
|
|
|
136
122
|
const setupWas = setupState();
|
|
137
123
|
const appJustAppeared = !noApp(host) && (setupWas === "ours-pristine" || setupWas === "ours-edited");
|
|
138
124
|
if (noApp(host)) {
|
|
139
|
-
if (setupWas === "ours-pristine" && !readFileSync(setupPath, "utf8").startsWith(
|
|
125
|
+
if (setupWas === "ours-pristine" && !readFileSync(setupPath, "utf8").startsWith("<!-- marver:managed ")) rmSync(setupPath);
|
|
140
126
|
if (setupWas !== "foreign") writeManaged("instructions/setup.md", SETUP_MD);
|
|
141
127
|
} else if (setupWas === "ours-pristine") {
|
|
142
128
|
rmSync(setupPath);
|
|
@@ -193,13 +179,6 @@ function init(root, opts) {
|
|
|
193
179
|
console.log(`\n next: npx ${NAME} dev (or: npx ${NAME} canvas - same thing; canvas on http://localhost:${DEFAULTS.port} by default)\n`);
|
|
194
180
|
if (!noApp(host)) console.log(` then, to your agent: "Read design/AGENTS.md. This is our first session - follow design/instructions/welcome.md."\n`);
|
|
195
181
|
}
|
|
196
|
-
const MANAGED_PREFIX = "<!-- marver:managed ";
|
|
197
|
-
const LEGACY_PREFIX = "<!-- generated by marver init";
|
|
198
|
-
const hashBody = (s) => createHash("sha256").update(s).digest("hex");
|
|
199
|
-
/** The marker carries a hash of the generated body: edits are DETECTED, not assumed.
|
|
200
|
-
* Edit freely - init preserves edits and stages upstream updates for merging.
|
|
201
|
-
* Deleting the marker line detaches the file from updates entirely. */
|
|
202
|
-
const managedFile = (body) => `${MANAGED_PREFIX}${hashBody(body)} - edit freely: init preserves your edits and stages upstream updates at design/.local/latest/ for you to merge. Delete this line to detach this file from updates entirely. -->\n${body}`;
|
|
203
182
|
/** The host's path aliases, re-rooted one level down for design/tsconfig.json.
|
|
204
183
|
* "./src/*" becomes "../src/*" so `@/` imports resolve from inside design/. */
|
|
205
184
|
function designPaths(root) {
|
|
@@ -1,19 +1,8 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
1
2
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { homedir, hostname, userInfo } from "node:os";
|
|
5
|
-
//#region \0rolldown/runtime.js
|
|
6
|
-
var __defProp = Object.defineProperty;
|
|
7
|
-
var __exportAll = (all, no_symbols) => {
|
|
8
|
-
let target = {};
|
|
9
|
-
for (var name in all) __defProp(target, name, {
|
|
10
|
-
get: all[name],
|
|
11
|
-
enumerable: true
|
|
12
|
-
});
|
|
13
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
14
|
-
return target;
|
|
15
|
-
};
|
|
16
|
-
//#endregion
|
|
17
6
|
//#region src/server/jam/device.ts
|
|
18
7
|
/**
|
|
19
8
|
* The device stamp - what makes "device-bound" true for the two files that decide whether
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
//#region src/server/managed.ts
|
|
6
|
+
/**
|
|
7
|
+
* Managed-file identity - the ONE source of truth for the `marver:managed <hash>` marker,
|
|
8
|
+
* shared by `init` (which WRITES managed files) and `dev`'s boot-time staleness probe (which
|
|
9
|
+
* only READS them). Kept side-effect-free on purpose: dev must learn whether the on-disk
|
|
10
|
+
* instructions are behind the installed package WITHOUT importing the mutating init() path.
|
|
11
|
+
*/
|
|
12
|
+
/** The marker records a HASH of the generated body: edits are DETECTED, not assumed. */
|
|
13
|
+
const MANAGED_PREFIX = "<!-- marver:managed ";
|
|
14
|
+
/** Hashless 0.2.2-dev marker: edits are undetectable, so init unconditionally upgrades it. */
|
|
15
|
+
const LEGACY_PREFIX = "<!-- generated by marver init";
|
|
16
|
+
const hashBody = (s) => createHash("sha256").update(s).digest("hex");
|
|
17
|
+
/** Prepend the managed marker to a generated body. Deleting the marker line detaches the
|
|
18
|
+
* file from updates entirely. */
|
|
19
|
+
const managedFile = (body) => `${MANAGED_PREFIX}${hashBody(body)} - edit freely: init preserves your edits and stages upstream updates at design/.local/latest/ for you to merge. Delete this line to detach this file from updates entirely. -->\n${body}`;
|
|
20
|
+
/** Package root = the nearest ancestor holding templates/ (one hop from dist/, two from
|
|
21
|
+
* src/** - the walk serves both, and tests run from source). */
|
|
22
|
+
const pkgDir = () => {
|
|
23
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
for (let i = 0; i < 4; i++) {
|
|
25
|
+
if (existsSync(join(dir, "templates"))) return dir;
|
|
26
|
+
dir = dirname(dir);
|
|
27
|
+
}
|
|
28
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
};
|
|
30
|
+
const templatesDir = () => join(pkgDir(), "templates");
|
|
31
|
+
function classifyManaged(current, shippedBody) {
|
|
32
|
+
if (current == null) return "absent";
|
|
33
|
+
if (current.startsWith("<!-- marver:managed ")) {
|
|
34
|
+
const nl = current.indexOf("\n");
|
|
35
|
+
if (nl < 0) return "detached";
|
|
36
|
+
const recorded = current.slice(20).split(" ")[0];
|
|
37
|
+
if (!/^[0-9a-f]{64}$/.test(recorded)) return "detached";
|
|
38
|
+
const body = current.slice(nl + 1);
|
|
39
|
+
const shipped = hashBody(shippedBody);
|
|
40
|
+
if (hashBody(body) === recorded) return recorded === shipped ? "pristine-current" : "pristine-stale";
|
|
41
|
+
return recorded === shipped ? "edited-current" : "edited-stale";
|
|
42
|
+
}
|
|
43
|
+
if (current.startsWith("<!-- generated by marver init")) return "legacy";
|
|
44
|
+
return "detached";
|
|
45
|
+
}
|
|
46
|
+
/** The managed INSTRUCTION set init writes, enumerated identically (init.ts): top-level
|
|
47
|
+
* templates/instructions/*.md plus one level of subdirectory *.md (reference/). setup.md is
|
|
48
|
+
* the SETUP_MD constant written separately/conditionally - it is not a template file, so it
|
|
49
|
+
* never appears here. */
|
|
50
|
+
function enumerateInstructionTemplates(templatesRoot) {
|
|
51
|
+
const instrRoot = join(templatesRoot, "instructions");
|
|
52
|
+
const out = [];
|
|
53
|
+
if (!existsSync(instrRoot)) return out;
|
|
54
|
+
for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
|
|
55
|
+
for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) out.push({
|
|
56
|
+
rel: `instructions/${e.name}/${f}`,
|
|
57
|
+
body: readFileSync(join(instrRoot, e.name, f), "utf8")
|
|
58
|
+
});
|
|
59
|
+
} else if (e.name.endsWith(".md") && e.name !== "setup.md") out.push({
|
|
60
|
+
rel: `instructions/${e.name}`,
|
|
61
|
+
body: readFileSync(join(instrRoot, e.name), "utf8")
|
|
62
|
+
});
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
const STALE = /* @__PURE__ */ new Set([
|
|
66
|
+
"absent",
|
|
67
|
+
"pristine-stale",
|
|
68
|
+
"edited-stale",
|
|
69
|
+
"legacy"
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* Which managed instruction files on disk are behind the INSTALLED package (distinct from the
|
|
73
|
+
* registry update-check). Returns display names (basenames) of the stale files, AGENTS.md
|
|
74
|
+
* included only when the file is entirely ABSENT (a deleted marker is the documented opt-out,
|
|
75
|
+
* and AGENTS.md is host-substituted so its pristine hash never matches the raw template).
|
|
76
|
+
* Never throws: any read/enumeration failure yields an empty list so a boot can't crash on it.
|
|
77
|
+
*/
|
|
78
|
+
function staleManagedInstructions(root) {
|
|
79
|
+
try {
|
|
80
|
+
const design = join(root, "design");
|
|
81
|
+
const stale = [];
|
|
82
|
+
for (const { rel, body } of enumerateInstructionTemplates(templatesDir())) {
|
|
83
|
+
const file = join(design, rel);
|
|
84
|
+
const current = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
85
|
+
if (STALE.has(classifyManaged(current, body))) stale.push(rel.split("/").pop() ?? rel);
|
|
86
|
+
}
|
|
87
|
+
if (!existsSync(join(design, "AGENTS.md"))) stale.unshift("AGENTS.md");
|
|
88
|
+
return stale;
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
export { LEGACY_PREFIX, MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir, staleManagedInstructions };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
1
2
|
import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
|
|
2
3
|
import { n as localProfile, t as isConnected } from "./profile-BkiWglVE.mjs";
|
|
3
4
|
import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DIsp3ldB.mjs";
|
|
@@ -265,7 +266,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
265
266
|
const frameId = url.searchParams.get("frame") ?? "";
|
|
266
267
|
const theme = url.searchParams.get("theme") ?? "light";
|
|
267
268
|
const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
|
|
268
|
-
const { shootFrame } = await import("./shot-
|
|
269
|
+
const { shootFrame } = await import("./shot-D7I0MCLu.mjs");
|
|
269
270
|
const r = await shootFrame({
|
|
270
271
|
root,
|
|
271
272
|
viewports: opts.viewports ?? {},
|
|
@@ -279,7 +280,12 @@ function apiMiddleware(root, opts = {}) {
|
|
|
279
280
|
frame: frameId,
|
|
280
281
|
theme,
|
|
281
282
|
width: r.width,
|
|
282
|
-
height: r.height
|
|
283
|
+
height: r.height,
|
|
284
|
+
scale: r.scale,
|
|
285
|
+
...r.truncated ? {
|
|
286
|
+
truncated: true,
|
|
287
|
+
note: r.note
|
|
288
|
+
} : {}
|
|
283
289
|
});
|
|
284
290
|
}
|
|
285
291
|
if (path === "me" && req.method === "GET") return json(res, 200, {
|
|
@@ -384,7 +390,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
384
390
|
};
|
|
385
391
|
});
|
|
386
392
|
const fresh = appendEvents(dir, cm[1], stamped);
|
|
387
|
-
const { record } = await import("./ledger-
|
|
393
|
+
const { record } = await import("./ledger-Bu0BjqIe.mjs").then((n) => n.n);
|
|
388
394
|
for (const ev of fresh) if (ev.type === "create" || ev.type === "reply") record(root, cm[1], ev.id);
|
|
389
395
|
backgroundPush(root);
|
|
390
396
|
return json(res, 200, { accepted: fresh.length });
|
|
@@ -486,6 +492,10 @@ function routesMiddleware(server, clientDir) {
|
|
|
486
492
|
* firewalled registries degrade to silence. MARVER_NO_UPDATE_CHECK=1 disables it.
|
|
487
493
|
* Published bundles never check anything - viewers are not the owner.
|
|
488
494
|
*/
|
|
495
|
+
var update_exports = /* @__PURE__ */ __exportAll({
|
|
496
|
+
checkUpdate: () => checkUpdate,
|
|
497
|
+
installedVersion: () => installedVersion
|
|
498
|
+
});
|
|
489
499
|
const TTL = 864e5;
|
|
490
500
|
/** Installed version: walk up from this module to the package's own package.json
|
|
491
501
|
* (one level from dist/, two from src/server/ - the walk covers both). */
|
|
@@ -788,7 +798,7 @@ function marverPlugin(ctx) {
|
|
|
788
798
|
});
|
|
789
799
|
return;
|
|
790
800
|
}
|
|
791
|
-
const { shootFrame } = await import("./shot-
|
|
801
|
+
const { shootFrame } = await import("./shot-D7I0MCLu.mjs");
|
|
792
802
|
write(await shootFrame({
|
|
793
803
|
root,
|
|
794
804
|
viewports: config.viewports,
|
|
@@ -868,4 +878,4 @@ async function tailwind3Css(root) {
|
|
|
868
878
|
return null;
|
|
869
879
|
}
|
|
870
880
|
//#endregion
|
|
871
|
-
export { tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
|
|
881
|
+
export { update_exports as i, tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { __exportAll as t };
|
|
@@ -34,6 +34,29 @@ function findChrome() {
|
|
|
34
34
|
return CHROMES.find((p) => existsSync(p)) ?? null;
|
|
35
35
|
}
|
|
36
36
|
const slug = (frameId) => frameId.replace(/\//g, "--");
|
|
37
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
38
|
+
function planShot(frame, viewports) {
|
|
39
|
+
const cw = typeof frame.contentWidth === "number" && Number.isFinite(frame.contentWidth) && frame.contentWidth > 0 ? frame.contentWidth : void 0;
|
|
40
|
+
const vpObj = frame.viewport ? viewports[frame.viewport] : void 0;
|
|
41
|
+
const fallback = viewports.mobile ?? {
|
|
42
|
+
width: 390,
|
|
43
|
+
height: 844
|
|
44
|
+
};
|
|
45
|
+
if (cw) {
|
|
46
|
+
const width = clamp(vpObj?.width ?? cw, 320, 1600);
|
|
47
|
+
return {
|
|
48
|
+
width,
|
|
49
|
+
initialHeight: Math.round(width * .75),
|
|
50
|
+
fullHeight: true
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const vp = vpObj ?? fallback;
|
|
54
|
+
return {
|
|
55
|
+
width: vp.width,
|
|
56
|
+
initialHeight: vp.height,
|
|
57
|
+
fullHeight: false
|
|
58
|
+
};
|
|
59
|
+
}
|
|
37
60
|
/** Resolve a frame from the manifest and screenshot it - the shared core behind BOTH
|
|
38
61
|
* transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
|
|
39
62
|
* name output identically. `origin` is the dev server's own base URL. */
|
|
@@ -52,24 +75,27 @@ async function shootFrame(opts) {
|
|
|
52
75
|
ok: false,
|
|
53
76
|
error: `unknown frame "${frameId}" - ids are in design/manifest.json`
|
|
54
77
|
};
|
|
55
|
-
const
|
|
56
|
-
width: 390,
|
|
57
|
-
height: 844
|
|
58
|
-
};
|
|
78
|
+
const plan = planShot(frame, viewports);
|
|
59
79
|
const shotsDir = join(root, "design", ".local", "shots");
|
|
60
80
|
mkdirSync(shotsDir, { recursive: true });
|
|
61
81
|
const rel = `design/.local/shots/${slug(frameId)}--${theme}.png`;
|
|
62
82
|
const result = await capture({
|
|
63
83
|
url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
|
|
64
|
-
width:
|
|
65
|
-
height:
|
|
66
|
-
out: join(root, rel)
|
|
84
|
+
width: plan.width,
|
|
85
|
+
height: plan.initialHeight,
|
|
86
|
+
out: join(root, rel),
|
|
87
|
+
fullHeight: plan.fullHeight
|
|
67
88
|
});
|
|
68
89
|
return result.ok ? {
|
|
69
90
|
ok: true,
|
|
70
91
|
path: rel,
|
|
71
|
-
width:
|
|
72
|
-
height:
|
|
92
|
+
width: result.width,
|
|
93
|
+
height: result.height,
|
|
94
|
+
scale: result.scale,
|
|
95
|
+
...result.truncated ? {
|
|
96
|
+
truncated: true,
|
|
97
|
+
note: result.note
|
|
98
|
+
} : {}
|
|
73
99
|
} : result;
|
|
74
100
|
}
|
|
75
101
|
/** One capture at a time: shots are seconds apart at most, and a Chrome per concurrent
|
|
@@ -80,7 +106,7 @@ function capture(req) {
|
|
|
80
106
|
chain = run;
|
|
81
107
|
return run;
|
|
82
108
|
}
|
|
83
|
-
async function captureNow({ url, width, height, out, timeoutMs = 3e4 }) {
|
|
109
|
+
async function captureNow({ url, width, height, out, fullHeight = false, timeoutMs = 3e4 }) {
|
|
84
110
|
const bin = findChrome();
|
|
85
111
|
if (!bin) return {
|
|
86
112
|
ok: false,
|
|
@@ -172,6 +198,7 @@ async function captureNow({ url, width, height, out, timeoutMs = 3e4 }) {
|
|
|
172
198
|
rej(e);
|
|
173
199
|
}
|
|
174
200
|
});
|
|
201
|
+
const sendD = (method, params, ms = 1500) => Promise.race([send(method, params, sessionId), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error(`${method} timed out`)), ms))]);
|
|
175
202
|
watchdog = setTimeout(() => {
|
|
176
203
|
try {
|
|
177
204
|
chrome.kill("SIGKILL");
|
|
@@ -226,9 +253,109 @@ async function captureNow({ url, width, height, out, timeoutMs = 3e4 }) {
|
|
|
226
253
|
ok: false,
|
|
227
254
|
error: `the frame rendered an error - ${frameError}`
|
|
228
255
|
};
|
|
229
|
-
|
|
256
|
+
let capW = width, capH = height, scale = 2, truncated = false, note = "";
|
|
257
|
+
if (fullHeight) {
|
|
258
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
259
|
+
const measureH = async () => {
|
|
260
|
+
const v = (await sendD("Runtime.evaluate", {
|
|
261
|
+
expression: `Math.ceil((document.querySelector('.mv-doc')||document.getElementById('root')||document.body).getBoundingClientRect().height)`,
|
|
262
|
+
returnByValue: true
|
|
263
|
+
}).catch(() => null))?.result?.value;
|
|
264
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
265
|
+
};
|
|
266
|
+
const lodIdle = async () => {
|
|
267
|
+
const v = (await sendD("Runtime.evaluate", {
|
|
268
|
+
expression: `(typeof window.__mvLodBusy==='function')?window.__mvLodBusy():0`,
|
|
269
|
+
returnByValue: true
|
|
270
|
+
}).catch(() => null))?.result?.value;
|
|
271
|
+
return typeof v === "number" ? v === 0 : true;
|
|
272
|
+
};
|
|
273
|
+
let measured = 0;
|
|
274
|
+
const growFit = async (dsf, capCss, deadline) => {
|
|
275
|
+
let h = Math.max(height, 2e3), prev = -1;
|
|
276
|
+
for (let i = 0; i < 8 && Date.now() < deadline; i++) {
|
|
277
|
+
await sendD("Emulation.setDeviceMetricsOverride", {
|
|
278
|
+
width,
|
|
279
|
+
height: Math.min(h, capCss),
|
|
280
|
+
deviceScaleFactor: dsf,
|
|
281
|
+
mobile: false
|
|
282
|
+
}).catch(() => {});
|
|
283
|
+
if (Date.now() >= deadline) break;
|
|
284
|
+
await sendD("Runtime.evaluate", {
|
|
285
|
+
expression: "document.fonts.ready.then(()=>true)",
|
|
286
|
+
awaitPromise: true,
|
|
287
|
+
returnByValue: true
|
|
288
|
+
}).catch(() => null);
|
|
289
|
+
if (Date.now() >= deadline) break;
|
|
290
|
+
for (let j = 0; j < 10 && Date.now() < deadline && !await lodIdle(); j++) await wait(100);
|
|
291
|
+
if (Date.now() >= deadline) break;
|
|
292
|
+
await wait(150);
|
|
293
|
+
if (Date.now() >= deadline) break;
|
|
294
|
+
const m = await measureH();
|
|
295
|
+
if (m > 0) measured = m;
|
|
296
|
+
const capped = Math.min(h, capCss);
|
|
297
|
+
if (capped >= capCss) break;
|
|
298
|
+
if (m > 0 && m <= capped && m === prev) break;
|
|
299
|
+
prev = m;
|
|
300
|
+
h = Math.max(m, capped);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
const CAP2 = 8192, CAP1 = 16384;
|
|
304
|
+
const settleDeadline = Date.now() + 6e3;
|
|
305
|
+
await growFit(2, CAP2, settleDeadline);
|
|
306
|
+
if (measured > CAP2) {
|
|
307
|
+
scale = 1;
|
|
308
|
+
await growFit(1, CAP1, settleDeadline);
|
|
309
|
+
}
|
|
310
|
+
const cap = scale === 2 ? CAP2 : CAP1;
|
|
311
|
+
capW = width;
|
|
312
|
+
if (measured > cap) {
|
|
313
|
+
capH = cap;
|
|
314
|
+
truncated = true;
|
|
315
|
+
note = `frame is ${measured}px tall; captured the top ${cap}px - split it or reduce its height`;
|
|
316
|
+
} else capH = clamp(measured || height, 80, cap);
|
|
317
|
+
await sendD("Emulation.setDeviceMetricsOverride", {
|
|
318
|
+
width: capW,
|
|
319
|
+
height: capH,
|
|
320
|
+
deviceScaleFactor: scale,
|
|
321
|
+
mobile: false
|
|
322
|
+
}, 5e3);
|
|
323
|
+
if (Date.now() < settleDeadline) {
|
|
324
|
+
await sendD("Runtime.evaluate", {
|
|
325
|
+
expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
|
|
326
|
+
returnByValue: true
|
|
327
|
+
}).catch(() => null);
|
|
328
|
+
await wait(300);
|
|
329
|
+
for (let j = 0; j < 20 && Date.now() < settleDeadline && !await lodIdle(); j++) await wait(100);
|
|
330
|
+
await sendD("Runtime.evaluate", {
|
|
331
|
+
expression: "document.fonts.ready.then(()=>true)",
|
|
332
|
+
awaitPromise: true,
|
|
333
|
+
returnByValue: true
|
|
334
|
+
}).catch(() => null);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const shot = await sendD("Page.captureScreenshot", {
|
|
338
|
+
format: "png",
|
|
339
|
+
captureBeyondViewport: true,
|
|
340
|
+
clip: {
|
|
341
|
+
x: 0,
|
|
342
|
+
y: 0,
|
|
343
|
+
width: capW,
|
|
344
|
+
height: capH,
|
|
345
|
+
scale: 1
|
|
346
|
+
}
|
|
347
|
+
}, 15e3);
|
|
230
348
|
writeFileSync(out, Buffer.from(String(shot.data), "base64"));
|
|
231
|
-
return {
|
|
349
|
+
return {
|
|
350
|
+
ok: true,
|
|
351
|
+
width: capW,
|
|
352
|
+
height: capH,
|
|
353
|
+
scale,
|
|
354
|
+
...truncated ? {
|
|
355
|
+
truncated: true,
|
|
356
|
+
note
|
|
357
|
+
} : {}
|
|
358
|
+
};
|
|
232
359
|
} catch (err) {
|
|
233
360
|
return {
|
|
234
361
|
ok: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
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,
|
|
@@ -35,6 +35,12 @@ const schedule = (job: () => Promise<void>): void => {
|
|
|
35
35
|
pump()
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Decode-idle signal for the headless shot: 0 means no decode is running or queued. The shot
|
|
39
|
+
// (src/server/shot.ts) polls this to know each image's first decode has settled - which pins
|
|
40
|
+
// its aspect-ratio and so stabilizes the frame's measured height - before it measures and
|
|
41
|
+
// captures. Harmless and 0 when there are no LOD images (nothing ever decodes).
|
|
42
|
+
if (typeof window !== 'undefined') (window as { __mvLodBusy?: () => number }).__mvLodBusy = () => active + q.length
|
|
43
|
+
|
|
38
44
|
interface Item { canvas: HTMLCanvasElement; src: string; bucket: number; token: number }
|
|
39
45
|
const items = new Set<Item>()
|
|
40
46
|
let scale = 0.2 // overview default until the shell primes the settled scale on frame-ready
|
|
@@ -73,7 +73,9 @@ window.addEventListener('message', (e) => {
|
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
if (isHtmlFrame) {
|
|
76
|
-
|
|
76
|
+
// stamp the URL revision (see main.tsx) so the shell can drop a ready from a superseded document
|
|
77
|
+
const gen = new URLSearchParams(location.search).get('r') ?? ''
|
|
78
|
+
const ready = () => post({ type: 'sh:ready', id, gen })
|
|
77
79
|
document.readyState === 'loading' ? addEventListener('DOMContentLoaded', ready) : ready()
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -77,7 +77,10 @@ async function boot() {
|
|
|
77
77
|
for (const W of wrappers.reverse()) if (W != null) tree = createElement(W, null, tree)
|
|
78
78
|
|
|
79
79
|
createRoot(document.getElementById('root')!).render(createElement(Boundary, null, tree))
|
|
80
|
-
|
|
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 })
|
|
81
84
|
} catch (err) {
|
|
82
85
|
fail((err as Error).message)
|
|
83
86
|
}
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -710,6 +710,11 @@ export function App() {
|
|
|
710
710
|
const s = useStore.getState()
|
|
711
711
|
|
|
712
712
|
if (data.type === 'sh:ready') {
|
|
713
|
+
// a WindowProxy survives navigation, so a ready queued by a SUPERSEDED document (one the
|
|
714
|
+
// ready-watchdog auto-renavigated past) can arrive here; drop it by generation like
|
|
715
|
+
// sh:measure does, so a stale ready never marks a reloading frame ready.
|
|
716
|
+
const gen = el.src.match(/[?&]r=(\d+)/)?.[1] ?? ''
|
|
717
|
+
if (String(data.gen ?? '') !== gen) return
|
|
713
718
|
s.setStatus(nodeKey, 'ready')
|
|
714
719
|
} else if (data.type === 'sh:error') {
|
|
715
720
|
s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
|
|
@@ -7,6 +7,7 @@ import { threadHostKey } from '../keys.ts'
|
|
|
7
7
|
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
8
8
|
import { primeCameraFor } from './camera-broadcast.ts'
|
|
9
9
|
import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
|
|
10
|
+
import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
|
|
10
11
|
|
|
11
12
|
export const HEADER = 28
|
|
12
13
|
const SNAP = 12
|
|
@@ -47,7 +48,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
47
48
|
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
|
|
48
49
|
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
|
|
49
50
|
// Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
|
|
50
|
-
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, setGesture, toast } = useStore.getState()
|
|
51
|
+
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, reloadFrame, setGesture, toast } = useStore.getState()
|
|
51
52
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
52
53
|
const themeRef = useRef(node.theme)
|
|
53
54
|
// src is frozen at mount: theme changes ride sh:set-theme (never navigation), so
|
|
@@ -204,12 +205,40 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
204
205
|
if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
|
|
205
206
|
}, [node.nav])
|
|
206
207
|
|
|
207
|
-
//
|
|
208
|
+
// Reload the frame, assigning the fresh URL SYNCHRONOUSLY so the live iframe's src is current the
|
|
209
|
+
// instant a stale sh:ready (queued by the document we just superseded) could be processed - the
|
|
210
|
+
// App generation guard then drops it against the new src. reloadFrame bumps nav+manifestRev; we
|
|
211
|
+
// pre-apply the src here and advance navRef so the passive nav effect does not navigate a 2nd time.
|
|
212
|
+
const reload = (automatic: boolean) => {
|
|
213
|
+
const before = useStore.getState().nodes.find((x) => x.key === node.key)
|
|
214
|
+
if (!before || (automatic && !canAutoReload(before))) return
|
|
215
|
+
reloadFrame(node.key, automatic)
|
|
216
|
+
const after = useStore.getState().nodes.find((x) => x.key === node.key)
|
|
217
|
+
const f = useStore.getState().frameFor(node)
|
|
218
|
+
if (after && f && iframeRef.current) {
|
|
219
|
+
iframeRef.current.src = frameUrl(f, node.theme)
|
|
220
|
+
navRef.current = after.nav ?? 0
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// latest-callback ref so the watchdog effect can call the current reload() without taking it as a
|
|
224
|
+
// dep (an unstable function there would reset the timer every render and starve the retry)
|
|
225
|
+
const reloadRef = useRef(reload)
|
|
226
|
+
reloadRef.current = reload
|
|
227
|
+
|
|
228
|
+
// ready watchdog: a slow dev server (Vite re-optimizing deps, or the box saturated by parallel
|
|
229
|
+
// Live Jam agents) can leave boot()'s module fetches unresolved past the deadline - that is a slow
|
|
230
|
+
// frame, NOT a failed one (a real failure posts sh:error immediately). So the first silent deadline
|
|
231
|
+
// auto-renavigates ONCE on a fresh rev; a second silence stays 'loading' (never a red error card).
|
|
232
|
+
// node.nav is a dep so a fresh navigation restarts the full budget; readyRetried flips true on the
|
|
233
|
+
// retry and bounds it to exactly one.
|
|
208
234
|
useEffect(() => {
|
|
209
|
-
if (node
|
|
210
|
-
const t = setTimeout(() =>
|
|
235
|
+
if (!shouldArmReadyWatch(node, !!frame)) return
|
|
236
|
+
const t = setTimeout(() => reloadRef.current(true), 10_000)
|
|
211
237
|
return () => clearTimeout(t)
|
|
212
|
-
|
|
238
|
+
// depend on the frame's stable SIGNATURE, not the manifest object - that object is replaced on
|
|
239
|
+
// every manifest reconcile, so depending on it would reset the budget on unrelated frames during
|
|
240
|
+
// 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])
|
|
213
242
|
|
|
214
243
|
const drag = (e: React.PointerEvent, mode: 'move' | 'e' | 's' | 'se') => {
|
|
215
244
|
e.stopPropagation()
|
|
@@ -345,7 +374,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
345
374
|
<span className="msg">{node.error}</span>
|
|
346
375
|
<span className="dim">{frame.file}</span>
|
|
347
376
|
<span className="row">
|
|
348
|
-
<button className="sh-no-pan" onClick={() =>
|
|
377
|
+
<button className="sh-no-pan" onClick={() => reload(false)}>
|
|
349
378
|
<ReloadIcon size={12} /> reload
|
|
350
379
|
</button>
|
|
351
380
|
<button className="sh-no-pan" onClick={() => { navigator.clipboard.writeText(`${frame.file}: ${node.error}`); toast('error copied for agent') }}>
|
|
@@ -354,6 +383,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
354
383
|
</span>
|
|
355
384
|
</div>
|
|
356
385
|
) : null}
|
|
386
|
+
{/* a frame still silent after its one auto-retry: a slow dev server, never a failure. A quiet
|
|
387
|
+
non-covering pill (not the red card) keeps it honest and offers a manual reload. */}
|
|
388
|
+
{node.status === 'loading' && node.readyRetried ? (
|
|
389
|
+
<div className="sh-loading sh-no-pan">
|
|
390
|
+
<span>still loading</span>
|
|
391
|
+
<button className="sh-no-pan" onClick={() => reload(false)}>
|
|
392
|
+
<ReloadIcon size={11} /> reload
|
|
393
|
+
</button>
|
|
394
|
+
</div>
|
|
395
|
+
) : null}
|
|
357
396
|
<iframe
|
|
358
397
|
ref={bindIframe}
|
|
359
398
|
className="sh-live"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Readiness-watchdog policy, kept pure so it can be unit-tested apart from the browser-coupled
|
|
2
|
+
// store (which imports Vite virtual modules and can't load under vitest).
|
|
3
|
+
//
|
|
4
|
+
// The frame host posts sh:ready SYNCHRONOUSLY after it renders, so a frame that misses the
|
|
5
|
+
// deadline has not failed - its dev-server module fetches just haven't settled (Vite re-optimizing
|
|
6
|
+
// dependencies, or the box saturated by parallel Live Jam agents). A genuine failure takes the
|
|
7
|
+
// separate immediate sh:error path. So a silent deadline earns exactly ONE automatic re-navigation
|
|
8
|
+
// on a fresh revision; a second silence stays 'loading' forever (never a red failure card).
|
|
9
|
+
|
|
10
|
+
export interface ReadyNode {
|
|
11
|
+
status: 'loading' | 'ready' | 'error'
|
|
12
|
+
readyRetried?: boolean
|
|
13
|
+
missing?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Arm the 10s watchdog only for a present, still-loading, not-yet-retried frame. A missing or
|
|
17
|
+
* absent frame must NOT spend the one-shot allowance - it has no live iframe booting, and would
|
|
18
|
+
* otherwise burn its retry before its file even arrives. */
|
|
19
|
+
export function shouldArmReadyWatch(node: ReadyNode, hasFrame: boolean): boolean {
|
|
20
|
+
return node.status === 'loading' && !node.readyRetried && hasFrame && !node.missing
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether an automatic (watchdog-driven) reload still applies: one shot, and only while the frame
|
|
24
|
+
* is still silently loading. A frame that already went ready/error, or already spent its retry,
|
|
25
|
+
* is left alone. */
|
|
26
|
+
export function canAutoReload(node: ReadyNode): boolean {
|
|
27
|
+
return node.status === 'loading' && !node.readyRetried
|
|
28
|
+
}
|
|
@@ -38,6 +38,10 @@ export interface Node {
|
|
|
38
38
|
* frame arrives - an authored size on a temporarily-missing frame is never touched. */
|
|
39
39
|
sizeFallback?: boolean
|
|
40
40
|
status: 'loading' | 'ready' | 'error'; error?: string; missing?: boolean
|
|
41
|
+
/** the frame's one automatic ready-retry has been spent (a slow dev server must not be reported
|
|
42
|
+
* as a failed frame; see canvas/ready-watch.ts). Transient - cleared on any real ready/error and
|
|
43
|
+
* on every fresh navigation. */
|
|
44
|
+
readyRetried?: boolean
|
|
41
45
|
}
|
|
42
46
|
/** A Live Jam notification: a persistent bottom-right glass pill for a Marver
|
|
43
47
|
* reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
|
|
@@ -48,6 +52,7 @@ export const CONFIG: { viewports: Record<string, { width: number; height: number
|
|
|
48
52
|
|
|
49
53
|
export { cap, humanize } from './labels.ts'
|
|
50
54
|
import { cap, humanize } from './labels.ts'
|
|
55
|
+
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
51
56
|
|
|
52
57
|
/** Board names for switchers: the agent's curated boards FIRST (ranked by each board's `order`, then
|
|
53
58
|
* name), and the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
@@ -94,7 +99,7 @@ export const bumpManifestRev = () => { manifestRev++ }
|
|
|
94
99
|
|
|
95
100
|
export function frameUrl(frame: FrameEntry, theme: string): string {
|
|
96
101
|
return frame.kind === 'html'
|
|
97
|
-
? `/${frame.file}?theme=${theme}`
|
|
102
|
+
? `/${frame.file}?theme=${theme}&r=${manifestRev}` // r= carries the generation for the sh:ready guard (static serving ignores it)
|
|
98
103
|
: `${ROUTE}/frame/?id=${encodeURIComponent(frame.id)}&theme=${theme}&r=${manifestRev}`
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -175,6 +180,7 @@ interface State {
|
|
|
175
180
|
resizeNode(key: string, w: number, h: number): void
|
|
176
181
|
measureNode(key: string, frameId: string, ownWidth: number, measuredWidth: number, height: number): void
|
|
177
182
|
setStatus(key: string, status: Node['status'], error?: string): void
|
|
183
|
+
reloadFrame(key: string, automatic?: boolean): void
|
|
178
184
|
removeNode(key: string): void
|
|
179
185
|
select(key: string | null, additive?: boolean): void
|
|
180
186
|
selectMany(keys: string[]): void
|
|
@@ -620,7 +626,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
620
626
|
n.w = vp?.width ?? d.w
|
|
621
627
|
n.h = vp?.height ?? d.h
|
|
622
628
|
}
|
|
623
|
-
if (arrived) delete n.sizeFallback
|
|
629
|
+
if (arrived) { delete n.sizeFallback; n.readyRetried = false } // a fresh mount earns a fresh allowance
|
|
624
630
|
n.missing = missing
|
|
625
631
|
changed = true
|
|
626
632
|
}
|
|
@@ -637,7 +643,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
637
643
|
if (!f?.contentWidth && n.sizeMode) { delete n.sizeMode; retinted = true }
|
|
638
644
|
// an errored frame whose file IS in the fresh manifest gets one automatic retry
|
|
639
645
|
// on a rev-stamped URL - the "unknown frame id" dead end must self-heal (#20)
|
|
640
|
-
if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
|
|
646
|
+
if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; n.readyRetried = false; retinted = true }
|
|
641
647
|
}
|
|
642
648
|
// auto boards prune deleted frames outright - "auto-managed" must manage both
|
|
643
649
|
// directions (friction log #15). Curated boards keep the explicit card.
|
|
@@ -793,7 +799,22 @@ export const useStore = create<State>((set, get) => {
|
|
|
793
799
|
else scheduleSave()
|
|
794
800
|
},
|
|
795
801
|
setStatus(key, status, error) {
|
|
796
|
-
|
|
802
|
+
// any real ready/error resets the one-shot retry allowance, so a later manual reload or a
|
|
803
|
+
// fresh manifest earns its own auto-retry again
|
|
804
|
+
set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, status, error, readyRetried: false } : n)) }))
|
|
805
|
+
},
|
|
806
|
+
// Reload a frame on a fresh rev-stamped URL (dodges a poisoned-cache document, #20). The
|
|
807
|
+
// watchdog calls this with automatic=true after a silent deadline: it fires at most once per
|
|
808
|
+
// load (canAutoReload gates it) and marks readyRetried so a second silence never retries and
|
|
809
|
+
// never becomes an error. A manual reload passes automatic=false, granting a fresh allowance.
|
|
810
|
+
reloadFrame(key, automatic = false) {
|
|
811
|
+
const n = get().nodes.find((x) => x.key === key)
|
|
812
|
+
if (!n) return
|
|
813
|
+
if (automatic && !canAutoReload(n)) return
|
|
814
|
+
bumpManifestRev()
|
|
815
|
+
set((s) => ({ nodes: s.nodes.map((x) => (x.key === key
|
|
816
|
+
? { ...x, status: 'loading' as const, error: undefined, nav: (x.nav ?? 0) + 1, readyRetried: automatic }
|
|
817
|
+
: x)) }))
|
|
797
818
|
},
|
|
798
819
|
// plain select replaces; additive toggles membership. Interact survives only while its
|
|
799
820
|
// frame stays selected.
|
|
@@ -884,7 +905,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
884
905
|
const nodes = s.nodes.map((n) => {
|
|
885
906
|
if (!ids.has(n.frame)) return n
|
|
886
907
|
if (frameIsLeased(n.frame, s)) { pending[n.frame] = revision; return n }
|
|
887
|
-
return { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 }
|
|
908
|
+
return { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1, readyRetried: false }
|
|
888
909
|
})
|
|
889
910
|
set({ nodes, pendingFrameRevisions: pending, ...(s.play ? { playUpdateRevision: revision } : {}) })
|
|
890
911
|
},
|
|
@@ -897,7 +918,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
897
918
|
const pending = { ...s.pendingFrameRevisions }
|
|
898
919
|
for (const id of ids) delete pending[id]
|
|
899
920
|
const nodes = s.nodes.map((n) => apply.has(n.frame)
|
|
900
|
-
? { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 } : n)
|
|
921
|
+
? { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1, readyRetried: false } : n)
|
|
901
922
|
set({ nodes, pendingFrameRevisions: pending })
|
|
902
923
|
},
|
|
903
924
|
setExternalLease(nodeKey, reason, on) {
|
|
@@ -426,6 +426,14 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
426
426
|
border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px;
|
|
427
427
|
padding: 4px 10px; cursor: pointer }
|
|
428
428
|
|
|
429
|
+
/* still-loading pill: a slow (not failed) frame after its one auto-retry. Non-covering, sits above
|
|
430
|
+
the drag overlay (z2) so its reload button is clickable, and stays out of the frame's content. */
|
|
431
|
+
.sh-loading { position: absolute; left: 8px; bottom: 8px; z-index: 6; display: inline-flex; align-items: center;
|
|
432
|
+
gap: 8px; padding: 4px 4px 4px 10px; border-radius: 999px; font: 500 11px -apple-system, system-ui, sans-serif;
|
|
433
|
+
color: var(--card-warn-ink); background: var(--card-warn-bg); box-shadow: 0 1px 4px rgba(0, 0, 0, .16) }
|
|
434
|
+
.sh-loading button { display: inline-flex; align-items: center; gap: 4px; font: 600 11px -apple-system, system-ui, sans-serif;
|
|
435
|
+
border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px; padding: 3px 9px; cursor: pointer }
|
|
436
|
+
|
|
429
437
|
/* shared icon button (glass surfaces) */
|
|
430
438
|
.sh-ibtn { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 999px;
|
|
431
439
|
border: 0; background: none; color: var(--glass-ink-3); cursor: pointer }
|
|
@@ -92,6 +92,10 @@ The `result.json` is the universal signal - it works even when you cannot see im
|
|
|
92
92
|
throw shows the frame's own exception, "the frame rendered an error - ..."; an unreachable
|
|
93
93
|
dev server or missing Chrome says so). So a crashed or blank frame is caught by the JSON
|
|
94
94
|
alone. `"ok":true` means it painted - and THEN the PNG tells you whether it painted *well*.
|
|
95
|
+
The result also reports the exact `width`/`height` captured: a content frame (a `Doc`) is
|
|
96
|
+
shot at its natural width and its FULL height, so a wide layout or a long spec renders in
|
|
97
|
+
full, not cropped. A frame tall enough to hit the capture cap comes back with
|
|
98
|
+
`"truncated":true` and a `note` - split it or shorten it and re-shoot.
|
|
95
99
|
|
|
96
100
|
(If you DO have a shell - `npx marver shot <scene/frame> [--theme dark]` is the same thing
|
|
97
101
|
in one line, printing the PNG path.)
|