@marver-design/marver 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -20,9 +20,9 @@ Then, to your agent:
20
20
  - `data-goto="scene/frame"` on any element links frames into a walkable prototype.
21
21
  - Uninstall: delete `design/`, remove the dependency. (If `init` patched your tsconfig `exclude`, revert that one line.)
22
22
 
23
- The implementation contract is [SPEC.md](./SPEC.md). Deviations live in [DECISIONS.md](./DECISIONS.md).
23
+ **Next.js**: supported with one caveat - frames render in Vite, outside Next. `next/font` CSS variables are undefined inside frames (give font tokens a fallback chain), `next/image`/`next/link` should be plain `img`/`data-goto` in frames, and Server Components cannot run there. `init` writes the specifics into `design/AGENTS.md` when it detects Next.
24
24
 
25
- ; private; TNEP4.
25
+ Implementation contracts (SPEC.md, DECISIONS.md) live in the [GitHub repo](https://github.com/TNEP4/marver).
26
26
 
27
27
  ## Shortcuts
28
28
 
@@ -1,8 +1,8 @@
1
1
  import { r as ROUTE, t as NAME } from "./cli.mjs";
2
- import { n as loadConfig, r as detectHost } from "./config-DMBEpdEN.mjs";
3
- import { i as scanFrames, n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DB5t2WUl.mjs";
4
- import { basename, dirname, join, sep } from "node:path";
2
+ import { a as loadConfig, n as scanFrames, o as detectHost } from "./manifest-CHmKAAtG.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-lVQoABEx.mjs";
5
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, join, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { build } from "vite";
8
8
  import react from "@vitejs/plugin-react";
package/dist/cli.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { cac } from "cac";
3
- import { resolve } from "node:path";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
  //#region src/client/const.ts
5
7
  /** Client-safe constants. The single source - src/cli/name.ts re-exports for node-side code.
6
8
  * Lives under src/client because that is the only source directory shipped in the package. */
@@ -14,16 +16,30 @@ if (major < 22 || major === 22 && minor < 18) {
14
16
  console.error(`${NAME} needs Node >= 22.18 (native TypeScript config loading). You have ${process.versions.node}.`);
15
17
  process.exit(1);
16
18
  }
19
+ const emitWarning = process.emitWarning.bind(process);
20
+ process.emitWarning = ((warning, ...rest) => {
21
+ const opt = rest[0];
22
+ if (((typeof opt === "object" && opt ? opt.code : rest[1]) ?? (warning && typeof warning === "object" ? warning.code : void 0)) === "MODULE_TYPELESS_PACKAGE_JSON") return;
23
+ emitWarning(warning, ...rest);
24
+ });
25
+ /** The real installed version - dist/cli.mjs lives one level under the package root. */
26
+ function version() {
27
+ try {
28
+ return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
29
+ } catch {
30
+ return "0.0.0";
31
+ }
32
+ }
17
33
  const cli = cac(NAME);
18
- cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
19
- const { init } = await import("./init-DcOy1krf.mjs");
34
+ 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) => {
35
+ const { init } = await import("./init-CHUZTYG7.mjs");
20
36
  init(resolve(opts.root), {
21
37
  mode: opts.mode === "embedded" ? "embedded" : "studio",
22
38
  demo: opts.demo !== false
23
39
  });
24
40
  });
25
41
  cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
26
- const { dev } = await import("./dev-BK4x3PBr.mjs");
42
+ const { dev } = await import("./dev-Dt9D4O7Z.mjs");
27
43
  let port;
28
44
  if (opts.port !== void 0) {
29
45
  const n = Number(opts.port);
@@ -33,7 +49,7 @@ cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root",
33
49
  await dev(resolve(opts.root), port);
34
50
  });
35
51
  cli.command("build", "Static export → design/.dist").option("--boards <names>", "Publish only these boards (comma-separated); the frame filter is applied at build time").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
36
- const { buildSite } = await import("./build-D0GnIR4G.mjs");
52
+ const { buildSite } = await import("./build-BYZDMiIS.mjs");
37
53
  try {
38
54
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
39
55
  await buildSite(resolve(opts.root), boards);
@@ -43,7 +59,7 @@ cli.command("build", "Static export → design/.dist").option("--boards <names>"
43
59
  }
44
60
  });
45
61
  cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default $PORT or 4199)").action(async (opts) => {
46
- const { serve } = await import("./serve-BPNmWeJx.mjs");
62
+ const { serve } = await import("./serve-BvbAbWeK.mjs");
47
63
  let port;
48
64
  if (opts.port !== void 0) {
49
65
  const n = Number(opts.port);
@@ -52,7 +68,7 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
52
68
  serve(resolve(opts.root), port);
53
69
  });
54
70
  cli.help();
55
- cli.version("0.1.0");
71
+ cli.version(version());
56
72
  cli.parse();
57
73
  //#endregion
58
74
  export { PKG as n, ROUTE as r, NAME as t };
@@ -1,9 +1,9 @@
1
1
  import { n as PKG, t as NAME } from "./cli.mjs";
2
- import { n as loadConfig, r as detectHost } from "./config-DMBEpdEN.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DB5t2WUl.mjs";
2
+ import { a as loadConfig, o as detectHost } from "./manifest-CHmKAAtG.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-lVQoABEx.mjs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { createServer, searchForWorkspaceRoot } from "vite";
6
+ import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
7
7
  import react from "@vitejs/plugin-react";
8
8
  //#region src/server/dev.ts
9
9
  /** packageDir = the installed marver package root (dist/cli.js lives one level down). */
@@ -19,7 +19,7 @@ async function dev(root, portFlag) {
19
19
  if (host.tailwind === 4) {
20
20
  const tw = await tailwind4Plugin(root);
21
21
  if (tw) plugins.push(...tw);
22
- else console.warn("[marver] tailwindcss v4 detected but @tailwindcss/vite not found in the host - theme classes may be missing.");
22
+ else console.warn(`[marver] tailwindcss v4 detected but @tailwindcss/vite could not be loaded - utility classes WILL be missing from frames. Fix: npm i -D @tailwindcss/vite (used only by the canvas, never by your app's build).`);
23
23
  }
24
24
  let css;
25
25
  if (host.tailwind === 3) css = await tailwind3Css(root) ?? void 0;
@@ -29,10 +29,21 @@ async function dev(root, portFlag) {
29
29
  config,
30
30
  detectedThemeCss: host.themeCss
31
31
  }));
