@marver-design/marver 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +1 -0
  2. package/dist/{build-BYZDMiIS.mjs → build-CNoXE13J.mjs} +1 -1
  3. package/dist/cli.mjs +3 -3
  4. package/dist/{dev-Dt9D4O7Z.mjs → dev-Blyy4jOL.mjs} +1 -1
  5. package/dist/{init-CHUZTYG7.mjs → init-3h9pXEzp.mjs} +137 -24
  6. package/dist/{plugin-lVQoABEx.mjs → plugin-DiDJA9n-.mjs} +102 -8
  7. package/package.json +1 -1
  8. package/src/client/shell/App.tsx +46 -4
  9. package/src/client/shell/store.ts +4 -1
  10. package/src/client/shell/styles.css +21 -2
  11. package/templates/AGENTS-embedded.md +28 -27
  12. package/templates/AGENTS-studio.md +28 -27
  13. package/templates/instructions/boards.md +38 -0
  14. package/templates/instructions/brand.md +60 -0
  15. package/templates/instructions/components.md +52 -0
  16. package/templates/instructions/configure.md +44 -0
  17. package/templates/instructions/craft.md +90 -0
  18. package/templates/instructions/discover.md +52 -0
  19. package/templates/instructions/reference/color.md +53 -0
  20. package/templates/instructions/reference/concepts.md +68 -0
  21. package/templates/instructions/reference/copy.md +57 -0
  22. package/templates/instructions/reference/critique.md +53 -0
  23. package/templates/instructions/reference/delight.md +35 -0
  24. package/templates/instructions/reference/layout.md +51 -0
  25. package/templates/instructions/reference/motion.md +66 -0
  26. package/templates/instructions/reference/operate.md +38 -0
  27. package/templates/instructions/reference/slop.md +76 -0
  28. package/templates/instructions/reference/states.md +48 -0
  29. package/templates/instructions/reference/tune.md +61 -0
  30. package/templates/instructions/reference/typography.md +45 -0
  31. package/templates/instructions/review.md +51 -0
  32. package/templates/instructions/wireframe.md +49 -0
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
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";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DiDJA9n-.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-3h9pXEzp.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-Blyy4jOL.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-CNoXE13J.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
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";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DiDJA9n-.mjs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
@@ -1,16 +1,45 @@
1
1
  import { t as NAME } from "./cli.mjs";
2
2
  import { i as DEFAULTS, n as scanFrames, o as detectHost, r as writeManifest } from "./manifest-CHmKAAtG.mjs";
3
- import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { createHash } from "node:crypto";
6
7
  //#region src/cli/init.ts
7
- const pkgDir = () => join(dirname(fileURLToPath(import.meta.url)), "..");
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
+ };
8
18
  /** Idempotent scaffolder: never overwrites existing files; every host-repo touch prints a diff. */
9
19
  function init(root, opts) {
10
20
  const host = detectHost(root);
11
21
  const design = join(root, "design");
12
22
  const templates = join(pkgDir(), "templates");
13
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
+ }
14
43
  const write = (rel, content) => {
15
44
  const file = join(design, rel);
16
45
  if (existsSync(file)) return;
@@ -18,21 +47,70 @@ function init(root, opts) {
18
47
  writeFileSync(file, content);
19
48
  created.push(`design/${rel}`);
20
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
+ };
21
87
  write("config.ts", configTemplate(opts.mode));
22
88
  if (host.themeCss) {
23
89
  const relCss = relative(design, join(root, host.themeCss)).split("\\").join("/");
24
90
  write("theme.css", themeWrapper(relCss, host.tailwind === 4));
25
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).`);
26
92
  write("providers.tsx", providersTemplate(host.router, host.toaster, host.routerPkg));
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)");
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;
35
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)`);
36
114
  }
37
115
  write("tsconfig.json", existsSync(join(root, "tsconfig.json")) ? readFileSync(join(templates, "design-tsconfig.json"), "utf8") : STANDALONE_TSCONFIG);
38
116
  write(".gitignore", ".local/\n.dist/\n");
@@ -53,32 +131,67 @@ function init(root, opts) {
53
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).`);
54
132
  if (noApp(host)) console.warn(`
55
133
  ┌─ 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. │
