@marver-design/marver 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +1 -0
  2. package/dist/{build-BYZDMiIS.mjs → build-Bqd6OEsQ.mjs} +2 -2
  3. package/dist/cli.mjs +3 -3
  4. package/dist/{dev-Dt9D4O7Z.mjs → dev-9-80L5i5.mjs} +2 -2
  5. package/dist/init-ClhCgn4v.mjs +358 -0
  6. package/dist/{manifest-CHmKAAtG.mjs → manifest-mYlO_1Pj.mjs} +78 -2
  7. package/dist/{plugin-lVQoABEx.mjs → plugin-YVpBNTB3.mjs} +103 -9
  8. package/package.json +1 -1
  9. package/src/client/shell/App.tsx +123 -19
  10. package/src/client/shell/Play.tsx +36 -0
  11. package/src/client/shell/canvas/Canvas.tsx +54 -7
  12. package/src/client/shell/canvas/FrameNode.tsx +14 -1
  13. package/src/client/shell/icons.tsx +2 -0
  14. package/src/client/shell/store.ts +119 -15
  15. package/src/client/shell/styles.css +107 -2
  16. package/src/client/shell/tidy.ts +347 -17
  17. package/src/client/stage/main.tsx +11 -3
  18. package/templates/AGENTS-embedded.md +38 -33
  19. package/templates/AGENTS-studio.md +38 -33
  20. package/templates/design-tsconfig.json +7 -2
  21. package/templates/instructions/boards.md +84 -0
  22. package/templates/instructions/brand.md +60 -0
  23. package/templates/instructions/components.md +52 -0
  24. package/templates/instructions/configure.md +44 -0
  25. package/templates/instructions/craft.md +90 -0
  26. package/templates/instructions/discover.md +58 -0
  27. package/templates/instructions/reference/color.md +53 -0
  28. package/templates/instructions/reference/concepts.md +68 -0
  29. package/templates/instructions/reference/copy.md +57 -0
  30. package/templates/instructions/reference/critique.md +53 -0
  31. package/templates/instructions/reference/delight.md +35 -0
  32. package/templates/instructions/reference/layout.md +51 -0
  33. package/templates/instructions/reference/motion.md +66 -0
  34. package/templates/instructions/reference/operate.md +38 -0
  35. package/templates/instructions/reference/slop.md +76 -0
  36. package/templates/instructions/reference/states.md +48 -0
  37. package/templates/instructions/reference/tune.md +61 -0
  38. package/templates/instructions/reference/typography.md +45 -0
  39. package/templates/instructions/review.md +51 -0
  40. package/templates/instructions/wireframe.md +49 -0
  41. package/dist/init-CHUZTYG7.mjs +0 -224
package/README.md CHANGED
@@ -18,6 +18,7 @@ Then, to your agent:
18
18
  - **Boards**: one canvas on screen at a time. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed.
19
19
  - **Devices view**: the Devices menu (or hotkeys `1`-`5`) sizes every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
20
20
  - `data-goto="scene/frame"` on any element links frames into a walkable prototype.
21
+ - **Upgrade**: `npm i -D @marver-design/marver@latest && npx marver init`. The canvas tells you when a new version is out (one anonymous registry check per day, cached in `design/.local/`; `MARVER_NO_UPDATE_CHECK=1` disables). Re-running init refreshes the managed files (AGENTS.md, `design/instructions/`) - your edits to them are detected and preserved; when both you and a release changed a file, the fresh version is staged at `design/.local/latest/` for you (or your agent) to merge. Everything else in `design/` is yours and never touched.
21
22
  - Uninstall: delete `design/`, remove the dependency. (If `init` patched your tsconfig `exclude`, revert that one line.)
22
23
 
23
24
  **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.
@@ -1,6 +1,6 @@
1
1
  import { r as ROUTE, t as NAME } from "./cli.mjs";
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";
2
+ import { a as loadConfig, n as scanFrames, o as detectHost } from "./manifest-mYlO_1Pj.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-YVpBNTB3.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
package/dist/cli.mjs CHANGED
@@ -32,14 +32,14 @@ function version() {
32
32
  }
33
33
  const cli = cac(NAME);
34
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");
35
+ const { init } = await import("./init-ClhCgn4v.mjs");
36
36
  init(resolve(opts.root), {
37
37
  mode: opts.mode === "embedded" ? "embedded" : "studio",
38
38
  demo: opts.demo !== false
39
39
  });
40
40
  });
41
41
  cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
42
- const { dev } = await import("./dev-Dt9D4O7Z.mjs");
42
+ const { dev } = await import("./dev-9-80L5i5.mjs");
43
43
  let port;
