@marver-design/marver 0.2.0 → 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 (39) hide show
  1. package/README.md +3 -2
  2. package/dist/{build-D0GnIR4G.mjs → build-CNoXE13J.mjs} +3 -3
  3. package/dist/cli.mjs +23 -7
  4. package/dist/{dev-BK4x3PBr.mjs → dev-Blyy4jOL.mjs} +24 -5
  5. package/dist/init-3h9pXEzp.mjs +337 -0
  6. package/dist/manifest-CHmKAAtG.mjs +298 -0
  7. package/dist/{plugin-DB5t2WUl.mjs → plugin-DiDJA9n-.mjs} +157 -120
  8. package/dist/{serve-BPNmWeJx.mjs → serve-BvbAbWeK.mjs} +8 -1
  9. package/package.json +4 -3
  10. package/src/client/frame-host/bridge.js +8 -2
  11. package/src/client/frame-host/main.tsx +7 -1
  12. package/src/client/shell/App.tsx +69 -8
  13. package/src/client/shell/canvas/FrameNode.tsx +9 -0
  14. package/src/client/shell/store.ts +58 -8
  15. package/src/client/shell/styles.css +21 -2
  16. package/templates/AGENTS-embedded.md +38 -26
  17. package/templates/AGENTS-studio.md +38 -26
  18. package/templates/instructions/boards.md +38 -0
  19. package/templates/instructions/brand.md +60 -0
  20. package/templates/instructions/components.md +52 -0
  21. package/templates/instructions/configure.md +44 -0
  22. package/templates/instructions/craft.md +90 -0
  23. package/templates/instructions/discover.md +52 -0
  24. package/templates/instructions/reference/color.md +53 -0
  25. package/templates/instructions/reference/concepts.md +68 -0
  26. package/templates/instructions/reference/copy.md +57 -0
  27. package/templates/instructions/reference/critique.md +53 -0
  28. package/templates/instructions/reference/delight.md +35 -0
  29. package/templates/instructions/reference/layout.md +51 -0
  30. package/templates/instructions/reference/motion.md +66 -0
  31. package/templates/instructions/reference/operate.md +38 -0
  32. package/templates/instructions/reference/slop.md +76 -0
  33. package/templates/instructions/reference/states.md +48 -0
  34. package/templates/instructions/reference/tune.md +61 -0
  35. package/templates/instructions/reference/typography.md +45 -0
  36. package/templates/instructions/review.md +51 -0
  37. package/templates/instructions/wireframe.md +49 -0
  38. package/dist/config-DMBEpdEN.mjs +0 -132
  39. package/dist/init-DcOy1krf.mjs +0 -168