134
+ │ No framework, no theme CSS, no component library. ${NAME} builds │
135
+ │ frames from YOUR components - with none, designs get thrown away. │
59
136
  │ │
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. │
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. │
67
140
  └───────────────────────────────────────────────────────────────────────┘`);
68
141
  console.log(`\n commit design/ - only .local/ is ignored`);
69
142
  console.log(` uninstall = delete design/, remove the ${NAME} dependency${host.tsconfigSweepsDesign ? ", revert the \"design\" line in tsconfig exclude" : ""}`);
70
143
  console.log(`\n next: npx ${NAME} dev (canvas on http://localhost:${DEFAULTS.port} by default)\n`);
71
144
  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
145
  }
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. -->";
146
+ const MANAGED_PREFIX = "<!-- marver:managed ";
147
+ const LEGACY_PREFIX = "<!-- generated by marver init";
148
+ const hashBody = (s) => createHash("sha256").update(s).digest("hex");
149
+ /** The marker carries a hash of the generated body: edits are DETECTED, not assumed.
150
+ * Edit freely - init preserves edits and stages upstream updates for merging.
151
+ * Deleting the marker line detaches the file from updates entirely. */
152
+ 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}`;
74
153
  /** No framework, no theme, no component alias = nothing to build frames FROM. */
75
154
  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) {
155
+ /** The UI line of AGENTS.md, matched to what detection actually found (friction log #1).
156
+ * The STOP branch fires only on the same condition that creates SETUP.md - an app
157
+ * without Tailwind (plain React + CSS) gets guidance, never a dead pointer. */
158
+ function uiGuidance(host, isNoApp) {
159
+ if (isNoApp) return `STOP - this repo has no app yet. Read design/instructions/setup.md before designing anything.`;
78
160
  if (host.shadcn) return `Use the app's UI: import from ${host.shadcn.uiAlias}; style with the app's Tailwind classes.`;
79
161
  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.`;
162
+ 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/.`;
81
163
  }
164
+ const SETUP_MD = `# Setup required - this repo has no app yet
165
+
166
+ > This file exists because \`${NAME} init\` ran in a repo with no framework, no theme
167
+ > CSS, and no component library. It disappears automatically: set up the app, re-run
168
+ > \`npx ${NAME} init\`, and init deletes this file and regenerates AGENTS.md against
169
+ > the real stack. While this file exists, DO NOT design.
170
+
171
+ ${NAME} builds frames from YOUR components and YOUR theme. With none, frames become
172
+ hand-rolled CSS that shares nothing with the future app and cannot be promoted into
173
+ it later - work that gets thrown away.
174
+
175
+ ## Do this first
176
+
177
+ For a web app or marketing site, the blessed stack:
178
+
179
+ \`\`\`bash
180
+ npx create-next-app@latest . --ts --tailwind --app --src-dir
181
+ npx shadcn@latest init
182
+ \`\`\`
183
+
184
+ Any React + CSS setup works; the point is that components and a theme EXIST.
185
+
186
+ ## Then
187
+
188
+ \`\`\`bash
189
+ npx ${NAME} init
190
+ \`\`\`
191
+
192
+ init is idempotent: it fills in what it now detects (theme wrapper, providers, a
193
+ shadcn-aware AGENTS.md), deletes this file, and you design from real parts.
194
+ `;
82
195
  /** Next.js frames render OUTSIDE Next - say concretely what that means (friction log #10/#11). */