44
44
  if (opts.port !== void 0) {
45
45
  const n = Number(opts.port);
@@ -49,7 +49,7 @@ cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root",
49
49
  await dev(resolve(opts.root), port);
50
50
  });
51
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) => {
52
- const { buildSite } = await import("./build-BYZDMiIS.mjs");
52
+ const { buildSite } = await import("./build-Bqd6OEsQ.mjs");
53
53
  try {
54
54
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
55
55
  await buildSite(resolve(opts.root), boards);
@@ -1,6 +1,6 @@
1
1
  import { n as PKG, t as NAME } from "./cli.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";
2
+ import { a as loadConfig, o as detectHost } from "./manifest-mYlO_1Pj.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-YVpBNTB3.mjs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
@@ -0,0 +1,358 @@
1
+ import { t as NAME } from "./cli.mjs";
2
+ import { i as DEFAULTS, n as scanFrames, o as detectHost, r as writeManifest, s as readJson } from "./manifest-mYlO_1Pj.mjs";
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createHash } from "node:crypto";
7
+ //#region src/cli/init.ts
8
+ /** Package root = the nearest ancestor holding templates/ (one hop from dist/, two
9
+ * from src/cli/ - the walk serves both, and tests run init from source). */
10
+ const pkgDir = () => {
11
+ let dir = dirname(fileURLToPath(import.meta.url));
12
+ for (let i = 0; i < 4; i++) {
13
+ if (existsSync(join(dir, "templates"))) return dir;
14
+ dir = dirname(dir);
15
+ }
16
+ return join(dirname(fileURLToPath(import.meta.url)), "..");
17
+ };
18
+ /** Idempotent scaffolder: never overwrites existing files; every host-repo touch prints a diff. */
19
+ function init(root, opts) {
20
+ const host = detectHost(root);
21
+ const design = join(root, "design");
22
+ const templates = join(pkgDir(), "templates");
23
+ const created = [];
24
+ const fileHas = (rel, needle) => {
25
+ try {
26
+ return readFileSync(join(design, rel), "utf8").includes(needle);
27
+ } catch {
28
+ return false;
29
+ }
30
+ };
31
+ const marverShaped = fileHas("config.ts", "marver") || fileHas("AGENTS.md", "agent contract");
32
+ if (existsSync(design) && !marverShaped && readdirSync(design).some((f) => !f.startsWith("."))) {
33
+ console.error(`
34
+ [${NAME}] design/ already exists in this repo and does not look like a ${NAME} workspace.
35
+ Refusing to merge into it - your files and ${NAME}'s would interleave, and
36
+ "uninstall = delete design/" would stop being safe.
37
+
38
+ Move or rename the existing design/ folder, then re-run \`npx ${NAME} init\`.
39
+ (If you need marver to live in a differently-named folder, say so at
40
+ github.com/TNEP4/marver - a --dir flag is planned.)`);
41
+ process.exit(1);
42
+ }
43
+ const write = (rel, content) => {
44
+ const file = join(design, rel);
45
+ if (existsSync(file)) return;
46
+ mkdirSync(dirname(file), { recursive: true });
47
+ writeFileSync(file, content);
48
+ created.push(`design/${rel}`);
49
+ };
50
+ const writeManaged = (rel, body) => {
51
+ const file = join(design, rel);
52
+ const next = managedFile(body);
53
+ const latest = join(design, ".local", "latest", rel);
54
+ if (!existsSync(file)) return write(rel, next);
55
+ const current = readFileSync(file, "utf8");
56
+ if (current === next) {
57
+ rmSync(latest, { force: true });
58
+ return;
59
+ }
60
+ if (current.startsWith(MANAGED_PREFIX)) {
61
+ const recorded = current.slice(MANAGED_PREFIX.length).split(" ")[0];
62
+ const nl = current.indexOf("\n");
63
+ const currentBody = nl >= 0 ? current.slice(nl + 1) : "";
64
+ if (nl >= 0 && hashBody(currentBody) === recorded) {
65
+ writeFileSync(file, next);
66
+ rmSync(latest, { force: true });
67
+ created.push(`design/${rel} (updated)`);
68
+ } else if (recorded !== hashBody(body)) {
69
+ mkdirSync(dirname(latest), { recursive: true });
70
+ writeFileSync(latest, body);
71
+ if (nl >= 0) {
72
+ const tmp = file + ".tmp";
73
+ writeFileSync(tmp, managedFile(body).split("\n")[0] + "\n" + currentBody);
74
+ renameSync(tmp, file);
75
+ }
76
+ console.warn(` ~ design/${rel}: you customized it and a newer version exists - your edits are untouched. Merge what you want from design/.local/latest/${rel}`);
77
+ }
78
+ } else if (current.startsWith(LEGACY_PREFIX)) {
79
+ writeFileSync(file, next);
80
+ rmSync(latest, { force: true });
81
+ created.push(`design/${rel} (updated)`);
82
+ } else if (current !== body) {
83
+ rmSync(latest, { force: true });
84
+ console.warn(` note: design/${rel} exists without a marver marker - left untouched. If you did not author it, delete it and re-run init to restore the managed version.`);
85
+ }
86
+ };
87
+ write("config.ts", configTemplate(opts.mode));
88
+ if (host.themeCss) {
89
+ const relCss = relative(design, join(root, host.themeCss)).split("\\").join("/");
90
+ write("theme.css", themeWrapper(relCss, host.tailwind === 4));
91
+ } 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).`);
92
+ write("providers.tsx", providersTemplate(host.router, host.toaster, host.routerPkg));
93
+ writeManaged("AGENTS.md", readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_GUIDANCE}}", uiGuidance(host, noApp(host))).replace(/\{\{NEXT_NOTES\}\}\n?/, host.router === "next" ? NEXT_NOTES : ""));
94
+ const agentsNow = readFileSync(join(design, "AGENTS.md"), "utf8");
95
+ if (!agentsNow.startsWith(MANAGED_PREFIX) && !agentsNow.startsWith(LEGACY_PREFIX) && agentsNow.includes("# Design canvas - agent contract") && !agentsNow.includes("## The method (binding)")) console.warn(` note: design/AGENTS.md predates managed regeneration - if you never edited it, delete it and re-run init to get the current contract (incl. the design/instructions routing).`);
96
+ const instrRoot = join(templates, "instructions");
97
+ for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
98
+ for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) writeManaged(`instructions/${e.name}/${f}`, readFileSync(join(instrRoot, e.name, f), "utf8"));
99
+ } else if (e.name.endsWith(".md")) writeManaged(`instructions/${e.name}`, readFileSync(join(instrRoot, e.name), "utf8"));
100
+ const setupPath = join(design, "instructions", "setup.md");
101
+ const ourSetup = () => {
102
+ try {
103
+ const s = readFileSync(setupPath, "utf8");
104
+ return s.startsWith("# Setup required") && s.includes("marver init");
105
+ } catch {
106
+ return false;
107
+ }
108
+ };
109
+ if (noApp(host)) {
110
+ if (!existsSync(setupPath)) write("instructions/setup.md", SETUP_MD);
111
+ } else if (existsSync(setupPath) && ourSetup()) {
112
+ rmSync(setupPath);
113
+ console.log(` - design/instructions/setup.md removed (app detected - setup complete)`);
114
+ }
115
+ write("tsconfig.json", existsSync(join(root, "tsconfig.json")) ? readFileSync(join(templates, "design-tsconfig.json"), "utf8").replace("{{PATHS}}", designPaths(root)) : STANDALONE_TSCONFIG);
116
+ write(".gitignore", ".local/\n.dist/\n");
117
+ write("scenes/_layout.tsx", readFileSync(join(templates, "root-layout.tsx"), "utf8"));
118
+ if (!existsSync(join(design, "boards"))) {
119
+ mkdirSync(join(design, "boards"), { recursive: true });
120
+ writeFileSync(join(design, "boards", ".gitkeep"), "");
121
+ created.push("design/boards/");
122
+ }
123
+ if (opts.demo && !existsSync(join(design, "scenes", "demo"))) {
124
+ cpSync(join(templates, "demo"), join(design, "scenes", "demo"), { recursive: true });
125
+ created.push("design/scenes/demo/ (3 frames)");
126
+ }
127
+ if (host.tsconfigSweepsDesign) patchTsconfigExclude(root);
128
+ writeManifest(root, scanFrames(root));
129
+ console.log(`\n${NAME} initialized (${opts.mode} mode). Created:`);
130
+ for (const f of created) console.log(` + ${f}`);
131
+ 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).`);
132
+ if (noApp(host)) console.warn(`
133
+ ┌─ NO APP DETECTED ─────────────────────────────────────────────────────┐
134
+ │ No framework, no theme CSS, no component library. ${NAME} builds │
135
+ │ frames from YOUR components - with none, designs get thrown away. │
136
+ │ │
137
+ │ Setup instructions: design/instructions/setup.md. Set up the app, │
138
+ │ re-run init, and that file removes itself. AGENTS.md carries a STOP │
139
+ │ so your agent does not design against components that do not exist. │
140
+ └───────────────────────────────────────────────────────────────────────┘`);
141
+ console.log(`\n commit design/ - only .local/ is ignored`);
142
+ console.log(` uninstall = delete design/, remove the ${NAME} dependency${host.tsconfigSweepsDesign ? ", revert the \"design\" line in tsconfig exclude" : ""}`);
143
+ if (!noApp(host) && !existsSync(join(design, "DESIGN.md"))) console.log(`\n note: design/DESIGN.md (the brand doc) does not exist yet - have your agent create it from the app's tokens (instructions/brand.md, Path A) to reach the idle state.`);
144
+ console.log(`\n next: npx ${NAME} dev (canvas on http://localhost:${DEFAULTS.port} by default)\n`);
145
+ 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`);
146
+ }
147
+ const MANAGED_PREFIX = "<!-- marver:managed ";
148
+ const LEGACY_PREFIX = "<!-- generated by marver init";
149
+ const hashBody = (s) => createHash("sha256").update(s).digest("hex");
150
+ /** The marker carries a hash of the generated body: edits are DETECTED, not assumed.
151
+ * Edit freely - init preserves edits and stages upstream updates for merging.
152
+ * Deleting the marker line detaches the file from updates entirely. */
153
+ const managedFile = (body) => `${MANAGED_PREFIX}${hashBody(body)} - edit freely: init preserves your edits and stages upstream updates at design/.local/latest/ for you to merge. Delete this line to detach this file from updates entirely. -->\n${body}`;
154
+ /** The host's path aliases, re-rooted one level down for design/tsconfig.json.
155
+ * "./src/*" becomes "../src/*" so `@/` imports resolve from inside design/. */
156
+ function designPaths(root) {
157
+ const hostPaths = readJson(join(root, "tsconfig.json"))?.compilerOptions?.paths;
158
+ if (!hostPaths || typeof hostPaths !== "object") return "";
159
+ const rerooted = {};
160
+ for (const [alias, targets] of Object.entries(hostPaths)) {
161
+ if (!Array.isArray(targets)) continue;
162
+ rerooted[alias] = targets.map((t) => typeof t === "string" ? t.startsWith("./") ? `../${t.slice(2)}` : `../${t}` : t);
163
+ }
164
+ if (!Object.keys(rerooted).length) return "";
165
+ return `,\n // the host's aliases, re-rooted (inherited paths resolve against the WRONG dir)\n "paths": ${JSON.stringify(rerooted)}`;
166
+ }
167
+ /** No framework, no theme, no component alias = nothing to build frames FROM. */
168
+ const noApp = (host) => !host.router && !host.tailwind && !host.shadcn && !host.themeCss;
169
+ /** The UI line of AGENTS.md, matched to what detection actually found (friction log #1).
170
+ * The STOP branch fires only on the same condition that creates SETUP.md - an app
171
+ * without Tailwind (plain React + CSS) gets guidance, never a dead pointer. */
172
+ function uiGuidance(host, isNoApp) {
173
+ if (isNoApp) return `STOP - this repo has no app yet. Read design/instructions/setup.md before designing anything.`;
174
+ if (host.shadcn) return `Use the app's UI: import from ${host.shadcn.uiAlias}; style with the app's Tailwind classes.`;
175
+ 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/.`;
176
+ return `Use the app's existing components and stylesheets (import them directly); there is no Tailwind or component library detected - extract shared pieces into design/components/.`;
177
+ }
178
+ const SETUP_MD = `# Setup required - this repo has no app yet
179
+
180
+ > This file exists because \`${NAME} init\` ran in a repo with no framework, no theme
181
+ > CSS, and no component library. It disappears automatically: set up the app, re-run
182
+ > \`npx ${NAME} init\`, and init deletes this file and regenerates AGENTS.md against
183
+ > the real stack. While this file exists, DO NOT design.
184
+
185
+ ${NAME} builds frames from YOUR components and YOUR theme. With none, frames become
186
+ hand-rolled CSS that shares nothing with the future app and cannot be promoted into
187
+ it later - work that gets thrown away.
188
+
189
+ ## Do this first
190
+
191
+ For a web app or marketing site, the blessed stack. NOTE: create-next-app refuses a
192
+ non-empty directory (this repo already holds design/ and a package.json), so
193
+ scaffold into a temp dir and merge:
194
+
195
+ \`\`\`bash
196
+ npx create-next-app@latest app-scaffold --ts --tailwind --app --src-dir --yes
197
+ # move everything from app-scaffold/ up to the repo root EXCEPT its package.json;
198
+ # merge app-scaffold/package.json's dependencies+scripts into the existing one,
199
+ # then: rm -rf app-scaffold && npm install
200
+ npx shadcn@latest init
201
+ \`\`\`
202
+
203
+ shadcn's flags change between versions - if a flag errors or it prompts despite
204
+ --yes, answer the prompts with its defaults. Any React + CSS setup works; the
205
+ point is that components and a theme EXIST.
206
+
207
+ ## Then
208
+
209
+ \`\`\`bash
210
+ npx ${NAME} init
211
+ \`\`\`
212
+
213
+ init is idempotent: it fills in what it now detects (theme wrapper, providers, a
214
+ shadcn-aware AGENTS.md), deletes this file, and you design from real parts.
215
+ `;
216
+ /** Next.js frames render OUTSIDE Next - say concretely what that means (friction log #10/#11). */
217
+ const NEXT_NOTES = `- Next.js caveats (frames render in Vite, outside Next):
218
+ next/font does not exist here - CSS variables it injects (e.g. --font-geist-sans) are
219
+ undefined in frames, so give every font token a real fallback chain in the app's CSS:
220
+ --font-sans: var(--font-geist-sans, ui-sans-serif, system-ui, sans-serif).
221
+ next/image and next/link render as plain img/a via shims at best - prefer <img> and
222
+ data-goto in frames. Server Components and server actions cannot run: frames are
223
+ client components importing client components.
224
+ `;
225
+ const STANDALONE_TSCONFIG = `{
226
+ "compilerOptions": {
227
+ "target": "ES2022",
228
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
229
+ "module": "ESNext",
230
+ "moduleResolution": "bundler",
231
+ "jsx": "react-jsx",
232
+ "strict": true,
233
+ "skipLibCheck": true,
234
+ "noEmit": true
235
+ },
236
+ "include": ["."]
237
+ }
238
+ `;
239
+ function patchTsconfigExclude(root) {
240
+ const file = join(root, "tsconfig.json");
241
+ if (!existsSync(file)) return;
242
+ const raw = readFileSync(file, "utf8");
243
+ try {
244
+ let next;
245
+ if (/"exclude"\s*:/.test(raw)) next = raw.replace(/("exclude"\s*:\s*\[)/, "$1\"design\", ");
246
+ else {
247
+ const at = firstJsonBrace(raw);
248
+ if (at < 0) throw new Error("no object brace found");
249
+ next = raw.slice(0, at + 1) + "\n \"exclude\": [\"design\"]," + raw.slice(at + 1);
250
+ }
251
+ if (next === raw) throw new Error("no anchor matched");
252
+ writeFileSync(file, next);
253
+ console.log(`\n patched tsconfig.json (the only host file touched):`);
254
+ console.log(` + "design" added to "exclude" (revert this line to fully uninstall)`);
255
+ } catch {
256
+ console.warn(` could not patch tsconfig.json - add "design" to its "exclude" yourself.`);
257
+ }
258
+ }
259
+ /** Index of the first `{` outside //, /* *\/ comments and strings (tsconfig is JSONC). */
260
+ function firstJsonBrace(src) {
261
+ let inLine = false, inBlock = false, inStr = false;
262
+ for (let i = 0; i < src.length; i++) {
263
+ const c = src[i], n = src[i + 1];
264
+ if (inLine) {
265
+ if (c === "\n") inLine = false;
266
+ continue;
267
+ }
268
+ if (inBlock) {
269
+ if (c === "*" && n === "/") {
270
+ inBlock = false;
271
+ i++;
272
+ }
273
+ continue;
274
+ }
275
+ if (inStr) {
276
+ if (c === "\\") i++;
277
+ else if (c === "\"") inStr = false;
278
+ continue;
279
+ }
280
+ if (c === "/" && n === "/") {
281
+ inLine = true;
282
+ i++;
283
+ continue;
284
+ }
285
+ if (c === "/" && n === "*") {
286
+ inBlock = true;
287
+ i++;
288
+ continue;
289
+ }
290
+ if (c === "\"") {
291
+ inStr = true;
292
+ continue;
293
+ }
294
+ if (c === "{") return i;
295
+ }
296
+ return -1;
297
+ }
298
+ const configTemplate = (mode) => `// ${NAME} config - OPTIONAL. Delete this file and everything still works on defaults.
299
+ // Theme lives in design/theme.css (it imports your app's real stylesheet) - not here.
300
+ // Sharp edges (native Node TS import): erasable syntax only (no enums/namespaces),
301
+ // relative imports need extensions, tsconfig paths are ignored here.
302
+ export default {
303
+ mode: ${JSON.stringify(mode)},
304
+ // Device widths for frames and the Devices view. Rename, retune, or uncomment tv.
305
+ viewports: {
306
+ mobile: { width: 390, height: 844 },
307
+ tablet: { width: 768, height: 1024 },
308
+ laptop: { width: 1280, height: 800 },
309
+ monitor: { width: 1920, height: 1080 },
310
+ // tv: { width: 3840, height: 2160 },
311
+ },
312
+ themes: ["light", "dark"],
313
+ port: ${DEFAULTS.port},
314
+ // Canvas zoom feel: 1 = default, 1.2 = 20% faster, 0.8 = 20% slower.
315
+ // zoomSpeed: 1,
316
+ // Publishing (\`${NAME} build\` + \`${NAME} serve\`): gate identity + branding footer.
317
+ // name/logo default to the host package.json name and design/logo.svg (then public/).
318
+ // branding is the small "Powered by Marver.design" line under the gate. Marver is
319
+ // free, and that line is how it spreads - we'd love it if you leave it on, but it
320
+ // is yours to remove, no strings: share: { branding: false }.
321
+ // share: { name: "My App", logo: "design/logo.svg", branding: true },
322
+ }
323
+ `;
324
+ const themeWrapper = (relCss, v4) => `/* ${NAME} theme wrapper - imports the app's real theme; the app's own build never sees design/. */
325
+ @import "${relCss}";
326
+ ${v4 ? `@source "./";\n` : ""}`;
327
+ function providersTemplate(router, toaster, routerPkg = "react-router-dom") {
328
+ const imports = [`import type { ReactNode } from 'react'`];
329
+ let open = "", close = "";
330
+ if (router === "react-router") {
331
+ imports.push(`import { MemoryRouter } from '${routerPkg}'`);
332
+ open += "<MemoryRouter>";
333
+ close = "</MemoryRouter>" + close;
334
+ }
335
+ let toasterEl = "";
336
+ if (toaster === "sonner") {
337
+ imports.push(`import { Toaster } from 'sonner'`);
338
+ toasterEl = "<Toaster />";
339
+ }
340
+ if (toaster === "react-hot-toast") {
341
+ imports.push(`import { Toaster } from 'react-hot-toast'`);
342
+ toasterEl = "<Toaster />";
343
+ }
344
+ return `// Mock contexts wrapped around every frame. Scaffolded by ${NAME} init from what it detected - yours to edit.
345
+ ${imports.join("\n")}
346
+
347
+ export default function Providers({ children }: { children: ReactNode }) {
348
+ return (
349
+ ${open || "<>"}
350
+ {children}
351
+ ${toasterEl}
352
+ ${close || "</>"}
353
+ )
354
+ }
355
+ `;
356
+ }
357
+ //#endregion
358
+ export { init };
@@ -204,7 +204,7 @@ function extractMeta(src) {
204
204
  if (!m) return {};
205
205
  const body = m[1];
206
206
  const pick = (key) => {
207
- const r = new RegExp(`${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
207
+ const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
208
208
  return r ? r[2] : void 0;
209
209
  };
210
210
  const out = {};
@@ -214,6 +214,10 @@ function extractMeta(src) {
214
214
  if (viewport) out.viewport = viewport;
215
215
  const theme = pick("theme");
216
216
  if (theme) out.theme = theme;
217
+ const of = pick("of");
218
+ if (of) out.of = of;
219
+ const variant = pick("variant");
220
+ if (variant) out.variant = variant;
217
221
  return out;
218
222
  }
219
223
  /** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
@@ -256,6 +260,8 @@ function scanFrames(root) {
256
260
  if (meta.title) entry.title = meta.title;
257
261
  if (meta.viewport) entry.viewport = meta.viewport;
258
262
  if (meta.theme) entry.theme = meta.theme;
263
+ if (meta.of) entry.variantGroup = meta.of;
264
+ if (meta.variant) entry.variant = meta.variant;
259
265
  }
260
266
  frames.push(entry);
261
267
  }
@@ -273,6 +279,7 @@ function scanFrames(root) {
273
279
  });
274
280
  frames.length = 0;
275
281
  frames.push(...deduped);
282
+ inferVariantGroups(frames);
276
283
  const sceneCounts = /* @__PURE__ */ new Map();
277
284
  for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
278
285
  return {
@@ -283,6 +290,75 @@ function scanFrames(root) {
283
290
  })).sort((a, b) => a.name.localeCompare(b.name))
284
291
  };
285
292
  }
293
+ /** Variant groups (SPEC-023 §1). A group = 2+ frames in one DIRECTORY whose basenames
294
+ * are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
295
+ * directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
296
+ * Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
297
+ * States (empty.tsx, error.tsx) never letter-prefix, so they never misgroup.
298
+ * Mutates entries in place: only frames whose group materializes keep the fields. */
299
+ function inferVariantGroups(frames) {
300
+ const dirOf = (id) => {
301
+ const i = id.lastIndexOf("/");
302
+ return i >= 0 ? id.slice(0, i) : "";
303
+ };
304
+ const candidates = /* @__PURE__ */ new Map();
305
+ for (const f of frames) {
306
+ if (f.kind !== "tsx") {
307
+ delete f.variantGroup;
308
+ delete f.variant;
309
+ continue;
310
+ }
311
+ let group = f.variantGroup;
312
+ let key = f.variant;
313
+ if (!group) {
314
+ const base = f.id.slice(f.id.lastIndexOf("/") + 1);
315
+ const m = /^([a-z])-.+$/.exec(base);
316
+ if (!m) continue;
317
+ group = dirOf(f.id);
318
+ if (!group) continue;
319
+ key = key ?? m[1];
320
+ } else if (!key) {
321
+ const m = /^([a-z])-.+$/.exec(f.id.slice(f.id.lastIndexOf("/") + 1));
322
+ if (!m) {
323
+ console.warn(`[marver] ${f.file} declares of:"${group}" but no variant key (add meta.variant or a letter prefix) - not grouped.`);
324
+ delete f.variantGroup;
325
+ delete f.variant;
326
+ continue;
327
+ }
328
+ key = m[1];
329
+ }
330
+ f.variantGroup = group;
331
+ f.variant = key;
332
+ candidates.set(group, [...candidates.get(group) ?? [], f]);
333
+ }
334
+ for (const [group, members] of candidates) {
335
+ if (new Set(members.map((m) => dirOf(m.id))).size > 1) {
336
+ console.warn(`[marver] group "${group}" spans directories - variants must be siblings; not grouped.`);
337
+ for (const m of members) {
338
+ delete m.variantGroup;
339
+ delete m.variant;
340
+ }
341
+ continue;
342
+ }
343
+ const seen = /* @__PURE__ */ new Set();
344
+ const kept = [];
345
+ for (const m of members.sort((a, b) => (a.variant ?? "").localeCompare(b.variant ?? ""))) {
346
+ if (seen.has(m.variant)) {
347
+ console.warn(`[marver] duplicate variant "${m.variant}" in group "${group}" (${m.file}) - not grouped.`);
348
+ delete m.variantGroup;
349
+ delete m.variant;
350
+ continue;
351
+ }
352
+ seen.add(m.variant);
353
+ kept.push(m);
354
+ }
355
+ if (kept.length < 2) for (const m of kept) {
356
+ delete m.variantGroup;
357
+ delete m.variant;
358
+ }
359
+ }
360
+ for (const f of frames) if (f.variant && !f.variantGroup) delete f.variant;
361
+ }
286
362
  /** Write design/manifest.json only when content changed. Returns the manifest either way. */
287
363
  function writeManifest(root, manifest) {
288
364
  const file = join(root, "design", "manifest.json");
@@ -295,4 +371,4 @@ function writeManifest(root, manifest) {
295
371
  }
296
372
  const hash = (s) => createHash("sha256").update(s).digest("hex");
297
373
  //#endregion
298
- export { loadConfig as a, DEFAULTS as i, scanFrames as n, detectHost as o, writeManifest as r, hash as t };
374
+ export { loadConfig as a, DEFAULTS as i, scanFrames as n, detectHost as o, writeManifest as r, readJson as s, hash as t };
@@ -1,7 +1,8 @@
1
- import { r as ROUTE } from "./cli.mjs";
2
- import { n as scanFrames, r as writeManifest, t as hash } from "./manifest-CHmKAAtG.mjs";
1
+ import { n as PKG, r as ROUTE, t as NAME } from "./cli.mjs";
2
+ import { n as scanFrames, r as writeManifest, t as hash } from "./manifest-mYlO_1Pj.mjs";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
4
- import { join, resolve, sep } from "node:path";
4
+ import { dirname, join, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { randomBytes } from "node:crypto";
6
7
  //#region src/server/api.ts
7
8
  const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
@@ -185,6 +186,73 @@ function routesMiddleware(server, clientDir) {
185
186
  };
186
187
  }
187
188
  //#endregion
189
+ //#region src/server/update.ts
190
+ /**
191
+ * Update discovery - dev only, and deliberately boring about privacy: one anonymous
192
+ * registry metadata GET per day (the same request `npm view` makes), cached in
193
+ * design/.local/, nothing sent beyond the request itself. Offline, slow, or
194
+ * firewalled registries degrade to silence. MARVER_NO_UPDATE_CHECK=1 disables it.
195
+ * Published bundles never check anything - viewers are not the owner.
196
+ */
197
+ const TTL = 864e5;
198
+ /** Installed version: walk up from this module to the package's own package.json
199
+ * (one level from dist/, two from src/server/ - the walk covers both). */
200
+ function installedVersion() {
201
+ let dir = dirname(fileURLToPath(import.meta.url));
202
+ for (let i = 0; i < 4; i++) {
203
+ try {
204
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
205
+ if (pkg.name === "@marver-design/marver") return pkg.version;
206
+ } catch {}
207
+ dir = dirname(dir);
208
+ }
209
+ return null;
210
+ }
211
+ /** Strictly-newer numeric compare; anything non-numeric (prerelease tags) compares false. */
212
+ const newer = (a, b) => {
213
+ const pa = a.split(".").map((n) => parseInt(n, 10));
214
+ const pb = b.split(".").map((n) => parseInt(n, 10));
215
+ for (let i = 0; i < 3; i++) {
216
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
217
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
218
+ if (x !== y) return x > y;
219
+ }
220
+ return false;
221
+ };
222
+ /** The latest published version when strictly newer than installed, else null. Never throws. */
223
+ async function checkUpdate(root) {
224
+ if (process.env.MARVER_NO_UPDATE_CHECK) return null;
225
+ const current = installedVersion();
226
+ if (!current) return null;
227
+ const latest = await latestVersion(root);
228
+ return latest && newer(latest, current) ? latest : null;
229
+ }
230
+ async function latestVersion(root) {
231
+ const cacheFile = join(root, "design", ".local", "update-check.json");
232
+ const wellFormed = (v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v);
233
+ try {
234
+ const c = JSON.parse(readFileSync(cacheFile, "utf8"));
235
+ const age = Date.now() - c.checkedAt;
236
+ if (typeof c.checkedAt === "number" && age >= 0 && age < TTL) return wellFormed(c.latest) ? c.latest : null;
237
+ } catch {}
238
+ let latest = null;
239
+ try {
240
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, { signal: AbortSignal.timeout(3e3) });
241
+ if (res.ok) {
242
+ const v = (await res.json())?.version;
243
+ if (wellFormed(v)) latest = v;
244
+ }
245
+ } catch {}
246
+ try {
247
+ mkdirSync(join(root, "design", ".local"), { recursive: true });
248
+ writeFileSync(cacheFile, JSON.stringify({
249
+ checkedAt: Date.now(),
250
+ latest
251
+ }) + "\n");
252
+ } catch {}
253
+ return latest;
254
+ }
255
+ //#endregion
188
256
  //#region src/server/plugin.ts
189
257
  const VIRTUAL_THEME = "virtual:sh-theme";
190
258
  const VIRTUAL_CONFIG = "virtual:sh-config";
@@ -211,12 +279,23 @@ function marverPlugin(ctx) {
211
279
  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).");
212
280
  return "/* marver: no theme configured */";
213
281
  }
214
- if (id === "\0virtual:sh-config") return `export default ${JSON.stringify({
215
- viewports: config.viewports,
216
- themes: config.themes,
217
- zoomSpeed: config.zoomSpeed,
218
- noTheme: themeFile() == null
219
- })}`;
282
+ if (id === "\0virtual:sh-config") {
283
+ const setupPending = (() => {
284
+ try {
285
+ const s = readFileSync(join(root, "design", "instructions", "setup.md"), "utf8");
286
+ return s.startsWith("# Setup required") && s.includes("marver init");
287
+ } catch {
288
+ return false;
289
+ }
290
+ })();
291
+ return `export default ${JSON.stringify({
292
+ viewports: config.viewports,
293
+ themes: config.themes,
294
+ zoomSpeed: config.zoomSpeed,
295
+ noTheme: themeFile() == null,
296
+ setup: setupPending
297
+ })}`;
298
+ }
220
299
  if (id === "\0virtual:sh-data") return "export default null";
221
300
  },
222
301
  /** HTML frames: inject theme + bridge into any design/**.html Vite serves.
@@ -263,6 +342,21 @@ function marverPlugin(ctx) {
263
342
  }
264
343
  next();
265
344
  });
345
+ const update = checkUpdate(root).catch(() => null);
346
+ update.then((latest) => {
347
+ if (latest) console.log(`\n update: ${PKG} ${latest} is out (installed ${installedVersion() ?? "?"}) → npm i -D ${PKG}@latest && npx ${NAME} init\n`);
348
+ });
349
+ server.middlewares.use((req, res, next) => {
350
+ if (new URL(req.url ?? "/", "http://x").pathname !== `/__mv/api/update`) return next();
351
+ update.then((latest) => {
352
+ res.setHeader("content-type", "application/json");
353
+ res.setHeader("cache-control", "no-store");
354
+ res.end(JSON.stringify({
355
+ latest,
356
+ current: installedVersion()
357
+ }));
358
+ });
359
+ });
266
360
  server.middlewares.use(apiMiddleware(root));
267
361
  server.middlewares.use(routesMiddleware(server, clientDir));
268
362
  const regen = debounce(() => {