@@ -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,9 @@
1
- import { r as ROUTE } from "./cli.mjs";
2
- import { join, relative, resolve, sep } from "node:path";
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-CHmKAAtG.mjs";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
4
- import { createHash, randomBytes } from "node:crypto";
5
- //#region src/server/manifest.ts
6
- const FRAME_EXT = /\.(tsx|jsx|html)$/;
7
- const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
8
- /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted (spec §6). */
9
- function extractMeta(src) {
10
- const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
11
- if (!m) return {};
12
- const body = m[1];
13
- const pick = (key) => {
14
- const r = new RegExp(`${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
15
- return r ? r[2] : void 0;
16
- };
17
- const out = {};
18
- const title = pick("title");
19
- if (title) out.title = title;
20
- const viewport = pick("viewport");
21
- if (viewport) out.viewport = viewport;
22
- const theme = pick("theme");
23
- if (theme) out.theme = theme;
24
- return out;
25
- }
26
- /** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
27
- function toFrameId(designRelPath) {
28
- const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
29
- return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
30
- }
31
- function walk(dir, out = []) {
32
- if (!existsSync(dir)) return out;
33
- for (const e of readdirSync(dir, { withFileTypes: true })) {
34
- if (e.name.startsWith(".")) continue;
35
- const p = join(dir, e.name);
36
- if (e.isDirectory()) walk(p, out);
37
- else out.push(p);
38
- }
39
- return out;
40
- }
41
- function scanFrames(root) {
42
- const design = join(root, "design");
43
- const frames = [];
44
- for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
45
- const name = abs.split(sep).pop();
46
- if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
47
- const rel = relative(design, abs).split(sep).join("/");
48
- const id = toFrameId(rel);
49
- const scene = id.includes("/") ? id.split("/")[0] : "";
50
- if (base === "scenes" && RESERVED_SCENES.has(scene)) {
51
- console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
52
- continue;
53
- }
54
- const kind = name.endsWith(".html") ? "html" : "tsx";
55
- const entry = {
56
- id,
57
- file: `design/${rel}`,
58
- kind,
59
- scene
60
- };
61
- if (kind === "tsx") {
62
- const meta = extractMeta(readFileSync(abs, "utf8"));
63
- if (meta.title) entry.title = meta.title;
64
- if (meta.viewport) entry.viewport = meta.viewport;
65
- if (meta.theme) entry.theme = meta.theme;
66
- }
67
- frames.push(entry);
68
- }
69
- const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
70
- frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
71
- const seen = /* @__PURE__ */ new Map();
72
- const deduped = frames.filter((f) => {
73
- const prev = seen.get(f.id);
74
- if (prev) {
75
- console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
76
- return false;
77
- }
78
- seen.set(f.id, f.file);
79
- return true;
80
- });
81
- frames.length = 0;
82
- frames.push(...deduped);
83
- const sceneCounts = /* @__PURE__ */ new Map();
84
- for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
85
- return {
86
- frames,
87
- scenes: [...sceneCounts.entries()].map(([name, n]) => ({
88
- name,
89
- frames: n
90
- })).sort((a, b) => a.name.localeCompare(b.name))
91
- };
92
- }
93
- /** Write design/manifest.json only when content changed. Returns the manifest either way. */
94
- function writeManifest(root, manifest) {
95
- const file = join(root, "design", "manifest.json");
96
- const next = JSON.stringify(manifest, null, 2) + "\n";
97
- const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
98
- if (hash(prev) === hash(next)) return false;
99
- mkdirSync(join(root, "design"), { recursive: true });
100
- writeFileSync(file, next);
101
- return true;
102
- }
103
- const hash = (s) => createHash("sha256").update(s).digest("hex");
104
- //#endregion
4
+ import { dirname, join, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { randomBytes } from "node:crypto";
105
7
  //#region src/server/api.ts
106
8
  const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
107
9
  const BODY_LIMIT = 1e6;
@@ -188,7 +90,7 @@ function apiMiddleware(root) {
188
90
  const p = boardPath(boardMatch[1]);
189
91
  if (!p) return json(res, 400, { error: "invalid board name" });
190
92
  if (req.method === "GET") {
191
- if (!existsSync(p)) return json(res, 404, { error: "not found" });
93
+ if (!existsSync(p)) return json(res, 200, { board: null });
192
94
  const content = readFileSync(p, "utf8");
193
95
  try {
194
96
  return json(res, 200, {
@@ -275,6 +177,7 @@ function routesMiddleware(server, clientDir) {
275
177
  try {
276
178
  const html = await server.transformIndexHtml(req.url ?? "/", page(dir));
277
179
  res.setHeader("content-type", "text/html");
180
+ res.setHeader("cache-control", "no-store");
278
181
  res.end(html);
279
182
  } catch (err) {
280
183
  res.statusCode = 500;
@@ -283,6 +186,73 @@ function routesMiddleware(server, clientDir) {
283
186
  };
284
187
  }
285
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
286
256
  //#region src/server/plugin.ts
287
257
  const VIRTUAL_THEME = "virtual:sh-theme";
288
258
  const VIRTUAL_CONFIG = "virtual:sh-config";
@@ -306,15 +276,26 @@ function marverPlugin(ctx) {
306
276
  },
307
277
  load(id) {
308
278
  if (id === "\0virtual:sh-theme.css") {
309
- console.warn("[marver] no theme detected - frames render unstyled. Set `theme` in design/config.ts.");
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).");
310
280
  return "/* marver: no theme configured */";
311
281
  }
312
- if (id === "\0virtual:sh-config") return `export default ${JSON.stringify({
313
- viewports: config.viewports,
314
- themes: config.themes,
315
- zoomSpeed: config.zoomSpeed,
316
- noTheme: themeFile() == null
317
- })}`;
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
+ }
318
299
  if (id === "\0virtual:sh-data") return "export default null";
319
300
  },
320
301
  /** HTML frames: inject theme + bridge into any design/**.html Vite serves.
@@ -354,6 +335,28 @@ function marverPlugin(ctx) {
354
335
  if (hctx.modules.length === 0) return [];
355
336
  },
356
337
  configureServer(server) {
338
+ server.middlewares.use((req, res, next) => {
339
+ if (req.url && req.url.includes("/frame-host/registry.ts")) {
340
+ const orig = res.setHeader.bind(res);
341
+ res.setHeader = ((name, value) => orig(name, String(name).toLowerCase() === "cache-control" ? "no-cache" : value));
342
+ }
343
+ next();
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
+ });
357
360
  server.middlewares.use(apiMiddleware(root));
358
361
  server.middlewares.use(routesMiddleware(server, clientDir));
359
362
  const regen = debounce(() => {
@@ -362,8 +365,39 @@ function marverPlugin(ctx) {
362
365
  }, 150);
363
366
  const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
364
367
  const inScope = (file) => watched.some((w) => file.startsWith(w));
365
- server.watcher.on("add", (f) => inScope(f) && regen());
366
- server.watcher.on("unlink", (f) => inScope(f) && regen());
368
+ const themeModules = () => {
369
+ const f = themeFile();
370
+ if (!f) return [];
371
+ const out = [];
372
+ const seen = /* @__PURE__ */ new Set();
373
+ const walk = (mod) => {
374
+ if (!mod || seen.has(mod)) return;
375
+ seen.add(mod);
376
+ out.push(mod);
377
+ for (const im of mod.clientImportedModules ?? mod.importedModules ?? []) if (String(im.id ?? "").split("?")[0].endsWith(".css")) walk(im);
378
+ };
379
+ for (const mod of server.moduleGraph.getModulesByFile(f) ?? []) walk(mod);
380
+ return out;
381
+ };
382
+ const pushTheme = debounce(() => {
383
+ for (const mod of themeModules()) server.reloadModule(mod).catch(() => {});
384
+ }, 150);
385
+ const rescanTheme = () => {
386
+ for (const mod of themeModules()) server.moduleGraph.invalidateModule(mod);
387
+ pushTheme();
388
+ };
389
+ server.watcher.on("add", (f) => {
390
+ if (inScope(f)) {
391
+ regen();
392
+ rescanTheme();
393
+ }
394
+ });
395
+ server.watcher.on("unlink", (f) => {
396
+ if (inScope(f)) {
397
+ regen();
398
+ rescanTheme();
399
+ }
400
+ });
367
401
  server.watcher.on("change", (f) => inScope(f) && /\.(tsx|jsx)$/.test(f) && regen());
368
402
  writeManifest(root, scanFrames(root));
369
403
  const boardsDir = join(root, "design", "boards");
@@ -397,15 +431,18 @@ function debounce(fn, ms) {
397
431
  t = setTimeout(() => fn(...a), ms);
398
432
  });
399
433
  }
400
- /** Extra vite plugin when the host runs Tailwind v4: use their own @tailwindcss/vite. */
434
+ /** Extra vite plugin when the host runs Tailwind v4. The host's own @tailwindcss/vite
435
+ * wins (version-matched to their tailwindcss); the fallback is marver's bundled copy -
436
+ * Next.js hosts use @tailwindcss/postcss and never have the vite plugin (the blessed
437
+ * stack would otherwise render every frame without its utility classes). */
401
438
  async function tailwind4Plugin(root) {
402
- try {
403
- const mod = await import(join(root, "node_modules", "@tailwindcss", "vite", "dist", "index.mjs"));
439
+ const factories = [() => import(join(root, "node_modules", "@tailwindcss", "vite", "dist", "index.mjs")), () => import("@tailwindcss/vite")];
440
+ for (const load of factories) try {
441
+ const mod = await load();
404
442
  const result = (mod.default ?? mod)();
405
443
  return Array.isArray(result) ? result : [result];
406
- } catch {
407
- return null;
408
- }
444
+ } catch {}
445
+ return null;
409
446
  }
410
447
  /** Tailwind v3: inline PostCSS config extending the host's tailwind config with design/ globs. Host files untouched. */
411
448
  async function tailwind3Css(root) {
@@ -434,4 +471,4 @@ async function tailwind3Css(root) {
434
471
  return null;
435
472
  }
436
473
  //#endregion
437
- export { scanFrames as i, tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
474
+ export { tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
@@ -1,6 +1,6 @@
1
1
  import { t as NAME } from "./cli.mjs";
2
- import { extname, isAbsolute, join, relative, resolve } from "node:path";
3
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
5
  import { createServer } from "node:http";
6
6
  //#region src/server/serve.ts
@@ -115,6 +115,13 @@ function serve(root, portFlag) {
115
115
  }
116
116
  });
117
117
  const port = portFlag ?? (Number(process.env.PORT) || 4199);
118
+ server.on("error", (err) => {
119
+ if (err.code === "EADDRINUSE") {
120
+ console.error(`[${NAME}] port ${port} is already in use - another \`${NAME} serve\` still running? Stop it, or pass --port <n>.`);
121
+ process.exit(1);
122
+ }
123
+ throw err;
124
+ });
118
125
  server.listen(port, () => {
119
126
  console.log(`\n ${NAME} serving design/.dist → http://localhost:${port}/`);
120
127
  console.log(verifier ? " gate: ON (MARVER_PASSWORD set)\n" : " gate: off - set MARVER_PASSWORD to require a password\n");
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.2.0",
3
+ "version": "0.2.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,
7
7
  "license": "Apache-2.0",
8
8
  "bin": {
9
- "marver": "./dist/cli.mjs"
9
+ "marver": "dist/cli.mjs"
10
10
  },
11
11
  "files": [
12
12
  "dist",
@@ -27,6 +27,7 @@
27
27
  "typecheck": "tsc --noEmit"
28
28
  },
29
29
  "dependencies": {
30
+ "@tailwindcss/vite": "^4.0.0",
30
31
  "@vitejs/plugin-react": "^6.0.0",
31
32
  "cac": "^7.0.0",
32
33
  "react-zoom-pan-pinch": "^3.6.1",
@@ -56,4 +57,4 @@
56
57
  "publishConfig": {
57
58
  "access": "public"
58
59
  }
59
- }
60
+ }