mockpit 0.0.0-stage → 1.1.0

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 (86) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +239 -2
  3. package/bin/demoData.js +132 -0
  4. package/bin/initDesign.js +846 -0
  5. package/bin/mockpit.js +1835 -0
  6. package/bin/serveUrl.d.ts +1 -0
  7. package/bin/serveUrl.js +7 -0
  8. package/dist/mcp/server.d.ts +2 -0
  9. package/dist/mcp/server.js +252 -0
  10. package/dist/server/apiViews.d.ts +329 -0
  11. package/dist/server/apiViews.js +135 -0
  12. package/dist/server/app.d.ts +55 -0
  13. package/dist/server/app.js +3134 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +16 -0
  16. package/dist/server/cdn.d.ts +6 -0
  17. package/dist/server/cdn.js +33 -0
  18. package/dist/server/codeRunner.d.ts +5 -0
  19. package/dist/server/codeRunner.js +89 -0
  20. package/dist/server/codeWorker.d.ts +1 -0
  21. package/dist/server/codeWorker.js +196 -0
  22. package/dist/server/designGuide.d.ts +16 -0
  23. package/dist/server/designGuide.js +311 -0
  24. package/dist/server/eventLog.d.ts +27 -0
  25. package/dist/server/eventLog.js +187 -0
  26. package/dist/server/events.d.ts +53 -0
  27. package/dist/server/events.js +14 -0
  28. package/dist/server/feedbackBatch.d.ts +43 -0
  29. package/dist/server/feedbackBatch.js +98 -0
  30. package/dist/server/icons.d.ts +50 -0
  31. package/dist/server/icons.js +225 -0
  32. package/dist/server/index.d.ts +1 -0
  33. package/dist/server/index.js +76 -0
  34. package/dist/server/kits.d.ts +36 -0
  35. package/dist/server/kits.js +279 -0
  36. package/dist/server/knobs.d.ts +15 -0
  37. package/dist/server/knobs.js +333 -0
  38. package/dist/server/logFile.d.ts +2 -0
  39. package/dist/server/logFile.js +27 -0
  40. package/dist/server/mcpHttp.d.ts +53 -0
  41. package/dist/server/mcpHttp.js +143 -0
  42. package/dist/server/mcpSpec.d.ts +191 -0
  43. package/dist/server/mcpSpec.js +402 -0
  44. package/dist/server/parts.d.ts +31 -0
  45. package/dist/server/parts.js +338 -0
  46. package/dist/server/postMarkdown.d.ts +10 -0
  47. package/dist/server/postMarkdown.js +271 -0
  48. package/dist/server/postSurfaces.d.ts +40 -0
  49. package/dist/server/postSurfaces.js +407 -0
  50. package/dist/server/public.d.ts +5 -0
  51. package/dist/server/public.js +5 -0
  52. package/dist/server/push.d.ts +45 -0
  53. package/dist/server/push.js +234 -0
  54. package/dist/server/richRender.d.ts +15 -0
  55. package/dist/server/richRender.js +392 -0
  56. package/dist/server/run.d.ts +82 -0
  57. package/dist/server/run.js +290 -0
  58. package/dist/server/runApi.d.ts +6 -0
  59. package/dist/server/runApi.js +82 -0
  60. package/dist/server/slots.d.ts +14 -0
  61. package/dist/server/slots.js +71 -0
  62. package/dist/server/sqlStore.d.ts +119 -0
  63. package/dist/server/sqlStore.js +954 -0
  64. package/dist/server/sqliteStorage.d.ts +2 -0
  65. package/dist/server/sqliteStorage.js +90 -0
  66. package/dist/server/surfacePage.d.ts +48 -0
  67. package/dist/server/surfacePage.js +1099 -0
  68. package/dist/server/themes.d.ts +48 -0
  69. package/dist/server/themes.js +305 -0
  70. package/dist/server/types.d.ts +599 -0
  71. package/dist/server/types.js +299 -0
  72. package/dist/workers/public.d.ts +1 -0
  73. package/dist/workers/public.js +3 -0
  74. package/extensions/mockpit.js +717 -0
  75. package/guide/AGENT_SETUP.md +23 -0
  76. package/guide/topics/asks.md +87 -0
  77. package/guide/topics/feedback.md +127 -0
  78. package/guide/topics/html.md +175 -0
  79. package/guide/topics/http.md +76 -0
  80. package/guide/topics/knobs.md +72 -0
  81. package/guide/topics/scripts.md +129 -0
  82. package/guide/topics/surfaces.md +100 -0
  83. package/package.json +165 -4
  84. package/skills/launch-video/SKILL.md +159 -0
  85. package/skills/mockpit/SKILL.md +40 -0
  86. package/viewer/dist/index.html +1922 -0
