@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 +2 -2
- package/dist/{build-D0GnIR4G.mjs → build-BYZDMiIS.mjs} +3 -3
- package/dist/cli.mjs +23 -7
- package/dist/{dev-BK4x3PBr.mjs → dev-Dt9D4O7Z.mjs} +24 -5
- package/dist/{init-DcOy1krf.mjs → init-CHUZTYG7.mjs} +63 -7
- package/dist/manifest-CHmKAAtG.mjs +298 -0
- package/dist/{plugin-DB5t2WUl.mjs → plugin-lVQoABEx.mjs} +56 -113
- package/dist/{serve-BPNmWeJx.mjs → serve-BvbAbWeK.mjs} +8 -1
- package/package.json +4 -3
- package/src/client/frame-host/bridge.js +8 -2
- package/src/client/frame-host/main.tsx +7 -1
- package/src/client/shell/App.tsx +24 -5
- package/src/client/shell/canvas/FrameNode.tsx +9 -0
- package/src/client/shell/store.ts +54 -7
- package/templates/AGENTS-embedded.md +14 -3
- package/templates/AGENTS-studio.md +14 -3
- package/dist/config-DMBEpdEN.mjs +0 -132
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
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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 {
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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(
|
|
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 {
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
5
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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": "
|
|
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)
|
|
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')
|
|
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
|
-
|
|
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
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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)
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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.
|
package/dist/config-DMBEpdEN.mjs
DELETED
|
@@ -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 };
|