83
196
  const NEXT_NOTES = `- Next.js caveats (frames render in Vite, outside Next):
84
197
  next/font does not exist here - CSS variables it injects (e.g. --font-geist-sans) are
@@ -1,7 +1,8 @@
1
- import { r as ROUTE } from "./cli.mjs";
1
+ import { n as PKG, r as ROUTE, t as NAME } from "./cli.mjs";
2
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 { 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(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components. The tool ships no AI - your coding agent is the designer.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,12 +1,12 @@
1
1
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { useStore, CONFIG, boardLabel, cap, fetchBoardNames } from './store.ts'
3
+ import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames } from './store.ts'
4
4
  import { Tip } from './Tip.tsx'
5
- import { ROUTE } from '../const.ts'
5
+ import { PKG, ROUTE } from '../const.ts'
6
6
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
7
  import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
8
  import { bootHash, parseHash, writeHash } from './hash.ts'
9
- import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, deviceIcon } from './icons.tsx'
9
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, XIcon, deviceIcon } from './icons.tsx'
10
10
 
11
11
  let booted = false // survives Fast Refresh; see the boot effect
12
12
 
@@ -251,6 +251,45 @@ function DeviceMenu() {
251
251
  )
252
252
  }
253
253
 
254
+ /** Update pill (dev only): the daily registry check surfaces here - same glass, same
255
+ * pill, bottom-center. Click the command to copy it; × dismisses THIS version for
256
+ * good (localStorage), so the pill returns only when the next release lands. */
257
+ function UpdatePill() {
258
+ const [latest, setLatest] = useState<string | null>(null)
259
+ const play = useStore((s) => s.play)
260
+ useEffect(() => {
261
+ if (PUBLISHED) return // a shared canvas never nags its viewers
262
+ fetch(`${ROUTE}/api/update`)
263
+ .then((r) => (r.ok ? r.json() : null))
264
+ .then((u) => {
265
+ if (u?.latest && localStorage.getItem('mv-update-seen') !== u.latest) setLatest(u.latest)
266
+ })
267
+ .catch(() => { /* dev server gone or endpoint absent - stay quiet */ })
268
+ }, [])
269
+ if (!latest || play) return null
270
+ // init rides along so managed files (AGENTS.md, instructions/) refresh with the code
271
+ const cmd = `npm i -D ${PKG}@latest && npx marver init`
272
+ const dismiss = () => {
273
+ try { localStorage.setItem('mv-update-seen', latest) } catch { /* storage unavailable */ }
274
+ setLatest(null)
275
+ }
276
+ return (
277
+ <div className="sh-update">
278
+ <span><b>{latest}</b> is out</span>
279
+ <Tip side="top" label="Copy, then paste to your terminal or your agent">
280
+ <button className="cmd" onClick={() => {
281
+ const t = useStore.getState().toast
282
+ navigator.clipboard?.writeText(cmd).then(() => t('update command copied'), () => t('copy blocked - select it manually'))
283
+ ?? t('copy unavailable - select it manually')
284
+ }}><code>{cmd}</code></button>
285
+ </Tip>
286
+ <Tip side="top" label="Dismiss this version">
287
+ <button className="x" onClick={dismiss}><XIcon size={13} /></button>
288
+ </Tip>
289
+ </div>
290
+ )
291
+ }
292
+
254
293
  const ZOOMS = [2, 1.5, 1, 0.5, 0.25, 0.1]
255
294
 
256
295
  /** Zoom preset dropdown on the percentage readout. */
@@ -642,7 +681,10 @@ export function App() {
642
681
 
643
682
  <PlayOverlay />
644
683
 
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>}
684
+ {CONFIG.setup
685
+ ? <div className="sh-banner">no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart</div>
686
+ : 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>}
687
+ <UpdatePill />
646
688
 
647
689
  <div className="sh-toasts">
648
690
  {toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
@@ -11,6 +11,9 @@ import shData from 'virtual:sh-data'
11
11
  * is where `/` opens - never a synthesized aggregate of a filtered build. */
12
12
  const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string } | null = shData
13
13
 
14
+ /** True on a published static canvas - no dev server, no API, no update checks. */
15
+ export const PUBLISHED = DATA !== null
16
+
14
17
  export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string }
15
18
  export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
16
19
  export interface Node {
@@ -27,7 +30,7 @@ export interface Node {
27
30
  }
28
31
  export interface Toast { id: number; text: string }
29
32
 
30
- export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean } = shConfig
33
+ export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean } = shConfig
31
34
 
32
35
  export const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
33
36
 
@@ -194,7 +194,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
194
194
  .sh-handle.se { right: -5px; bottom: -5px; width: 11px; height: 11px; cursor: nwse-resize }
195
195
 
196
196
  /* glass surfaces share one recipe; chrome text is never selectable (shift-click = multi-select) */
197
- .sh-ctx, .sh-panel, .sh-fab, .sh-pill, .sh-pill-fab, .sh-menu, .sh-banner, .sh-toast {
197
+ .sh-ctx, .sh-panel, .sh-fab, .sh-pill, .sh-pill-fab, .sh-menu, .sh-banner, .sh-toast, .sh-update {
198
198
  background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
199
199
  border: 1px solid var(--glass-brd); box-shadow: var(--shadow-glass); position: relative;
200
200
  user-select: none; -webkit-user-select: none }
@@ -202,7 +202,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
202
202
  /* edge light: a 1px gradient ring over the border, so glass reads as glass even
203
203
  with nothing behind it to blur (masked-border technique) */
204
204
  .sh-ctx::after, .sh-panel::after, .sh-fab::after, .sh-pill::after, .sh-pill-fab::after,
205
- .sh-menu::after, .sh-banner::after, .sh-toast::after, .sh-node::after {
205
+ .sh-menu::after, .sh-banner::after, .sh-toast::after, .sh-update::after, .sh-node::after {
206
206
  content: ''; position: absolute; inset: -1px; padding: 1px; border-radius: inherit;
207
207
  background: var(--edge-light); pointer-events: none;
208
208
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
@@ -404,6 +404,25 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
404
404
  .sh-banner { position: absolute; bottom: var(--edge); right: var(--edge); z-index: 10; font: 500 12px -apple-system, system-ui, sans-serif;
405
405
  color: var(--glass-warn); border-radius: 999px; padding: 8px 14px }
406
406
 
407
+ /* update pill: bottom-center, the quietest possible member of the pill family.
408
+ The command IS the button - one click copies it for the terminal or the agent. */
409
+ .sh-update { position: absolute; left: 50%; bottom: var(--edge); transform: translateX(-50%); z-index: 10;
410
+ display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 7px 0 16px; border-radius: 999px;
411
+ max-width: min(92vw, 620px); white-space: nowrap;
412
+ font: 500 12.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-2);
413
+ animation: sh-update-in .35s cubic-bezier(.2, .9, .3, 1.2) }
414
+ @keyframes sh-update-in { from { opacity: 0; transform: translate(-50%, 8px) scale(.96) } }
415
+ .sh-update b { font-weight: 650; color: var(--glass-ink) }
416
+ .sh-update .cmd { border: 1px solid var(--glass-brd); background: var(--glass-hover); border-radius: 999px;
417
+ padding: 4px 11px; cursor: pointer; min-width: 0; overflow: hidden; transition: color .15s }
418
+ .sh-update .cmd code { font: 500 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--glass-ink-2);
419
+ display: block; overflow: hidden; text-overflow: ellipsis }
420
+ .sh-update .cmd:hover code { color: var(--glass-ink) }
421
+ .sh-update .x { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px;
422
+ border: 0; background: none; border-radius: 999px; color: var(--glass-ink-3); cursor: pointer;
423
+ transition: color .15s, background .15s }
424
+ .sh-update .x:hover { background: var(--glass-hover); color: var(--glass-ink) }
425
+
407
426
  .sh-toasts { position: absolute; left: var(--edge); bottom: var(--edge); z-index: 12; display: flex; flex-direction: column; gap: 6px }
408
427
  .sh-toast { display: flex; align-items: center; gap: 6px; font: 500 12px -apple-system, system-ui, sans-serif;
409
428
  color: var(--glass-ink-2); border-radius: 999px; padding: 7px 13px }
@@ -3,6 +3,29 @@
3
3
  You design by writing files. The canvas at the printed localhost URL reflects them live.
4
4
  Never run or talk to the canvas tool; read and write files only.
5
5
 
6
+ ## The method (binding)
7
+
8
+ Design work moves through phases. BEFORE working in a phase, read its instruction
9
+ file in design/instructions/ - they are short, strict, and part of this contract:
10
+
11
+ | Phase | When | Read |
12
+ |---|---|---|
13
+ | Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
14
+ | Discover | any new surface, feature, or flow | instructions/discover.md |
15
+ | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
16
+ | Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
17
+ | Build | hi-fi frames from real components | instructions/craft.md + components.md |
18
+ | Review | before presenting anything | instructions/review.md |
19
+ | Boards | creating a board or publishing | instructions/boards.md |
20
+
21
+ Refining an existing screen: Configure must hold, then Build + Review. New work runs
22
+ the full ladder. Unsure which phase you are in? Ask the human - one question beats a
23
+ phase of wrong work.
24
+
25
+ Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
26
+ guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
27
+ the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
28
+
6
29
  ## Frames
7
30
  - A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
8
31
  - It default-exports a React component. No imports from the tool are needed. Optional:
@@ -35,7 +58,7 @@ Never run or talk to the canvas tool; read and write files only.
35
58
  convention). The root design/scenes/_layout.tsx mounts the app's real shell component.
36
59
 
37
60
  ## Fixtures
38
- - design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the future API.
61
+ - design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the component's PROPS (containers map real APIs into them at promotion; see instructions/components.md).
39
62
  - Fixture shapes should match the component's props so tsc catches drift.
40
63
  - Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
41
64
  setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
@@ -59,29 +82,7 @@ Never run or talk to the canvas tool; read and write files only.
59
82
 
60
83
  ## Boards (curated canvases)
61
84
 
62
- A board is a saved canvas: `design/boards/<name>.json` (name: `^[a-z0-9][a-z0-9-]*$`).
63
- The human switches boards in the sidebar; YOU create and manage them by writing files.
64
- Minimal file - just list the frames; the shell fills sizes from each frame's viewport,
65
- lays it out, and keeps it tidy:
66
-
67
- ```json
68
- { "version": 1, "name": "checkout-compare", "auto": false,
69
- "nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
70
- ```
71
-
72
- - The same frame may appear on many boards, or twice on one board (e.g. two widths:
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.
77
- - `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
78
- never write it.
79
- - Use boards for comparisons: version A vs B vs C of a flow, side by side.
80
- - Boards are also the unit of PUBLISHING (`marver build --boards <name>`): a flow being
81
- published must have every frame it data-gotos on that board - unlisted frames are
82
- excluded from the bundle at build time. The published gate page shows the app's
83
- identity: design/logo.svg + the host package name (overridable via config `share`).
84
- If the app has no logo asset yet, create a simple design/logo.svg for it.
85
- Leave `share.branding` ON unless the human explicitly asks to remove it: Marver is
86
- free, the gate is already fully personalized to the app, and the small "Powered by"
87
- line is how the tool spreads. Do not remove it as part of "branding polish".
85
+ A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
86
+ by writing files; `all-scenes` is auto-managed, never write it. BEFORE creating a
87
+ board or publishing anything, read instructions/boards.md (file format, layout
88
+ durability, publishing rules).