@@ -0,0 +1,846 @@
1
+ // Design-system detection for `mockpit init`. Node built-ins only, so the CLI
2
+ // keeps its zero-runtime-dependency promise and can run straight from a
3
+ // checkout or from the packed npm tarball.
4
+ //
5
+ // The point of this file is that the agent never assembles a design set by
6
+ // hand: init reads what the repo already declares (Tailwind, shadcn, a `:root`
7
+ // token block, fonts), turns it into the project's DesignSettings, and writes a
8
+ // starter file the agent copies from. Everything here is deterministic — no
9
+ // model in the loop.
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { createRequire } from "node:module";
13
+ import { readdirSync, readFileSync, statSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { basename, join, relative } from "node:path";
16
+
17
+ // The palette mapper lives in server/themes.ts so the server and the CLI can
18
+ // never disagree about what a repo's tokens mean. Node type-strips the .ts in a
19
+ // checkout; the packed CLI has only the compiled tree, hence the fallback.
20
+ async function loadThemes() {
21
+ try {
22
+ return await import("../server/themes.ts");
23
+ } catch {
24
+ return await import("../dist/server/themes.js");
25
+ }
26
+ }
27
+
28
+ // Where repos actually keep their global stylesheet. Ordered by how likely the
29
+ // first hit is to be THE token file, because the first file with the most
30
+ // custom properties wins.
31
+ const CSS_ROOTS = [
32
+ "src",
33
+ "app",
34
+ "styles",
35
+ "css",
36
+ "web/src",
37
+ "web/app",
38
+ "web/styles",
39
+ "assets",
40
+ "public",
41
+ "",
42
+ ];
43
+ const SKIP_DIRS = new Set([
44
+ "node_modules",
45
+ ".git",
46
+ "dist",
47
+ "build",
48
+ ".next",
49
+ ".nuxt",
50
+ ".output",
51
+ "coverage",
52
+ "vendor",
53
+ "__snapshots__",
54
+ ]);
55
+ const MAX_FILES = 200;
56
+ const MAX_DEPTH = 5;
57
+
58
+ function exists(path) {
59
+ try {
60
+ statSync(path);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function readJson(path) {
68
+ try {
69
+ return JSON.parse(readFileSync(path, "utf8"));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // Walk a root for .css files (or what `match` accepts), bounded by MAX_FILES
76
+ // across the whole scan so a monorepo can't turn `mockpit init` into a
77
+ // full-disk crawl.
78
+ function findCssFiles(cwd, budget, match = (name) => name.endsWith(".css")) {
79
+ const found = [];
80
+ const walk = (dir, depth) => {
81
+ if (found.length >= budget.left || depth > MAX_DEPTH) return;
82
+ let entries;
83
+ try {
84
+ entries = readdirSync(dir, { withFileTypes: true });
85
+ } catch {
86
+ return;
87
+ }
88
+ for (const e of entries) {
89
+ if (found.length >= budget.left) return;
90
+ if (e.name.startsWith(".") && e.name !== ".") continue;
91
+ const full = join(dir, e.name);
92
+ if (e.isDirectory()) {
93
+ if (SKIP_DIRS.has(e.name)) continue;
94
+ walk(full, depth + 1);
95
+ } else if (e.isFile() && match(e.name)) {
96
+ budget.left--;
97
+ found.push(full);
98
+ }
99
+ }
100
+ };
101
+ for (const root of CSS_ROOTS) {
102
+ if (budget.left <= 0) break;
103
+ const dir = root ? join(cwd, root) : cwd;
104
+ if (!exists(dir)) continue;
105
+ walk(dir, root ? 1 : 0);
106
+ }
107
+ return [...new Set(found)];
108
+ }
109
+
110
+ // --- minimal CSS block reader --------------------------------------------
111
+ // Only enough to pull declaration blocks out; it parses files we did not write
112
+ // and must never throw.
113
+
114
+ function readBlocks(css) {
115
+ const out = [];
116
+ const walk = (text) => {
117
+ let i = 0;
118
+ while (i < text.length) {
119
+ const open = text.indexOf("{", i);
120
+ if (open === -1) return;
121
+ // Trailing statement at-rules (`@import …;`) sit in the same slice.
122
+ const selector = text.slice(i, open).split(";").pop().trim();
123
+ let depth = 1;
124
+ let j = open + 1;
125
+ while (j < text.length && depth > 0) {
126
+ const ch = text[j];
127
+ if (ch === "{") depth++;
128
+ else if (ch === "}") depth--;
129
+ j++;
130
+ }
131
+ const body = text.slice(open + 1, depth === 0 ? j - 1 : text.length);
132
+ if (body.includes("{")) walk(body);
133
+ else out.push({ selector, body });
134
+ i = j;
135
+ }
136
+ };
137
+ walk(css.replace(/\/\*[\s\S]*?\*\//g, ""));
138
+ return out;
139
+ }
140
+
141
+ const TOKEN_SELECTOR = /^(:root|html|body|:host|\*|\.dark|:root\.dark|html\.dark|\[data-theme)/i;
142
+ const THEME_AT_RULE = /^@theme\b/i;
143
+
144
+ function customProps(body) {
145
+ const props = [];
146
+ const re = /--([\w-]+)\s*:\s*([^;]+)/g;
147
+ let m;
148
+ while ((m = re.exec(body))) props.push([m[1], m[2].trim()]);
149
+ return props;
150
+ }
151
+
152
+ // The raw token text injected into every html surface for this project. Token
153
+ // blocks (`:root`, `.dark`, …) are kept verbatim so the repo's own values —
154
+ // including `--radius` and anything mockpit doesn't map — reach the sandbox.
155
+ // `@theme` blocks are NOT valid CSS outside Tailwind's compiler, so only their
156
+ // resolvable declarations (not the `var(--x)` aliases Tailwind re-exports) are
157
+ // folded into `:root`.
158
+ function extractTokens(css) {
159
+ const blocks = readBlocks(css);
160
+ const pieces = [];
161
+ const themeProps = [];
162
+ let count = 0;
163
+ for (const b of blocks) {
164
+ const props = customProps(b.body);
165
+ if (props.length === 0) continue;
166
+ if (TOKEN_SELECTOR.test(b.selector)) {
167
+ pieces.push(`${b.selector}{${b.body.trim()}}`);
168
+ count += props.length;
169
+ } else if (THEME_AT_RULE.test(b.selector)) {
170
+ for (const [name, value] of props) {
171
+ if (value.startsWith("var(")) continue;
172
+ themeProps.push(`--${name}: ${value};`);
173
+ count++;
174
+ }
175
+ }
176
+ }
177
+ if (themeProps.length) pieces.unshift(`:root{${themeProps.join("")}}`);
178
+ return { text: pieces.join("\n"), count };
179
+ }
180
+
181
+ // Font families the repo declares: @font-face names plus any `font-family` /
182
+ // `--font-*` token. Reported so the brief guide can tell the agent which
183
+ // families are actually available in the frame.
184
+ function extractFonts(css) {
185
+ const fonts = new Set();
186
+ const add = (value) => {
187
+ const first = value
188
+ .split(",")[0]
189
+ .trim()
190
+ .replace(/^["']|["']$/g, "");
191
+ if (first && !/^(inherit|initial|unset|var\()/.test(first) && !first.startsWith("-apple")) {
192
+ fonts.add(first);
193
+ }
194
+ };
195
+ for (const m of css.matchAll(/@font-face\s*\{[^}]*font-family\s*:\s*([^;}]+)/gi)) add(m[1]);
196
+ for (const m of css.matchAll(/--font(?:-family)?[\w-]*\s*:\s*([^;}]+)/gi)) add(m[1]);
197
+ for (const m of css.matchAll(/(?:^|[;{])\s*font-family\s*:\s*([^;}]+)/gi)) add(m[1]);
198
+ return [...fonts].slice(0, 8);
199
+ }
200
+
201
+ // The only ids the Tailwind v4 browser build resolves for `@import`; any other
202
+ // import, and every `@plugin` or `@config`, makes its compile throw, which
203
+ // would leave the frame with no utilities at all.
204
+ const TAILWIND_CORE_IMPORTS = new Set([
205
+ "tailwindcss",
206
+ ...["preflight", "theme", "utilities"].flatMap((f) => [
207
+ `tailwindcss/${f}`,
208
+ `tailwindcss/${f}.css`,
209
+ ]),
210
+ ]);
211
+ const TAILWIND_ENTRY = /@import\s+(?:url\(\s*)?["']tailwindcss["']/;
212
+ const TAILWIND_V3 = /@tailwind\s+(base|components|utilities)/;
213
+ const IMPORT_RULE =
214
+ /@(import|plugin|config)\s+(url\(\s*(?:"[^"]*"|'[^']*'|[^)]*?)\s*\)|"[^"]*"|'[^']*')[^;]*;?/g;
215
+ const TAILWIND_CSS_MAX = 128_000;
216
+
217
+ const unquote = (s) =>
218
+ s
219
+ .replace(/^url\(\s*|\s*\)$/g, "")
220
+ .trim()
221
+ .replace(/^["']|["']$/g, "");
222
+
223
+ /**
224
+ * The repo's Tailwind entry stylesheet reduced to what the browser build can
225
+ * compile, so the frame gets the repo's own theme and `bg-card` works.
226
+ * `{css, strippedImports}`, or null when the result would not compile or fit.
227
+ */
228
+ export function tailwindSource(css) {
229
+ // A v3 entry's theme lives in tailwind.config, which the browser build
230
+ // cannot load; its `@apply border-border` would then throw.
231
+ if (TAILWIND_V3.test(css)) return null;
232
+ const stripped = [];
233
+ let text = css.replace(/\/\*[\s\S]*?\*\//g, "");
234
+ text = text.replace(IMPORT_RULE, (rule, kind, target) => {
235
+ const id = unquote(target);
236
+ if (kind === "import" && TAILWIND_CORE_IMPORTS.has(id)) return rule;
237
+ stripped.push(id);
238
+ return "";
239
+ });
240
+ text = text.replace(/\n\s*\n(\s*\n)+/g, "\n\n").trim();
241
+ if (!/@import\b/.test(text)) text = `@import "tailwindcss";\n${text}`;
242
+ if (text.length > TAILWIND_CSS_MAX) return null;
243
+ return { css: text, strippedImports: [...new Set(stripped)] };
244
+ }
245
+
246
+ function hasTailwind(cwd) {
247
+ if (
248
+ ["js", "cjs", "mjs", "ts", "cts", "mts"].some((ext) =>
249
+ exists(join(cwd, `tailwind.config.${ext}`)),
250
+ )
251
+ ) {
252
+ return true;
253
+ }
254
+ const pkg = readJson(join(cwd, "package.json"));
255
+ const deps = pkg ? { ...pkg.dependencies, ...pkg.devDependencies } : {};
256
+ return Boolean(deps.tailwindcss || deps["@tailwindcss/vite"] || deps["@tailwindcss/postcss"]);
257
+ }
258
+
259
+ /**
260
+ * Inspect a repo and derive the project's DesignSettings inputs.
261
+ * Never throws on a hostile/odd tree — an undetectable repo returns the
262
+ * builtin kit with no palette, which is a perfectly good starting point.
263
+ */
264
+ export async function detectDesign(cwd) {
265
+ const { paletteFromCssVars } = await loadThemes();
266
+ const budget = { left: MAX_FILES };
267
+ const cssFiles = findCssFiles(cwd, budget);
268
+
269
+ let best = null;
270
+ let entry = null;
271
+ const fonts = new Set();
272
+ for (const file of cssFiles) {
273
+ let css;
274
+ try {
275
+ css = readFileSync(file, "utf8");
276
+ } catch {
277
+ continue;
278
+ }
279
+ if (TAILWIND_ENTRY.test(css) || TAILWIND_V3.test(css)) {
280
+ const count = customProps(css).length;
281
+ if (!entry || count > entry.count) entry = { file, css, count };
282
+ }
283
+ if (!/@theme\b|:root\s*\{|\.dark\s*\{/.test(css)) continue;
284
+ const tokens = extractTokens(css);
285
+ for (const f of extractFonts(css)) fonts.add(f);
286
+ if (tokens.count > 0 && (!best || tokens.count > best.count)) {
287
+ best = { ...tokens, file };
288
+ }
289
+ }
290
+
291
+ const tailwind = hasTailwind(cwd) || entry !== null;
292
+ const shadcn = Boolean(readJson(join(cwd, "components.json"))?.aliases);
293
+ const cssVars = best ? best.text : "";
294
+ let tailwindFile = tailwind ? (entry?.file ?? best?.file ?? null) : null;
295
+ let tw = null;
296
+ if (tailwindFile) {
297
+ try {
298
+ tw = tailwindSource(entry ? entry.css : readFileSync(tailwindFile, "utf8"));
299
+ } catch {
300
+ tw = null;
301
+ }
302
+ if (!tw) tailwindFile = null;
303
+ }
304
+ const rel = (file) => relative(cwd, file) || basename(file);
305
+ return {
306
+ detected: {
307
+ tailwind,
308
+ shadcn,
309
+ cssVars: best ? best.count : 0,
310
+ fonts: [...fonts].slice(0, 8),
311
+ },
312
+ palette: cssVars ? paletteFromCssVars(cssVars) : null,
313
+ cssVars,
314
+ // Where the tokens came from, relative to the repo — `init` prints it so
315
+ // the operator can see which file was imported.
316
+ source: best ? rel(best.file) : null,
317
+ kit: tailwind ? "tailwind" : "builtin",
318
+ tailwindCss: tw ? tw.css : "",
319
+ strippedImports: tw ? tw.strippedImports : [],
320
+ tailwindSource: tailwindFile ? rel(tailwindFile) : null,
321
+ designFiles: readDesignFiles(cwd, `${cssVars}\n${tw ? tw.css : ""}`),
322
+ };
323
+ }
324
+
325
+ // --- design files ---------------------------------------------------------
326
+ // What a repo already says about its design, read as data for the brief:
327
+ // Google Labs' DESIGN.md, DTCG token files, and shadcn's components.json.
328
+ // Every reader here parses files we did not write and returns null, never
329
+ // throws, on anything it does not understand.
330
+
331
+ const DOS_MAX = 1_500;
332
+ const TOKENS_MAX = 400;
333
+ const COMPONENTS_MAX = 80;
334
+ const VALUE_MAX = 120;
335
+ // The design PUT refuses a larger designFiles blob; init sheds tokens first.
336
+ export const DESIGN_FILES_MAX = 32_000;
337
+
338
+ function readText(path) {
339
+ try {
340
+ return readFileSync(path, "utf8");
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ function scalar(raw) {
347
+ let v = raw.trim();
348
+ if (v.startsWith('"') || v.startsWith("'")) {
349
+ const end = v.indexOf(v[0], 1);
350
+ return end === -1 ? v.slice(1) : v.slice(1, end);
351
+ }
352
+ // `primary: #fff` is a comment to YAML, but a color is what the author meant.
353
+ const comment = v.search(/\s#/);
354
+ if (comment !== -1) v = v.slice(0, comment).trim();
355
+ return v;
356
+ }
357
+
358
+ // Block-style YAML maps only: `key: value` and `key:` over an indented block,
359
+ // any depth. Lists, anchors and flow collections are skipped, which is all a
360
+ // DESIGN.md front matter needs for its token maps.
361
+ function readYamlMap(text) {
362
+ const root = {};
363
+ const stack = [{ indent: -1, node: root }];
364
+ for (const line of text.split(/\r?\n/)) {
365
+ const trimmed = line.trim();
366
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
367
+ const m = line.match(/^(\s*)("[^"]*"|'[^']*'|[^:#\s][^:]*?)\s*:(?:\s+(.*))?$/);
368
+ if (!m) continue;
369
+ const indent = m[1].length;
370
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
371
+ const parent = stack[stack.length - 1].node;
372
+ const key = scalar(m[2]);
373
+ const value = m[3] === undefined ? "" : scalar(m[3]);
374
+ if (value === "" || value === "|" || value === ">") {
375
+ parent[key] = {};
376
+ stack.push({ indent, node: parent[key] });
377
+ } else {
378
+ parent[key] = value;
379
+ }
380
+ }
381
+ return root;
382
+ }
383
+
384
+ const isMap = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
385
+
386
+ function pathGet(root, path) {
387
+ let node = root;
388
+ for (const k of path.split(".")) {
389
+ if (!isMap(node) || !Object.hasOwn(node, k)) return undefined;
390
+ node = node[k];
391
+ }
392
+ return node;
393
+ }
394
+
395
+ // `{colors.primary}` becomes the value it names, following a short chain.
396
+ function resolveRefs(root, value) {
397
+ let v = value;
398
+ for (let hop = 0; hop < 4; hop++) {
399
+ const m = v.match(/^\{([\w.-]+)\}$/);
400
+ const target = m ? pathGet(root, m[1]) : undefined;
401
+ if (typeof target !== "string") break;
402
+ v = target;
403
+ }
404
+ return v;
405
+ }
406
+
407
+ function flatMap(root, node, depth) {
408
+ const out = {};
409
+ if (!isMap(node)) return out;
410
+ for (const [k, v] of Object.entries(node)) {
411
+ if (typeof v === "string") out[k] = resolveRefs(root, v).slice(0, VALUE_MAX);
412
+ else if (depth > 0 && isMap(v)) out[k] = flatMap(root, v, depth - 1);
413
+ }
414
+ return out;
415
+ }
416
+
417
+ /** A DESIGN.md's token maps, section headings and Do's and Don'ts text. */
418
+ export function readDesignMd(text) {
419
+ try {
420
+ const fm = text.match(/^?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
421
+ const yaml = fm ? readYamlMap(fm[1]) : {};
422
+ const body = fm ? text.slice(fm[0].length) : text;
423
+ const headings = [...body.matchAll(/^##[ \t]+(.+?)[ \t#]*$/gm)].map((m) => m[1]).slice(0, 20);
424
+ let dos = "";
425
+ const start = body.search(/^##[ \t]+Do[’']?s[ \t]+(?:and|&)[ \t]+Don[’']?ts\b/im);
426
+ if (start !== -1) {
427
+ const rest = body.slice(start).replace(/^.*(?:\r?\n|$)/, "");
428
+ const next = rest.search(/^##\s/m);
429
+ dos = (next === -1 ? rest : rest.slice(0, next)).trim().slice(0, DOS_MAX);
430
+ }
431
+ return {
432
+ name: typeof yaml.name === "string" ? yaml.name.slice(0, VALUE_MAX) : "",
433
+ colors: flatMap(yaml, yaml.colors, 0),
434
+ typography: flatMap(yaml, yaml.typography, 1),
435
+ rounded: flatMap(yaml, yaml.rounded, 0),
436
+ spacing: flatMap(yaml, yaml.spacing, 0),
437
+ components: flatMap(yaml, yaml.components, 1),
438
+ headings,
439
+ dos,
440
+ };
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+
446
+ const TOKEN_TYPES = new Set(["color", "dimension", "fontFamily", "fontWeight", "number"]);
447
+ const isTokenFile = (name) =>
448
+ name === "tokens.json" || name === "design-tokens.json" || name.endsWith(".tokens.json");
449
+
450
+ // DTCG 2025.10 values may be structured; the brief wants one CSS-like string.
451
+ function tokenText(v) {
452
+ if (typeof v === "string" || typeof v === "number") return String(v);
453
+ if (Array.isArray(v)) return v.map(tokenText).join(", ");
454
+ if (isMap(v)) {
455
+ if (typeof v.hex === "string") return v.hex;
456
+ if (v.value !== undefined && typeof v.unit === "string") return `${v.value}${v.unit}`;
457
+ if (typeof v.colorSpace === "string" && Array.isArray(v.components)) {
458
+ return `color(${v.colorSpace} ${v.components.join(" ")})`;
459
+ }
460
+ }
461
+ return null;
462
+ }
463
+
464
+ function collectTokens(node, path, inherited, out) {
465
+ if (!isMap(node)) return;
466
+ const type = typeof node.$type === "string" ? node.$type : inherited;
467
+ if (Object.hasOwn(node, "$value")) {
468
+ out.set(path.join("."), { type, value: node.$value });
469
+ return;
470
+ }
471
+ for (const [k, v] of Object.entries(node)) {
472
+ if (!k.startsWith("$")) collectTokens(v, [...path, k], type, out);
473
+ }
474
+ }
475
+
476
+ // `{a.b}` or `{"$ref": "#/a/b/$value"}` gives `a.b`; a plain value gives null.
477
+ function aliasTarget(value) {
478
+ if (typeof value === "string") return value.match(/^\{([^{}]+)\}$/)?.[1] ?? null;
479
+ if (isMap(value) && typeof value.$ref === "string") {
480
+ return value.$ref
481
+ .replace(/^#\//, "")
482
+ .replace(/\/\$value$/, "")
483
+ .split("/")
484
+ .join(".");
485
+ }
486
+ return null;
487
+ }
488
+
489
+ /** DTCG token files flattened to `name -> value`, `{count, values}` or null. */
490
+ export function readTokenFiles(files) {
491
+ const all = new Map();
492
+ for (const file of files) {
493
+ const json = readJson(file);
494
+ if (isMap(json)) collectTokens(json, [], undefined, all);
495
+ }
496
+ const values = {};
497
+ let count = 0;
498
+ for (const [name, token] of all) {
499
+ let { type, value } = token;
500
+ // One level deep: an alias to an alias stays a reference.
501
+ const target = aliasTarget(value);
502
+ if (target && all.has(target)) {
503
+ type ??= all.get(target).type;
504
+ value = all.get(target).value;
505
+ }
506
+ if (!TOKEN_TYPES.has(type)) continue;
507
+ const ref = aliasTarget(value);
508
+ const text = ref ? `{${ref}}` : tokenText(value);
509
+ if (text === null) continue;
510
+ count++;
511
+ if (count <= TOKENS_MAX) values[name] = text.slice(0, VALUE_MAX);
512
+ }
513
+ return count ? { count, values } : null;
514
+ }
515
+
516
+ // tsconfig is JSONC: comments and trailing commas, outside strings.
517
+ function readJsonc(path) {
518
+ const text = readText(path);
519
+ if (text === null) return null;
520
+ let out = "";
521
+ for (let i = 0; i < text.length; i++) {
522
+ const ch = text[i];
523
+ if (ch === '"') {
524
+ let j = i + 1;
525
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
526
+ out += text.slice(i, j + 1);
527
+ i = j;
528
+ } else if (ch === "/" && text[i + 1] === "/") {
529
+ while (i < text.length && text[i] !== "\n") i++;
530
+ out += "\n";
531
+ } else if (ch === "/" && text[i + 1] === "*") {
532
+ const end = text.indexOf("*/", i + 2);
533
+ i = end === -1 ? text.length : end + 1;
534
+ } else out += ch;
535
+ }
536
+ try {
537
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
538
+ } catch {
539
+ return null;
540
+ }
541
+ }
542
+
543
+ // Where shadcn put its components: the `ui` alias mapped through tsconfig
544
+ // `paths`, then the two layouts the shadcn CLI writes by default.
545
+ function uiDirs(cwd, alias) {
546
+ const dirs = [];
547
+ if (alias) {
548
+ const compiler = readJsonc(join(cwd, "tsconfig.json"))?.compilerOptions ?? {};
549
+ const base = join(cwd, typeof compiler.baseUrl === "string" ? compiler.baseUrl : ".");
550
+ for (const [pattern, targets] of Object.entries(isMap(compiler.paths) ? compiler.paths : {})) {
551
+ const prefix = pattern.replace(/\*$/, "");
552
+ if (!pattern.endsWith("*") || !alias.startsWith(prefix) || !Array.isArray(targets)) continue;
553
+ for (const t of targets) {
554
+ if (typeof t !== "string") continue;
555
+ dirs.push(join(base, t.replace(/\*$/, ""), alias.slice(prefix.length)));
556
+ }
557
+ }
558
+ if (!/^[@~#]/.test(alias)) dirs.push(join(cwd, alias));
559
+ }
560
+ dirs.push(join(cwd, "src", "components", "ui"), join(cwd, "components", "ui"));
561
+ return dirs;
562
+ }
563
+
564
+ function readShadcn(cwd) {
565
+ const config = readJson(join(cwd, "components.json"));
566
+ if (!isMap(config)) return null;
567
+ const aliases = isMap(config.aliases) ? config.aliases : {};
568
+ let alias = "";
569
+ if (typeof aliases.ui === "string") alias = aliases.ui;
570
+ else if (typeof aliases.components === "string") alias = `${aliases.components}/ui`;
571
+ let components = [];
572
+ for (const dir of uiDirs(cwd, alias)) {
573
+ try {
574
+ components = readdirSync(dir, { withFileTypes: true })
575
+ .filter((e) => e.isDirectory() || /\.(tsx|ts|jsx|js|vue|svelte)$/.test(e.name))
576
+ .map((e) => e.name.replace(/\.[^.]+$/, ""))
577
+ .filter((n) => !n.startsWith(".") && n !== "index");
578
+ break;
579
+ } catch {
580
+ // not this layout
581
+ }
582
+ }
583
+ const str = (v) => (typeof v === "string" ? v.slice(0, 60) : "");
584
+ return {
585
+ style: str(config.style),
586
+ baseColor: str(isMap(config.tailwind) ? config.tailwind.baseColor : ""),
587
+ iconLibrary: str(config.iconLibrary),
588
+ components: [...new Set(components)].sort().slice(0, COMPONENTS_MAX),
589
+ };
590
+ }
591
+
592
+ /**
593
+ * The repo's own design descriptions: DESIGN.md at the root, DTCG token
594
+ * files, and shadcn's components.json. `css` is the stylesheet text the frame
595
+ * gets, to tell whether token names also exist as CSS custom properties.
596
+ * Null when the repo has none of them.
597
+ */
598
+ export function readDesignFiles(cwd, css = "") {
599
+ const files = {};
600
+ const md = readText(join(cwd, "DESIGN.md"));
601
+ const designMd = md === null ? null : readDesignMd(md);
602
+ if (designMd) files.designMd = designMd;
603
+ const tokenFiles = findCssFiles(cwd, { left: MAX_FILES }, isTokenFile);
604
+ const tokens = tokenFiles.length ? readTokenFiles(tokenFiles) : null;
605
+ if (tokens) {
606
+ const names = Object.keys(tokens.values);
607
+ // A DTCG file emits no CSS by itself: `var(--name)` works only for a
608
+ // token whose dashed name the frame's stylesheet declares.
609
+ const declared = new Set(customProps(css).map(([name]) => name));
610
+ files.tokens = {
611
+ files: tokenFiles.map((f) => relative(cwd, f)).slice(0, 20),
612
+ count: tokens.count,
613
+ cssVars:
614
+ tokens.count === names.length && names.every((n) => declared.has(n.replace(/\./g, "-"))),
615
+ values: tokens.values,
616
+ };
617
+ // Tokens are the one unbounded part; shed them until the blob fits.
618
+ while (JSON.stringify(files).length > DESIGN_FILES_MAX && names.length) {
619
+ for (const n of names.splice(Math.floor(names.length / 2))) delete files.tokens.values[n];
620
+ }
621
+ }
622
+ const shadcn = readShadcn(cwd);
623
+ if (shadcn) files.shadcn = shadcn;
624
+ return Object.keys(files).length ? files : null;
625
+ }
626
+
627
+ // --- icon sets ------------------------------------------------------------
628
+
629
+ // Icon packages a repo may already use, mapped to the Iconify prefix that
630
+ // draws the same icons. react-icons bundles many sets under one name, so it
631
+ // maps to none.
632
+ const ICON_PACKAGES = [
633
+ [/^@iconify-json\/([a-z0-9][a-z0-9-]*)$/, (m) => m[1]],
634
+ [/^lucide(-[a-z-]+)?$/, () => "lucide"],
635
+ [/^@tabler\/icons/, () => "tabler"],
636
+ [/^@phosphor-icons\//, () => "ph"],
637
+ [/^@heroicons\//, () => "heroicons"],
638
+ ];
639
+
640
+ function dirNames(dir) {
641
+ try {
642
+ return readdirSync(dir, { withFileTypes: true })
643
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
644
+ .map((e) => e.name);
645
+ } catch {
646
+ return [];
647
+ }
648
+ }
649
+
650
+ /**
651
+ * The Iconify prefixes this repo's own icon packages correspond to, read from
652
+ * package.json and the top level of node_modules. `[{prefix, from}]`, one per
653
+ * prefix.
654
+ */
655
+ export function detectIconSets(cwd) {
656
+ const pkg = readJson(join(cwd, "package.json"));
657
+ const names = new Set(Object.keys({ ...pkg?.dependencies, ...pkg?.devDependencies }));
658
+ const modules = join(cwd, "node_modules");
659
+ for (const name of dirNames(modules)) {
660
+ if (!name.startsWith("@")) names.add(name);
661
+ else for (const sub of dirNames(join(modules, name))) names.add(`${name}/${sub}`);
662
+ }
663
+ const found = new Map();
664
+ for (const name of [...names].sort()) {
665
+ for (const [re, toPrefix] of ICON_PACKAGES) {
666
+ const m = name.match(re);
667
+ if (!m) continue;
668
+ const prefix = toPrefix(m);
669
+ if (!found.has(prefix)) found.set(prefix, { prefix, from: name });
670
+ }
671
+ }
672
+ return [...found.values()];
673
+ }
674
+
675
+ // Only what draws an icon; info, categories and search hints are dropped so a
676
+ // large set stays under the asset size limit.
677
+ function slimIconSet(set) {
678
+ const out = { prefix: set.prefix, icons: set.icons };
679
+ for (const k of ["aliases", "width", "height", "left", "top"]) {
680
+ if (set[k] !== undefined) out[k] = set[k];
681
+ }
682
+ return out;
683
+ }
684
+
685
+ function readIconSet(path, prefix) {
686
+ const set = readJson(path);
687
+ return set && set.prefix === prefix && set.icons && typeof set.icons === "object" ? set : null;
688
+ }
689
+
690
+ const versionParts = (v) => v.split(/[.@-]/).map((n) => Number(n) || 0);
691
+ const newestFirst = (a, b) => {
692
+ const [x, y] = [versionParts(a), versionParts(b)];
693
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
694
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0);
695
+ }
696
+ return 0;
697
+ };
698
+
699
+ // Installed copies that cost a directory listing at most: the project (and
700
+ // its parents, as Node resolves), the global npm root, and bun's cache. The
701
+ // pnpm store is content-addressed, so it is not searched.
702
+ function localIconSetPaths(prefix, cwd) {
703
+ const paths = [];
704
+ const file = `@iconify-json/${prefix}/icons.json`;
705
+ try {
706
+ paths.push(createRequire(join(cwd, "noop.js")).resolve(file));
707
+ } catch {
708
+ // not installed in the project
709
+ }
710
+ try {
711
+ const root = execFileSync("npm", ["root", "-g"], {
712
+ encoding: "utf8",
713
+ timeout: 3000,
714
+ stdio: ["ignore", "pipe", "ignore"],
715
+ }).trim();
716
+ if (root) paths.push(join(root, file));
717
+ } catch {
718
+ // no npm on PATH
719
+ }
720
+ const bunCache = join(homedir(), ".bun", "install", "cache", "@iconify-json");
721
+ const cached = dirNames(bunCache)
722
+ .filter((d) => d.startsWith(`${prefix}@`))
723
+ .map((d) => d.slice(prefix.length + 1))
724
+ .sort(newestFirst);
725
+ for (const version of cached) paths.push(join(bunCache, `${prefix}@${version}`, "icons.json"));
726
+ return paths;
727
+ }
728
+
729
+ export const ICONIFY_CDN = "https://cdn.jsdelivr.net/npm";
730
+
731
+ /**
732
+ * Find the Iconify JSON set for `prefix`: an installed copy if one is cheap to
733
+ * reach, else jsDelivr. Resolves `{ set, source }`; rejects with a one-line
734
+ * message when the set does not exist or cannot be fetched.
735
+ */
736
+ export async function findIconSet(prefix, cwd) {
737
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(prefix)) {
738
+ throw new Error(`"${prefix}" is not an Iconify prefix (lowercase, e.g. lucide)`);
739
+ }
740
+ for (const path of localIconSetPaths(prefix, cwd)) {
741
+ const set = readIconSet(path, prefix);
742
+ if (set) return { set: slimIconSet(set), source: path };
743
+ }
744
+ const url = `${ICONIFY_CDN}/@iconify-json/${prefix}/icons.json`;
745
+ let res;
746
+ try {
747
+ res = await fetch(url, { signal: AbortSignal.timeout(20_000) });
748
+ } catch (err) {
749
+ const why = err?.cause?.code ?? err?.name ?? "network error";
750
+ throw new Error(`could not fetch ${prefix} from jsDelivr (${why})`);
751
+ }
752
+ if (res.status === 404) {
753
+ throw new Error(`no icon set "${prefix}" on jsDelivr (@iconify-json/${prefix})`);
754
+ }
755
+ if (!res.ok) throw new Error(`could not fetch ${prefix} from jsDelivr (${res.status})`);
756
+ const set = await res.json().catch(() => null);
757
+ if (!set || set.prefix !== prefix || !set.icons) {
758
+ throw new Error(`@iconify-json/${prefix} on jsDelivr is not an Iconify set`);
759
+ }
760
+ return { set: slimIconSet(set), source: url };
761
+ }
762
+
763
+ // --- starter file ---------------------------------------------------------
764
+
765
+ // The starter is the whole point of init from the agent's side: one file that
766
+ // already speaks the project's kit, tokens and icons, so the first publish is a
767
+ // copy-and-edit rather than a guess. Two bodies, because the class vocabulary
768
+ // differs: Tailwind's utilities vs the builtin kit's components.
769
+ // The theme classes resolve because the frame compiles the repo's own Tailwind
770
+ // entry stylesheet (`tailwindCss`).
771
+ const TAILWIND_BODY = `<section class="flex flex-col gap-3 rounded-xl border border-border bg-card p-6 text-card-foreground">
772
+ <h2 class="text-lg font-semibold">Title</h2>
773
+ <p class="text-sm text-muted-foreground">Body copy.</p>
774
+ <div class="flex gap-2">
775
+ <button class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground">ICON_SLOT Action</button>
776
+ <button class="inline-flex items-center rounded-lg border border-border px-4 py-2 text-sm font-medium">Cancel</button>
777
+ </div>
778
+ </section>`;
779
+
780
+ const BUILTIN_BODY = `<section class="card">
781
+ <div class="card-header">
782
+ <h2 class="card-title">Title</h2>
783
+ <p class="card-desc">Body copy.</p>
784
+ </div>
785
+ <div class="card-footer">
786
+ <button class="btn btn-primary">ICON_SLOT Action</button>
787
+ <button class="btn btn-ghost">Cancel</button>
788
+ </div>
789
+ </section>`;
790
+
791
+ // Basecoat 1.x: a card is header/section/footer children and variants are
792
+ // `data-variant`, not the builtin's `.btn-primary` classes.
793
+ const BASECOAT_BODY = `<div class="card">
794
+ <header>
795
+ <h2>Title</h2>
796
+ <p>Body copy.</p>
797
+ </header>
798
+ <footer>
799
+ <button class="btn">ICON_SLOT Action</button>
800
+ <button class="btn" data-variant="ghost">Cancel</button>
801
+ </footer>
802
+ </div>`;
803
+
804
+ const STARTER_TOKENS = [
805
+ "background",
806
+ "foreground",
807
+ "card",
808
+ "primary",
809
+ "muted-foreground",
810
+ "border",
811
+ "radius",
812
+ "font-sans",
813
+ ];
814
+
815
+ /**
816
+ * The text of `.mockpit/starter.html` — a body fragment (never a full
817
+ * document; that is the html contract) showing the project's kit classes, its
818
+ * tokens, and one icon in use. `iconSets` are the prefixes the server resolves
819
+ * for this project.
820
+ */
821
+ export function renderStarter(design, iconSets = ["lucide", "mage"]) {
822
+ const kit = design?.kit ?? "builtin";
823
+ const bodies = { tailwind: TAILWIND_BODY, basecoat: BASECOAT_BODY };
824
+ const body = (bodies[kit] ?? BUILTIN_BODY).replace("ICON_SLOT", '<i icon="lucide:check"></i>');
825
+ // Show the tokens an agent reaches for first. A repo's block is usually led
826
+ // by sizing/easing tokens, so surface the semantic colors ahead of them.
827
+ const props = customProps(design?.cssVars ?? "");
828
+ const rank = (name) => {
829
+ const i = STARTER_TOKENS.indexOf(name);
830
+ return i === -1 ? STARTER_TOKENS.length : i;
831
+ };
832
+ const tokens = props
833
+ .slice()
834
+ .sort((a, b) => rank(a[0]) - rank(b[0]))
835
+ .slice(0, 6)
836
+ .map(([name, value]) => ` var(--${name}) = ${value}`)
837
+ .join("\n");
838
+ return `<!-- .mockpit/starter.html — copy this, don't publish it as-is.
839
+ Send the BODY FRAGMENT only; mockpit wraps it in a themed sandbox.
840
+ kit: ${kit} · icons: ${iconSets.join(", ")}, as <i icon="prefix:name"></i>
841
+ ${tokens ? ` tokens imported from this repo:\n${tokens}\n` : ""} Colors come from these vars or from mockpit's --color-* tokens
842
+ (--color-text-primary, --color-background-primary, …), never a hardcoded
843
+ hex — every surface has to read in both light and dark. -->
844
+ ${body}
845
+ `;
846
+ }