32
+ const logger = createLogger("info");
33
+ const noise = (msg) => /Sourcemap for .*node_modules.*points to/.test(msg);
34
+ const warnBase = logger.warn.bind(logger);
35
+ const warnOnceBase = logger.warnOnce.bind(logger);
36
+ logger.warn = (msg, opts) => {
37
+ if (!noise(msg)) warnBase(msg, opts);
38
+ };
39
+ logger.warnOnce = (msg, opts) => {
40
+ if (!noise(msg)) warnOnceBase(msg, opts);
41
+ };
32
42
  const server = await createServer({
33
43
  configFile: false,
34
44
  root,
35
45
  css,
46
+ customLogger: logger,
36
47
  plugins,
37
48
  server: {
38
49
  port: portFlag ?? config.port,
@@ -46,7 +57,15 @@ async function dev(root, portFlag) {
46
57
  "**/design/manifest.json",
47
58
  "**/design/boards/**",
48
59
  "**/design/.local/**",
49
- "**/design/.dist/**"
60
+ "**/design/.dist/**",
61
+ "**/.next/**",
62
+ "**/.turbo/**",
63
+ "**/.vercel/**",
64
+ "**/.output/**",
65
+ "**/dist/**",
66
+ "**/build/**",
67
+ "**/out/**",
68
+ "**/coverage/**"
50
69
  ] }
51
70
  },
52
71
  resolve: {
@@ -1,7 +1,7 @@
1
1
  import { t as NAME } from "./cli.mjs";
2
- import { r as detectHost, t as DEFAULTS } from "./config-DMBEpdEN.mjs";
3
- import { dirname, join, relative } from "node:path";
2
+ import { i as DEFAULTS, n as scanFrames, o as detectHost, r as writeManifest } from "./manifest-CHmKAAtG.mjs";
4
3
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  //#region src/cli/init.ts
7
7
  const pkgDir = () => join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -22,10 +22,19 @@ function init(root, opts) {
22
22
  if (host.themeCss) {
23
23
  const relCss = relative(design, join(root, host.themeCss)).split("\\").join("/");
24
24
  write("theme.css", themeWrapper(relCss, host.tailwind === 4));
25
- } else console.warn(`[${NAME}] no theme CSS detected - set \`theme\` in design/config.ts when you have one.`);
25
+ } else console.warn(`[${NAME}] no theme CSS detected - create design/theme.css importing your app's stylesheet when you have one (or set \`theme\` in design/config.ts).`);
26
26
  write("providers.tsx", providersTemplate(host.router, host.toaster, host.routerPkg));
27
- write("AGENTS.md", readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_ALIAS}}", host.shadcn?.uiAlias ?? "@/components/ui"));
28
- write("tsconfig.json", readFileSync(join(templates, "design-tsconfig.json"), "utf8"));
27
+ const agents = AGENTS_MARKER + "\n" + readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_GUIDANCE}}", uiGuidance(host)).replace(/\{\{NEXT_NOTES\}\}\n?/, host.router === "next" ? NEXT_NOTES : "");
28
+ const agentsPath = join(design, "AGENTS.md");
29
+ if (!existsSync(agentsPath)) write("AGENTS.md", agents);
30
+ else {
31
+ const current = readFileSync(agentsPath, "utf8");
32
+ if (current.startsWith(AGENTS_MARKER) && current !== agents) {
33
+ writeFileSync(agentsPath, agents);
34
+ created.push("design/AGENTS.md (regenerated - detected stack changed)");
35
+ }
36
+ }
37
+ write("tsconfig.json", existsSync(join(root, "tsconfig.json")) ? readFileSync(join(templates, "design-tsconfig.json"), "utf8") : STANDALONE_TSCONFIG);
29
38
  write(".gitignore", ".local/\n.dist/\n");
30
39
  write("scenes/_layout.tsx", readFileSync(join(templates, "root-layout.tsx"), "utf8"));
31
40
  if (!existsSync(join(design, "boards"))) {
@@ -38,14 +47,61 @@ function init(root, opts) {
38
47
  created.push("design/scenes/demo/ (3 frames)");
39
48
  }
40
49
  if (host.tsconfigSweepsDesign) patchTsconfigExclude(root);
50
+ writeManifest(root, scanFrames(root));
41
51
  console.log(`\n${NAME} initialized (${opts.mode} mode). Created:`);
42
52
  for (const f of created) console.log(` + ${f}`);
43
- if (host.router === "next") console.log(`\n note: Next.js support is partial until M3 - HTML frames and next-free components work today.`);
53
+ if (host.router === "next") console.log(`\n note: Next.js support is partial - frames render outside Next, so next/font, next/image and Server Components do not exist inside them (details in design/AGENTS.md).`);
54
+ if (noApp(host)) console.warn(`
55
+ ┌─ NO APP DETECTED ─────────────────────────────────────────────────────┐
56
+ │ This repo has no framework, no theme CSS, and no component library. │
57
+ │ ${NAME} builds frames from YOUR components - with none, frames become │
58
+ │ hand-rolled CSS that cannot be promoted into an app later. │
59
+ │ │
60
+ │ Set up the app first, then re-run init (it is idempotent and will │
61
+ │ fill in what it detects). For a web app or site: │
62
+ │ npx create-next-app@latest . --ts --tailwind --app --src-dir │
63
+ │ npx shadcn@latest init │
64
+ │ │
65
+ │ design/AGENTS.md was generated with a STOP instruction so your agent │
66
+ │ does not design against a component library that does not exist. │
67
+ └───────────────────────────────────────────────────────────────────────┘`);
44
68
  console.log(`\n commit design/ - only .local/ is ignored`);
45
69
  console.log(` uninstall = delete design/, remove the ${NAME} dependency${host.tsconfigSweepsDesign ? ", revert the \"design\" line in tsconfig exclude" : ""}`);
46
70
  console.log(`\n next: npx ${NAME} dev (canvas on http://localhost:${DEFAULTS.port} by default)\n`);
47
- console.log(` then, to your agent: "Read design/AGENTS.md. Build an onboarding scene - welcome, form, done - mobile-first, using our components."\n`);
71
+ if (!noApp(host)) console.log(` then, to your agent: "Read design/AGENTS.md. Build an onboarding scene - welcome, form, done - mobile-first, using our components."\n`);
72
+ }
73
+ const AGENTS_MARKER = "<!-- generated by marver init from the detected stack; re-running init regenerates this file when detection changes. Made edits you want to keep? Delete this line and init will never touch the file again. -->";
74
+ /** No framework, no theme, no component alias = nothing to build frames FROM. */
75
+ const noApp = (host) => !host.router && !host.tailwind && !host.shadcn && !host.themeCss;
76
+ /** The UI line of AGENTS.md, matched to what detection actually found (friction log #1). */
77
+ function uiGuidance(host) {
78
+ if (host.shadcn) return `Use the app's UI: import from ${host.shadcn.uiAlias}; style with the app's Tailwind classes.`;
79
+ if (host.tailwind) return `Style with the app's Tailwind classes and design tokens; there is no detected component library - extract shared pieces into design/components/.`;
80
+ return `STOP - this repo has no component library, no Tailwind, and no theme. Frames built from hand-rolled CSS cannot be promoted into an app later. Ask the human to set up the app first (framework + styling), then re-run \`npx ${NAME} init\` so this contract regenerates against the real stack.`;
48
81
  }
82
+ /** Next.js frames render OUTSIDE Next - say concretely what that means (friction log #10/#11). */
83
+ const NEXT_NOTES = `- Next.js caveats (frames render in Vite, outside Next):
84
+ next/font does not exist here - CSS variables it injects (e.g. --font-geist-sans) are
85
+ undefined in frames, so give every font token a real fallback chain in the app's CSS:
86
+ --font-sans: var(--font-geist-sans, ui-sans-serif, system-ui, sans-serif).
87
+ next/image and next/link render as plain img/a via shims at best - prefer <img> and
88
+ data-goto in frames. Server Components and server actions cannot run: frames are
89
+ client components importing client components.
90
+ `;
91
+ const STANDALONE_TSCONFIG = `{
92
+ "compilerOptions": {
93
+ "target": "ES2022",
94
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
95
+ "module": "ESNext",
96
+ "moduleResolution": "bundler",
97
+ "jsx": "react-jsx",
98
+ "strict": true,
99
+ "skipLibCheck": true,
100
+ "noEmit": true
101
+ },
102
+ "include": ["."]
103
+ }
104
+ `;
49
105
  function patchTsconfigExclude(root) {
50
106
  const file = join(root, "tsconfig.json");
51
107
  if (!existsSync(file)) return;
@@ -0,0 +1,298 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { createHash } from "node:crypto";
5
+ //#region src/server/detect.ts
6
+ function readJson(file) {
7
+ try {
8
+ return JSON.parse(stripJsonComments(readFileSync(file, "utf8")));
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+ function stripJsonComments(s) {
14
+ let out = "", inStr = false, inLine = false, inBlock = false;
15
+ for (let i = 0; i < s.length; i++) {
16
+ const c = s[i], n = s[i + 1];
17
+ if (inLine) {
18
+ if (c === "\n") {
19
+ inLine = false;
20
+ out += c;
21
+ }
22
+ continue;
23
+ }
24
+ if (inBlock) {
25
+ if (c === "*" && n === "/") {
26
+ inBlock = false;
27
+ i++;
28
+ }
29
+ continue;
30
+ }
31
+ if (inStr) {
32
+ out += c;
33
+ if (c === "\\") {
34
+ out += n ?? "";
35
+ i++;
36
+ } else if (c === "\"") inStr = false;
37
+ continue;
38
+ }
39
+ if (c === "\"") {
40
+ inStr = true;
41
+ out += c;
42
+ continue;
43
+ }
44
+ if (c === "/" && n === "/") {
45
+ inLine = true;
46
+ i++;
47
+ continue;
48
+ }
49
+ if (c === "/" && n === "*") {
50
+ inBlock = true;
51
+ i++;
52
+ continue;
53
+ }
54
+ out += c;
55
+ }
56
+ let res = "", inS = false;
57
+ for (let i = 0; i < out.length; i++) {
58
+ const c = out[i];
59
+ if (inS) {
60
+ res += c;
61
+ if (c === "\\") {
62
+ res += out[i + 1] ?? "";
63
+ i++;
64
+ } else if (c === "\"") inS = false;
65
+ continue;
66
+ }
67
+ if (c === "\"") {
68
+ inS = true;
69
+ res += c;
70
+ continue;
71
+ }
72
+ if (c === ",") {
73
+ let j = i + 1;
74
+ while (j < out.length && /\s/.test(out[j])) j++;
75
+ if (out[j] === "}" || out[j] === "]") continue;
76
+ }
77
+ res += c;
78
+ }
79
+ return res;
80
+ }
81
+ function detectHost(root) {
82
+ const pkg = readJson(join(root, "package.json")) ?? {};
83
+ const deps = {
84
+ ...pkg.dependencies,
85
+ ...pkg.devDependencies
86
+ };
87
+ const twRange = deps["tailwindcss"];
88
+ const tailwind = !twRange ? null : /(^|[^\d])4\./.test(twRange) || twRange.startsWith("^4") || twRange.startsWith("~4") ? 4 : 3;
89
+ const router = deps["next"] ? "next" : deps["react-router"] || deps["react-router-dom"] ? "react-router" : null;
90
+ const routerPkg = deps["react-router-dom"] ? "react-router-dom" : "react-router";
91
+ const toaster = deps["sonner"] ? "sonner" : deps["react-hot-toast"] ? "react-hot-toast" : null;
92
+ let shadcn = null;
93
+ const comp = readJson(join(root, "components.json"));
94
+ if (comp?.tailwind?.css) shadcn = {
95
+ themeCss: comp.tailwind.css,
96
+ uiAlias: comp.aliases?.ui ?? "@/components/ui"
97
+ };
98
+ const themeCss = shadcn?.themeCss && existsSync(join(root, shadcn.themeCss)) ? shadcn.themeCss : firstExisting(root, [
99
+ "src/index.css",
100
+ "src/styles/theme.css",
101
+ "src/styles/globals.css",
102
+ "src/app/globals.css",
103
+ "app/globals.css",
104
+ "src/globals.css",
105
+ "src/style.css",
106
+ "styles/globals.css"
107
+ ]);
108
+ const ts = readJson(join(root, "tsconfig.json"));
109
+ const include = ts?.include;
110
+ const exclude = ts?.exclude ?? [];
111
+ const sweeps = ts != null && !exclude.some((e) => e === "design" || e.startsWith("design/")) && (!include || include.some((i) => i === "." || i.startsWith("**") || i.startsWith("design")));
112
+ return {
113
+ tailwind,
114
+ router,
115
+ routerPkg,
116
+ toaster,
117
+ shadcn,
118
+ themeCss,
119
+ tsconfigSweepsDesign: !!sweeps
120
+ };
121
+ }
122
+ function firstExisting(root, candidates) {
123
+ for (const c of candidates) if (existsSync(join(root, c))) return c;
124
+ return null;
125
+ }
126
+ //#endregion
127
+ //#region src/server/config.ts
128
+ const DEFAULTS = {
129
+ mode: "studio",
130
+ theme: null,
131
+ viewports: {
132
+ mobile: {
133
+ width: 390,
134
+ height: 844
135
+ },
136
+ tablet: {
137
+ width: 768,
138
+ height: 1024
139
+ },
140
+ laptop: {
141
+ width: 1280,
142
+ height: 800
143
+ },
144
+ monitor: {
145
+ width: 1920,
146
+ height: 1080
147
+ }
148
+ },
149
+ themes: ["light", "dark"],
150
+ port: 5199,
151
+ zoomSpeed: 1,
152
+ share: { branding: true }
153
+ };
154
+ /** Load design/config.ts via native TS import (Node >= 22.18). Missing or broken fields fall back to defaults. */
155
+ async function loadConfig(root) {
156
+ const file = join(root, "design", "config.ts");
157
+ if (!existsSync(file)) return { ...DEFAULTS };
158
+ try {
159
+ const user = (await import(`${pathToFileURL(file).href}?t=${Date.now()}`)).default ?? {};
160
+ return {
161
+ ...DEFAULTS,
162
+ ...user,
163
+ viewports: validViewports(user.viewports) ?? DEFAULTS.viewports,
164
+ themes: Array.isArray(user.themes) && user.themes.length ? user.themes.map(String) : DEFAULTS.themes,
165
+ port: validPort(user.port) ?? DEFAULTS.port,
166
+ zoomSpeed: validZoom(user.zoomSpeed) ?? DEFAULTS.zoomSpeed,
167
+ share: {
168
+ branding: user.share?.branding !== false,
169
+ name: typeof user.share?.name === "string" ? user.share.name : void 0,
170
+ logo: typeof user.share?.logo === "string" ? user.share.logo : void 0
171
+ }
172
+ };
173
+ } catch (err) {
174
+ console.error(`[marver] design/config.ts failed to load, using defaults:\n ${err.message}`);
175
+ return { ...DEFAULTS };
176
+ }
177
+ }
178
+ const validDim = (n) => typeof n === "number" && Number.isFinite(n) && n >= 1 && n <= 2e4;
179
+ function validPort(n) {
180
+ return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
181
+ }
182
+ function validZoom(n) {
183
+ return typeof n === "number" && Number.isFinite(n) && n >= .1 && n <= 10 ? n : null;
184
+ }
185
+ function validViewports(v) {
186
+ if (!v || typeof v !== "object") return null;
187
+ const out = {};
188
+ for (const [k, val] of Object.entries(v)) {
189
+ const vp = val;
190
+ if (validDim(vp?.width) && validDim(vp?.height)) out[k] = {
191
+ width: Math.round(vp.width),
192
+ height: Math.round(vp.height)
193
+ };
194
+ }
195
+ return Object.keys(out).length ? out : null;
196
+ }
197
+ //#endregion
198
+ //#region src/server/manifest.ts
199
+ const FRAME_EXT = /\.(tsx|jsx|html)$/;
200
+ const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
201
+ /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted (spec §6). */
202
+ function extractMeta(src) {
203
+ const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
204
+ if (!m) return {};
205
+ const body = m[1];
206
+ const pick = (key) => {
207
+ const r = new RegExp(`${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
208
+ return r ? r[2] : void 0;
209
+ };
210
+ const out = {};
211
+ const title = pick("title");
212
+ if (title) out.title = title;
213
+ const viewport = pick("viewport");
214
+ if (viewport) out.viewport = viewport;
215
+ const theme = pick("theme");
216
+ if (theme) out.theme = theme;
217
+ return out;
218
+ }
219
+ /** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
220
+ function toFrameId(designRelPath) {
221
+ const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
222
+ return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
223
+ }
224
+ function walk(dir, out = []) {
225
+ if (!existsSync(dir)) return out;
226
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
227
+ if (e.name.startsWith(".")) continue;
228
+ const p = join(dir, e.name);
229
+ if (e.isDirectory()) walk(p, out);
230
+ else out.push(p);
231
+ }
232
+ return out;
233
+ }
234
+ function scanFrames(root) {
235
+ const design = join(root, "design");
236
+ const frames = [];
237
+ for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
238
+ const name = abs.split(sep).pop();
239
+ if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
240
+ const rel = relative(design, abs).split(sep).join("/");
241
+ const id = toFrameId(rel);
242
+ const scene = id.includes("/") ? id.split("/")[0] : "";
243
+ if (base === "scenes" && RESERVED_SCENES.has(scene)) {
244
+ console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
245
+ continue;
246
+ }
247
+ const kind = name.endsWith(".html") ? "html" : "tsx";
248
+ const entry = {
249
+ id,
250
+ file: `design/${rel}`,
251
+ kind,
252
+ scene
253
+ };
254
+ if (kind === "tsx") {
255
+ const meta = extractMeta(readFileSync(abs, "utf8"));
256
+ if (meta.title) entry.title = meta.title;
257
+ if (meta.viewport) entry.viewport = meta.viewport;
258
+ if (meta.theme) entry.theme = meta.theme;
259
+ }
260
+ frames.push(entry);
261
+ }
262
+ const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
263
+ frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
264
+ const seen = /* @__PURE__ */ new Map();
265
+ const deduped = frames.filter((f) => {
266
+ const prev = seen.get(f.id);
267
+ if (prev) {
268
+ console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
269
+ return false;
270
+ }
271
+ seen.set(f.id, f.file);
272
+ return true;
273
+ });
274
+ frames.length = 0;
275
+ frames.push(...deduped);
276
+ const sceneCounts = /* @__PURE__ */ new Map();
277
+ for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
278
+ return {
279
+ frames,
280
+ scenes: [...sceneCounts.entries()].map(([name, n]) => ({
281
+ name,
282
+ frames: n
283
+ })).sort((a, b) => a.name.localeCompare(b.name))
284
+ };
285
+ }
286
+ /** Write design/manifest.json only when content changed. Returns the manifest either way. */
287
+ function writeManifest(root, manifest) {
288
+ const file = join(root, "design", "manifest.json");
289
+ const next = JSON.stringify(manifest, null, 2) + "\n";
290
+ const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
291
+ if (hash(prev) === hash(next)) return false;
292
+ mkdirSync(join(root, "design"), { recursive: true });
293
+ writeFileSync(file, next);
294
+ return true;
295
+ }
296
+ const hash = (s) => createHash("sha256").update(s).digest("hex");
297
+ //#endregion
298
+ export { loadConfig as a, DEFAULTS as i, scanFrames as n, detectHost as o, writeManifest as r, hash as t };
@@ -1,107 +1,8 @@
1
1
  import { r as ROUTE } from "./cli.mjs";
2
- import { join, relative, resolve, sep } from "node:path";
2
+ import { n as scanFrames, r as writeManifest, t as hash } from "./manifest-CHmKAAtG.mjs";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
4
- import { createHash, randomBytes } from "node:crypto";
5
- //#region src/server/manifest.ts
6
- const FRAME_EXT = /\.(tsx|jsx|html)$/;
7
- const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
8
- /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted (spec §6). */
9
- function extractMeta(src) {
10
- const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
11
- if (!m) return {};
12
- const body = m[1];
13
- const pick = (key) => {
14
- const r = new RegExp(`${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
15
- return r ? r[2] : void 0;
16
- };
17
- const out = {};
18
- const title = pick("title");
19
- if (title) out.title = title;
20
- const viewport = pick("viewport");
21
- if (viewport) out.viewport = viewport;
22
- const theme = pick("theme");
23
- if (theme) out.theme = theme;
24
- return out;
25
- }
26
- /** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
27
- function toFrameId(designRelPath) {
28
- const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
29
- return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
30
- }
31
- function walk(dir, out = []) {
32
- if (!existsSync(dir)) return out;
33
- for (const e of readdirSync(dir, { withFileTypes: true })) {
34
- if (e.name.startsWith(".")) continue;
35
- const p = join(dir, e.name);
36
- if (e.isDirectory()) walk(p, out);
37
- else out.push(p);
38
- }
39
- return out;
40
- }
41
- function scanFrames(root) {
42
- const design = join(root, "design");
43
- const frames = [];
44
- for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
45
- const name = abs.split(sep).pop();
46
- if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
47
- const rel = relative(design, abs).split(sep).join("/");
48
- const id = toFrameId(rel);
49
- const scene = id.includes("/") ? id.split("/")[0] : "";
50
- if (base === "scenes" && RESERVED_SCENES.has(scene)) {
51
- console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
52
- continue;
53
- }
54
- const kind = name.endsWith(".html") ? "html" : "tsx";
55
- const entry = {
56
- id,
57
- file: `design/${rel}`,
58
- kind,
59
- scene
60
- };
61
- if (kind === "tsx") {
62
- const meta = extractMeta(readFileSync(abs, "utf8"));
63
- if (meta.title) entry.title = meta.title;
64
- if (meta.viewport) entry.viewport = meta.viewport;
65
- if (meta.theme) entry.theme = meta.theme;
66
- }
67
- frames.push(entry);
68
- }
69
- const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
70
- frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
71
- const seen = /* @__PURE__ */ new Map();
72
- const deduped = frames.filter((f) => {
73
- const prev = seen.get(f.id);
74
- if (prev) {
75
- console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
76
- return false;
77
- }
78
- seen.set(f.id, f.file);
79
- return true;
80
- });
81
- frames.length = 0;
82
- frames.push(...deduped);
83
- const sceneCounts = /* @__PURE__ */ new Map();
84
- for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
85
- return {
86
- frames,
87
- scenes: [...sceneCounts.entries()].map(([name, n]) => ({
88
- name,
89
- frames: n
90
- })).sort((a, b) => a.name.localeCompare(b.name))
91
- };
92
- }
93
- /** Write design/manifest.json only when content changed. Returns the manifest either way. */
94
- function writeManifest(root, manifest) {
95
- const file = join(root, "design", "manifest.json");
96
- const next = JSON.stringify(manifest, null, 2) + "\n";
97
- const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
98
- if (hash(prev) === hash(next)) return false;
99
- mkdirSync(join(root, "design"), { recursive: true });
100
- writeFileSync(file, next);
101
- return true;
102
- }
103
- const hash = (s) => createHash("sha256").update(s).digest("hex");
104
- //#endregion
4
+ import { join, resolve, sep } from "node:path";
5
+ import { randomBytes } from "node:crypto";
105
6
  //#region src/server/api.ts
106
7
  const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
107
8
  const BODY_LIMIT = 1e6;
@@ -188,7 +89,7 @@ function apiMiddleware(root) {
188
89
  const p = boardPath(boardMatch[1]);
189
90
  if (!p) return json(res, 400, { error: "invalid board name" });
190
91
  if (req.method === "GET") {
191
- if (!existsSync(p)) return json(res, 404, { error: "not found" });
92
+ if (!existsSync(p)) return json(res, 200, { board: null });
192
93
  const content = readFileSync(p, "utf8");
193
94
  try {
194
95
  return json(res, 200, {
@@ -275,6 +176,7 @@ function routesMiddleware(server, clientDir) {
275
176
  try {
276
177
  const html = await server.transformIndexHtml(req.url ?? "/", page(dir));
277
178
  res.setHeader("content-type", "text/html");
179
+ res.setHeader("cache-control", "no-store");
278
180
  res.end(html);
279
181
  } catch (err) {
280
182
  res.statusCode = 500;
@@ -306,7 +208,7 @@ function marverPlugin(ctx) {
306
208
  },
307
209
  load(id) {
308
210
  if (id === "\0virtual:sh-theme.css") {
309
- console.warn("[marver] no theme detected - frames render unstyled. Set `theme` in design/config.ts.");
211
+ console.warn("[marver] no theme detected - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set `theme` in design/config.ts).");
310
212
  return "/* marver: no theme configured */";
311
213
  }
312
214
  if (id === "\0virtual:sh-config") return `export default ${JSON.stringify({
@@ -354,6 +256,13 @@ function marverPlugin(ctx) {
354
256
  if (hctx.modules.length === 0) return [];
355
257
  },
356
258
  configureServer(server) {
259
+ server.middlewares.use((req, res, next) => {
260
+ if (req.url && req.url.includes("/frame-host/registry.ts")) {
261
+ const orig = res.setHeader.bind(res);
262
+ res.setHeader = ((name, value) => orig(name, String(name).toLowerCase() === "cache-control" ? "no-cache" : value));
263
+ }
264
+ next();
265
+ });
357
266
  server.middlewares.use(apiMiddleware(root));
358
267
  server.middlewares.use(routesMiddleware(server, clientDir));
359
268
  const regen = debounce(() => {
@@ -362,8 +271,39 @@ function marverPlugin(ctx) {
362
271
  }, 150);
363
272
  const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
364
273
  const inScope = (file) => watched.some((w) => file.startsWith(w));
365
- server.watcher.on("add", (f) => inScope(f) && regen());
366
- server.watcher.on("unlink", (f) => inScope(f) && regen());
274
+ const themeModules = () => {
275
+ const f = themeFile();
276
+ if (!f) return [];
277
+ const out = [];
278
+ const seen = /* @__PURE__ */ new Set();
279
+ const walk = (mod) => {
280
+ if (!mod || seen.has(mod)) return;
281
+ seen.add(mod);
282
+ out.push(mod);
283
+ for (const im of mod.clientImportedModules ?? mod.importedModules ?? []) if (String(im.id ?? "").split("?")[0].endsWith(".css")) walk(im);
284
+ };
285
+ for (const mod of server.moduleGraph.getModulesByFile(f) ?? []) walk(mod);
286
+ return out;
287
+ };
288
+ const pushTheme = debounce(() => {
289
+ for (const mod of themeModules()) server.reloadModule(mod).catch(() => {});
290
+ }, 150);
291
+ const rescanTheme = () => {
292
+ for (const mod of themeModules()) server.moduleGraph.invalidateModule(mod);
293
+ pushTheme();
294
+ };
295
+ server.watcher.on("add", (f) => {
296
+ if (inScope(f)) {
297
+ regen();
298
+ rescanTheme();
299
+ }
300
+ });
301
+ server.watcher.on("unlink", (f) => {
302
+ if (inScope(f)) {
303
+ regen();
304
+ rescanTheme();
305
+ }
306
+ });
367
307
  server.watcher.on("change", (f) => inScope(f) && /\.(tsx|jsx)$/.test(f) && regen());
368
308
  writeManifest(root, scanFrames(root));
369
309
  const boardsDir = join(root, "design", "boards");
@@ -397,15 +337,18 @@ function debounce(fn, ms) {
397
337
  t = setTimeout(() => fn(...a), ms);
398
338
  });
399
339
  }
400
- /** Extra vite plugin when the host runs Tailwind v4: use their own @tailwindcss/vite. */
340
+ /** Extra vite plugin when the host runs Tailwind v4. The host's own @tailwindcss/vite
341
+ * wins (version-matched to their tailwindcss); the fallback is marver's bundled copy -
342
+ * Next.js hosts use @tailwindcss/postcss and never have the vite plugin (the blessed
343
+ * stack would otherwise render every frame without its utility classes). */
401
344
  async function tailwind4Plugin(root) {
402
- try {
403
- const mod = await import(join(root, "node_modules", "@tailwindcss", "vite", "dist", "index.mjs"));
345
+ const factories = [() => import(join(root, "node_modules", "@tailwindcss", "vite", "dist", "index.mjs")), () => import("@tailwindcss/vite")];
346
+ for (const load of factories) try {
347
+ const mod = await load();
404
348
  const result = (mod.default ?? mod)();
405
349
  return Array.isArray(result) ? result : [result];
406
- } catch {
407
- return null;
408
- }
350
+ } catch {}
351
+ return null;
409
352
  }
410
353
  /** Tailwind v3: inline PostCSS config extending the host's tailwind config with design/ globs. Host files untouched. */
411
354
  async function tailwind3Css(root) {
@@ -434,4 +377,4 @@ async function tailwind3Css(root) {
434
377
  return null;
435
378
  }
436
379
  //#endregion
437
- export { scanFrames as i, tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
380
+ export { tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
@@ -1,6 +1,6 @@
1
1
  import { t as NAME } from "./cli.mjs";
2
- import { extname, isAbsolute, join, relative, resolve } from "node:path";
3
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
5
  import { createServer } from "node:http";
6
6
  //#region src/server/serve.ts
@@ -115,6 +115,13 @@ function serve(root, portFlag) {
115
115
  }
116
116
  });
117
117
  const port = portFlag ?? (Number(process.env.PORT) || 4199);
118
+ server.on("error", (err) => {
119
+ if (err.code === "EADDRINUSE") {
120
+ console.error(`[${NAME}] port ${port} is already in use - another \`${NAME} serve\` still running? Stop it, or pass --port <n>.`);
121
+ process.exit(1);
122
+ }
123
+ throw err;
124
+ });
118
125
  server.listen(port, () => {
119
126
  console.log(`\n ${NAME} serving design/.dist → http://localhost:${port}/`);
120
127
  console.log(verifier ? " gate: ON (MARVER_PASSWORD set)\n" : " gate: off - set MARVER_PASSWORD to require a password\n");
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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. The tool ships no AI - your coding agent is the designer.",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "license": "Apache-2.0",
8
8
  "bin": {
9
- "marver": "./dist/cli.mjs"
9
+ "marver": "dist/cli.mjs"
10
10
  },
11
11
  "files": [
12
12
  "dist",
@@ -27,6 +27,7 @@
27
27
  "typecheck": "tsc --noEmit"
28
28
  },
29
29
  "dependencies": {
30
+ "@tailwindcss/vite": "^4.0.0",
30
31
  "@vitejs/plugin-react": "^6.0.0",
31
32
  "cac": "^7.0.0",
32
33
  "react-zoom-pan-pinch": "^3.6.1",
@@ -56,4 +57,4 @@
56
57
  "publishConfig": {
57
58
  "access": "public"
58
59
  }
59
- }
60
+ }
@@ -4,9 +4,15 @@ const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
4
4
  const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
5
5
  const id = new URLSearchParams(location.search).get('id') ?? location.pathname
6
6
 
7
+ // theme lands as BOTH signals: [data-theme] plus the `dark` class Tailwind/shadcn key on
8
+ const setTheme = (theme) => {
9
+ document.documentElement.dataset.theme = theme
10
+ document.documentElement.classList.toggle('dark', theme === 'dark')
11
+ }
12
+
7
13
  if (isHtmlFrame) {
8
14
  const theme = new URLSearchParams(location.search).get('theme')
9
- if (theme) document.documentElement.dataset.theme = theme
15
+ if (theme) setTheme(theme)
10
16
  }
11
17
 
12
18
  document.addEventListener('click', (e) => {
@@ -23,7 +29,7 @@ window.addEventListener('error', (e) => post({ type: 'sh:error', id, message: St
23
29
  window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:error', id, message: `unhandled rejection: ${e.reason}` }))
24
30
 
25
31
  window.addEventListener('message', (e) => {
26
- if (e?.data?.type === 'sh:set-theme') document.documentElement.dataset.theme = e.data.theme
32
+ if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
27
33
  })
28
34
 
29
35
  if (isHtmlFrame) {
@@ -12,7 +12,11 @@ import { frameFile, frames, layoutChain, layouts, providers } from './registry.t
12
12
  const params = new URLSearchParams(location.search)
13
13
  const id = params.get('id') ?? ''
14
14
  const theme = params.get('theme') ?? 'light'
15
+ // Both signals, always: [data-theme] for token systems keyed on the attribute, and the
16
+ // `dark` class for Tailwind/shadcn (`@custom-variant dark (&:is(.dark *))` never sees a
17
+ // data attribute). The bridge applies the same pair on sh:set-theme.
15
18
  document.documentElement.dataset.theme = theme
19
+ document.documentElement.classList.toggle('dark', theme === 'dark')
16
20
 
17
21
  const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
18
22
 
@@ -47,7 +51,9 @@ async function boot() {
47
51
  await import('virtual:sh-theme' as string)
48
52
 
49
53
  const fileKey = frameFile(id)
50
- if (!fileKey) return fail(`unknown frame id "${id}"`)
54
+ // Honest copy: the id usually IS valid on disk - this document's frame registry is
55
+ // what's stale (file just added/renamed, or the dev server restarted). See #20.
56
+ 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.`)
51
57
 
52
58
  const frameMod: any = await frames[fileKey]()
53
59
  const Frame = frameMod.default
@@ -115,6 +115,18 @@ function SelectionBar() {
115
115
  const node = useStore((s) => s.nodes.find((n) => n.key === s.selection[s.selection.length - 1]))
116
116
  const frame = useStore((s) => (node ? s.frameFor(node) : undefined))
117
117
  const nodes = useStore((s) => s.nodes)
118
+ // measured width feeds the viewport clamp below; a callback ref because the bar
119
+ // mounts/unmounts with the selection (an effect with [] would miss remounts)
120
+ const [barW, setBarW] = useState(0)
121
+ const roRef = useRef<ResizeObserver | null>(null)
122
+ const barRef = (el: HTMLDivElement | null) => {
123
+ roRef.current?.disconnect()
124
+ roRef.current = null
125
+ if (el) {
126
+ roRef.current = new ResizeObserver(() => setBarW(el.offsetWidth))
127
+ roRef.current.observe(el)
128
+ }
129
+ }
118
130
  if (!node || !frame || node.missing) return null
119
131
  // anchor: centered over the bounding box of ALL selected frames, above the topmost
120
132
  const selNodes = nodes.filter((n) => selection.includes(n.key))
@@ -135,14 +147,21 @@ function SelectionBar() {
135
147
  .map((k) => { const n = st.nodes.find((x) => x.key === k); return n ? st.frameFor(n) : undefined })
136
148
  .filter((f): f is NonNullable<typeof f> => !!f)
137
149
  }
150
+ // centered over the selection's bounding box, then CLAMPED into the viewport: the
151
+ // controls for a selected frame must stay reachable when its top edge is panned
152
+ // off-screen, and must never drift off the sides (friction log #23)
153
+ const centerX = `calc(var(--sh-tx, 0px) + var(--sh-s, 1) * ${(bx0 + bx1) / 2}px)`
154
+ const rawTop = `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - 52px)`
138
155
  return (
139
156
  <div
140
157
  className="sh-ctx"
158
+ ref={barRef}
141
159
  style={{
142
- // centered over the selection's bounding box; translateX keeps it centered at any width
143
- left: `calc(var(--sh-tx, 0px) + var(--sh-s, 1) * ${(bx0 + bx1) / 2}px)`,
144
- top: `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - 52px)`,
145
- transform: 'translateX(-50%)',
160
+ left: barW
161
+ ? `clamp(8px, calc(${centerX} - ${Math.round(barW / 2)}px), calc(100vw - ${barW + 8}px))`
162
+ : centerX,
163
+ top: `clamp(8px, ${rawTop}, calc(100vh - 52px))`,
164
+ transform: barW ? undefined : 'translateX(-50%)',
146
165
  }}
147
166
  >
148
167
  {multi && <>
@@ -623,7 +642,7 @@ export function App() {
623
642
 
624
643
  <PlayOverlay />
625
644
 
626
- {CONFIG.noTheme && <div className="sh-banner">no theme configured - frames render unstyled (design/config.ts → theme)</div>}
645
+ {CONFIG.noTheme && <div className="sh-banner">no theme configured - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set theme in design/config.ts)</div>}
627
646
 
628
647
  <div className="sh-toasts">
629
648
  {toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
@@ -47,6 +47,15 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
47
47
  fileRef.current = sig
48
48
  }, [frame?.kind, frame?.file])
49
49
 
50
+ // shell-requested renavigation (store bumps node.nav): reload on a FRESH rev-stamped
51
+ // URL - the errored document's own URL may be poisoned by cache (friction log #20)
52
+ const navRef = useRef(node.nav ?? 0)
53
+ useEffect(() => {
54
+ if ((node.nav ?? 0) === navRef.current) return
55
+ navRef.current = node.nav ?? 0
56
+ if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
57
+ }, [node.nav])
58
+
50
59
  // ready timeout (spec §7): 10s without sh:ready -> error card with reload
51
60
  useEffect(() => {
52
61
  if (node.status !== 'loading') return
@@ -17,6 +17,9 @@ export interface Node {
17
17
  key: string; frame: string; x: number; y: number; w: number; h: number
18
18
  /** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
19
19
  theme: string
20
+ /** Renavigation nonce: bumped when the shell wants this iframe on a FRESH URL
21
+ * (errored frame whose file is back in the manifest). Never persisted. */
22
+ nav?: number
20
23
  /** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
21
24
  * The only theme value that persists into the board file. */
22
25
  themeUser?: string
@@ -42,10 +45,16 @@ const HEADER = 28
42
45
  let toastSeq = 0
43
46
  const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
44
47
 
48
+ /** Manifest revision - bumps whenever a manifest lands (boot, switch, sh:manifest).
49
+ * Stamped into frame URLs so a changed frame set mints genuinely NEW iframe URLs:
50
+ * the browser can never revive a pre-change document from cache (friction log #20). */
51
+ let manifestRev = 0
52
+ export const bumpManifestRev = () => { manifestRev++ }
53
+
45
54
  export function frameUrl(frame: FrameEntry, theme: string): string {
46
55
  return frame.kind === 'html'
47
56
  ? `/${frame.file}?theme=${theme}`
48
- : `${ROUTE}/frame/?id=${encodeURIComponent(frame.id)}&theme=${theme}`
57
+ : `${ROUTE}/frame/?id=${encodeURIComponent(frame.id)}&theme=${theme}&r=${manifestRev}`
49
58
  }
50
59
 
51
60
  /** The global view theme: the user's sticky preference, applied across boards and
@@ -148,6 +157,7 @@ export const useStore = create<State>((set, get) => {
148
157
  raw = await mRes.json().catch(() => undefined)
149
158
  }
150
159
  if (raw === undefined || raw === null || typeof raw !== 'object') return null
160
+ bumpManifestRev() // fresh manifest → fresh iframe URLs
151
161
  const manifest: Manifest = {
152
162
  frames: (Array.isArray(raw?.frames) ? raw.frames : [])
153
163
  .filter((f: any) => f && typeof f.id === 'string' && typeof f.file === 'string'),
@@ -164,9 +174,12 @@ export const useStore = create<State>((set, get) => {
164
174
  if (DATA) loaded = DATA.boards[boardName] ? { board: DATA.boards[boardName], sha256: 'published' } : 'fresh'
165
175
  else {
166
176
  const res = await fetch(`${ROUTE}/api/boards/${boardName}`)
167
- if (res.ok) loaded = await res.json()
168
- else if (res.status === 404) loaded = 'fresh'
169
- else loaded = null // only 404 means "fresh board"; anything else must not commit an empty canvas
177
+ if (res.ok) {
178
+ const body = await res.json()
179
+ loaded = body?.board == null ? 'fresh' : body // board:null = not materialized yet
180
+ }
181
+ else if (res.status === 404) loaded = 'fresh' // older servers still 404 fresh boards
182
+ else loaded = null // anything else must not commit an empty canvas
170
183
  }
171
184
  if (loaded === null) return null
172
185
  if (loaded !== 'fresh') {
@@ -210,6 +223,16 @@ export const useStore = create<State>((set, get) => {
210
223
  if (typeof board?.deviceView === 'string' && CONFIG.viewports[board.deviceView]) deviceView = board.deviceView
211
224
  if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
212
225
  }
226
+ // auto-managed goes both ways (friction log #15): an auto board gains new frames
227
+ // AND sheds deleted ones. Tombstone cards are a curated-board concept (spec §7).
228
+ // Pruning at load DIRTIES the board (dev only) so the file on disk sheds the
229
+ // tombstones too - otherwise a recreated frame id resurrects its stale node.
230
+ let prunedAtLoad = false
231
+ if (boardAuto) {
232
+ const before = nodes.length
233
+ nodes = nodes.filter((n) => !n.missing)
234
+ prunedAtLoad = !DATA && nodes.length !== before
235
+ }
213
236
  // frames not on the board yet → auto boards only (a curated board shows exactly its list)
214
237
  if (boardAuto) {
215
238
  const placed = new Set(nodes.map((n) => n.frame))
@@ -234,8 +257,9 @@ export const useStore = create<State>((set, get) => {
234
257
  const placedAll = tidy(nodes.map((n) => ({ key: n.key, scene: sceneOf(n.frame), w: n.w, h: n.h + HEADER })))
235
258
  for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
236
259
  }
237
- // dirty: false - the committed state matches disk by construction
238
- return { manifest, nodes, boardHash, boardAuto, deviceView, baseLayout, selection: [], dirty: false }
260
+ // dirty matches disk by construction - except when load-time pruning changed the
261
+ // node set; callers see dirty:true and schedule the save that persists the prune
262
+ return { manifest, nodes, boardHash, boardAuto, deviceView, baseLayout, selection: [], dirty: prunedAtLoad }
239
263
  } catch { return null }
240
264
  }
241
265
 
@@ -255,6 +279,7 @@ export const useStore = create<State>((set, get) => {
255
279
  if (get().board !== boardName || editRev !== revAtStart) return false
256
280
  const live = get().manifest // a WS manifest update may have landed mid-fetch
257
281
  set(next)
282
+ if (next.dirty) scheduleSave() // load-time prune must reach the disk
258
283
  if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
259
284
  return true
260
285
  },
@@ -280,10 +305,12 @@ export const useStore = create<State>((set, get) => {
280
305
  ++loadSeq // invalidate any in-flight boot of the old board
281
306
  const live = get().manifest // a WS manifest update may have landed mid-load
282
307
  set({ board: name, interact: null, ...next })
308
+ if (next.dirty) scheduleSave() // load-time prune must reach the disk
283
309
  if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
284
310
  },
285
311
 
286
312
  applyManifest(m) {
313
+ bumpManifestRev() // frame set changed → new iframes get fresh URLs
287
314
  const { nodes, toast, boardAuto } = get()
288
315
  const known = new Set(nodes.map((n) => n.frame))
289
316
  const next = [...nodes]
@@ -313,8 +340,28 @@ export const useStore = create<State>((set, get) => {
313
340
  const f = m.frames.find((x) => x.id === n.frame)
314
341
  const want = n.themeUser ?? f?.theme ?? get().viewTheme
315
342
  if (n.theme !== want) { n.theme = want; retinted = true }
343
+ // an errored frame whose file IS in the fresh manifest gets one automatic retry
344
+ // on a rev-stamped URL - the "unknown frame id" dead end must self-heal (#20)
345
+ if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
316
346
  }
317
- set({ manifest: m, nodes: changed || retinted ? [...next] : next, ...(changed ? { dirty: true, baseLayout: nextBase } : {}) })
347
+ // auto boards prune deleted frames outright - "auto-managed" must manage both
348
+ // directions (friction log #15). Curated boards keep the explicit card (spec §7).
349
+ let final = next
350
+ if (boardAuto) {
351
+ final = next.filter((n) => !n.missing)
352
+ const dropped = next.length - final.length
353
+ if (dropped) {
354
+ changed = true
355
+ toast(dropped === 1 ? 'removed 1 deleted frame' : `removed ${dropped} deleted frames`)
356
+ }
357
+ }
358
+ set((s) => ({
359
+ manifest: m,
360
+ nodes: changed || retinted ? [...final] : final,
361
+ selection: s.selection.filter((k) => final.some((n) => n.key === k)),
362
+ interact: s.interact && final.some((n) => n.key === s.interact) ? s.interact : null,
363
+ ...(changed ? { dirty: true, baseLayout: nextBase } : {}),
364
+ }))
318
365
  if (changed) scheduleSave()
319
366
  },
320
367
 
@@ -11,7 +11,14 @@ Never run or talk to the canvas tool; read and write files only.
11
11
  // tv available commented-out). Pick the one the screen is designed for - the human can
12
12
  // flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
13
13
  - States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
14
- - Use the app's UI: import from {{UI_ALIAS}}; style with the app's Tailwind classes.
14
+ - VERSIONS are sibling frames too - the scene is the surface, each frame one direction:
15
+ design/scenes/landing/a-terminal.tsx, landing/b-editorial.tsx, landing/c-product.tsx.
16
+ Layout and the sidebar follow frame-id order, so variants named under one scene with
17
+ a-/b-/c- prefixes stay adjacent and ordered through tidy and every device view.
18
+ Never spread versions across scenes (terminal/landing, editorial/landing) - they
19
+ interleave with everything else and the comparison falls apart.
20
+ - {{UI_GUIDANCE}}
21
+ {{NEXT_NOTES}}
15
22
  - Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
16
23
  In play mode (the human presses P) frames swap in place inside one device - design flows
17
24
  as complete graphs: every screen a data-goto points at should itself link somewhere or be
@@ -34,7 +41,8 @@ Never run or talk to the canvas tool; read and write files only.
34
41
  setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
35
42
 
36
43
  ## Orientation
37
- - design/manifest.json lists every frame (id, file, scene, title) - read it before exploring.
44
+ - design/manifest.json lists every frame (id, file, scene, title) - read it before
45
+ exploring. `init` writes the first one; `marver dev` keeps it fresh.
38
46
  - Component galleries: create design/components/<name>/variants.tsx rendering each variant
39
47
  and each state (default / hover-styled / focus / disabled / loading) of one ui component.
40
48
 
@@ -62,7 +70,10 @@ lays it out, and keeps it tidy:
62
70
  ```
63
71
 
64
72
  - The same frame may appear on many boards, or twice on one board (e.g. two widths:
65
- add `"w"`/`"h"` on a node to pin a size).
73
+ add `"w"`/`"h"` on a node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison
74
+ row: same `y`, increasing `x`). Know that the human's tidy (`t`) and device views
75
+ re-layout in frame-id order, so id ordering is the durable arrangement; explicit
76
+ coordinates are for one-off setups.
66
77
  - `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
67
78
  never write it.
68
79
  - Use boards for comparisons: version A vs B vs C of a flow, side by side.
@@ -11,7 +11,14 @@ Never run or talk to the canvas tool; read and write files only.
11
11
  // tv available commented-out). Pick the one the screen is designed for - the human can
12
12
  // flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
13
13
  - States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
14
- - Use the app's UI: import from {{UI_ALIAS}}; style with the app's Tailwind classes.
14
+ - VERSIONS are sibling frames too - the scene is the surface, each frame one direction:
15
+ design/scenes/landing/a-terminal.tsx, landing/b-editorial.tsx, landing/c-product.tsx.
16
+ Layout and the sidebar follow frame-id order, so variants named under one scene with
17
+ a-/b-/c- prefixes stay adjacent and ordered through tidy and every device view.
18
+ Never spread versions across scenes (terminal/landing, editorial/landing) - they
19
+ interleave with everything else and the comparison falls apart.
20
+ - {{UI_GUIDANCE}}
21
+ {{NEXT_NOTES}}
15
22
  - Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
16
23
  In play mode (the human presses P) frames swap in place inside one device - design flows
17
24
  as complete graphs: every screen a data-goto points at should itself link somewhere or be
@@ -33,7 +40,8 @@ Never run or talk to the canvas tool; read and write files only.
33
40
  setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
34
41
 
35
42
  ## Orientation
36
- - design/manifest.json lists every frame (id, file, scene, title) - read it before exploring.
43
+ - design/manifest.json lists every frame (id, file, scene, title) - read it before
44
+ exploring. `init` writes the first one; `marver dev` keeps it fresh.
37
45
  - Component galleries: create design/components/<name>/variants.tsx rendering each variant
38
46
  and each state (default / hover-styled / focus / disabled / loading) of one ui component.
39
47
 
@@ -62,7 +70,10 @@ lays it out, and keeps it tidy:
62
70
  ```
63
71
 
64
72
  - The same frame may appear on many boards, or twice on one board (e.g. two widths:
65
- add `"w"`/`"h"` on a node to pin a size).
73
+ add `"w"`/`"h"` on a node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison
74
+ row: same `y`, increasing `x`). Know that the human's tidy (`t`) and device views
75
+ re-layout in frame-id order, so id ordering is the durable arrangement; explicit
76
+ coordinates are for one-off setups.
66
77
  - `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
67
78
  never write it.
68
79
  - Use boards for comparisons: version A vs B vs C of a flow, side by side.
@@ -1,132 +0,0 @@
1
- import { join } from "node:path";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { pathToFileURL } from "node:url";
4
- //#region src/server/detect.ts
5
- function readJson(file) {
6
- try {
7
- return JSON.parse(stripJsonComments(readFileSync(file, "utf8")));
8
- } catch {
9
- return null;
10
- }
11
- }
12
- function stripJsonComments(s) {
13
- return s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/,\s*([}\]])/g, "$1");
14
- }
15
- function detectHost(root) {
16
- const pkg = readJson(join(root, "package.json")) ?? {};
17
- const deps = {
18
- ...pkg.dependencies,
19
- ...pkg.devDependencies
20
- };
21
- const twRange = deps["tailwindcss"];
22
- const tailwind = !twRange ? null : /(^|[^\d])4\./.test(twRange) || twRange.startsWith("^4") || twRange.startsWith("~4") ? 4 : 3;
23
- const router = deps["next"] ? "next" : deps["react-router"] || deps["react-router-dom"] ? "react-router" : null;
24
- const routerPkg = deps["react-router-dom"] ? "react-router-dom" : "react-router";
25
- const toaster = deps["sonner"] ? "sonner" : deps["react-hot-toast"] ? "react-hot-toast" : null;
26
- let shadcn = null;
27
- const comp = readJson(join(root, "components.json"));
28
- if (comp?.tailwind?.css) shadcn = {
29
- themeCss: comp.tailwind.css,
30
- uiAlias: comp.aliases?.ui ?? "@/components/ui"
31
- };
32
- const themeCss = shadcn?.themeCss && existsSync(join(root, shadcn.themeCss)) ? shadcn.themeCss : firstExisting(root, [
33
- "src/index.css",
34
- "src/styles/theme.css",
35
- "src/styles/globals.css",
36
- "src/app/globals.css",
37
- "app/globals.css",
38
- "src/globals.css",
39
- "src/style.css",
40
- "styles/globals.css"
41
- ]);
42
- const ts = readJson(join(root, "tsconfig.json"));
43
- const include = ts?.include;
44
- const exclude = ts?.exclude ?? [];
45
- const sweeps = ts != null && !exclude.some((e) => e === "design" || e.startsWith("design/")) && (!include || include.some((i) => i === "." || i === "**/*" || i.startsWith("design")));
46
- return {
47
- tailwind,
48
- router,
49
- routerPkg,
50
- toaster,
51
- shadcn,
52
- themeCss,
53
- tsconfigSweepsDesign: !!sweeps
54
- };
55
- }
56
- function firstExisting(root, candidates) {
57
- for (const c of candidates) if (existsSync(join(root, c))) return c;
58
- return null;
59
- }
60
- //#endregion
61
- //#region src/server/config.ts
62
- const DEFAULTS = {
63
- mode: "studio",
64
- theme: null,
65
- viewports: {
66
- mobile: {
67
- width: 390,
68
- height: 844
69
- },
70
- tablet: {
71
- width: 768,
72
- height: 1024
73
- },
74
- laptop: {
75
- width: 1280,
76
- height: 800
77
- },
78
- monitor: {
79
- width: 1920,
80
- height: 1080
81
- }
82
- },
83
- themes: ["light", "dark"],
84
- port: 5199,
85
- zoomSpeed: 1,
86
- share: { branding: true }
87
- };
88
- /** Load design/config.ts via native TS import (Node >= 22.18). Missing or broken fields fall back to defaults. */
89
- async function loadConfig(root) {
90
- const file = join(root, "design", "config.ts");
91
- if (!existsSync(file)) return { ...DEFAULTS };
92
- try {
93
- const user = (await import(`${pathToFileURL(file).href}?t=${Date.now()}`)).default ?? {};
94
- return {
95
- ...DEFAULTS,
96
- ...user,
97
- viewports: validViewports(user.viewports) ?? DEFAULTS.viewports,
98
- themes: Array.isArray(user.themes) && user.themes.length ? user.themes.map(String) : DEFAULTS.themes,
99
- port: validPort(user.port) ?? DEFAULTS.port,
100
- zoomSpeed: validZoom(user.zoomSpeed) ?? DEFAULTS.zoomSpeed,
101
- share: {
102
- branding: user.share?.branding !== false,
103
- name: typeof user.share?.name === "string" ? user.share.name : void 0,
104
- logo: typeof user.share?.logo === "string" ? user.share.logo : void 0
105
- }
106
- };
107
- } catch (err) {
108
- console.error(`[marver] design/config.ts failed to load, using defaults:\n ${err.message}`);
109
- return { ...DEFAULTS };
110
- }
111
- }
112
- const validDim = (n) => typeof n === "number" && Number.isFinite(n) && n >= 1 && n <= 2e4;
113
- function validPort(n) {
114
- return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
115
- }
116
- function validZoom(n) {
117
- return typeof n === "number" && Number.isFinite(n) && n >= .1 && n <= 10 ? n : null;
118
- }
119
- function validViewports(v) {
120
- if (!v || typeof v !== "object") return null;
121
- const out = {};
122
- for (const [k, val] of Object.entries(v)) {
123
- const vp = val;
124
- if (validDim(vp?.width) && validDim(vp?.height)) out[k] = {
125
- width: Math.round(vp.width),
126
- height: Math.round(vp.height)
127
- };
128
- }
129
- return Object.keys(out).length ? out : null;
130
- }
131
- //#endregion
132
- export { loadConfig as n, detectHost as r, DEFAULTS as t };