mockpit 3.0.0 → 3.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 (54) hide show
  1. package/README.md +11 -23
  2. package/bin/initDesign.js +11 -16
  3. package/bin/mockpit.js +242 -52
  4. package/bin/poster.js +64 -7
  5. package/dist/mcp/server.js +48 -6
  6. package/dist/server/apiViews.d.ts +562 -557
  7. package/dist/server/apiViews.js +18 -9
  8. package/dist/server/app.js +723 -206
  9. package/dist/server/cdnInline.js +6 -4
  10. package/dist/server/codeWorker.js +2 -2
  11. package/dist/server/contrast.d.ts +4 -0
  12. package/dist/server/contrast.js +157 -0
  13. package/dist/server/decisionList.d.ts +4 -1
  14. package/dist/server/decisionList.js +43 -5
  15. package/dist/server/designGuide.d.ts +13 -6
  16. package/dist/server/designGuide.js +206 -41
  17. package/dist/server/events.d.ts +6 -0
  18. package/dist/server/feedbackBatch.d.ts +4 -0
  19. package/dist/server/feedbackBatch.js +19 -1
  20. package/dist/server/guidance.d.ts +1 -1
  21. package/dist/server/guidance.js +2 -2
  22. package/dist/server/kits.d.ts +2 -2
  23. package/dist/server/kits.js +54 -1
  24. package/dist/server/mcpHttp.d.ts +2 -0
  25. package/dist/server/mcpHttp.js +13 -1
  26. package/dist/server/mcpSpec.d.ts +27 -27
  27. package/dist/server/mcpSpec.js +78 -10
  28. package/dist/server/run.js +22 -1
  29. package/dist/server/runApi.d.ts +2 -2
  30. package/dist/server/runApi.js +9 -2
  31. package/dist/server/sqlStore.d.ts +68 -39
  32. package/dist/server/sqlStore.js +163 -26
  33. package/dist/server/surfacePage.d.ts +4 -2
  34. package/dist/server/surfacePage.js +80 -32
  35. package/dist/server/system.d.ts +234 -0
  36. package/dist/server/system.js +1508 -0
  37. package/dist/server/systemChecks.d.ts +15 -0
  38. package/dist/server/systemChecks.js +224 -0
  39. package/dist/server/systemSpecimen.d.ts +2 -0
  40. package/dist/server/systemSpecimen.js +49 -0
  41. package/dist/server/themes.d.ts +9 -7
  42. package/dist/server/themes.js +43 -90
  43. package/dist/server/types.d.ts +43 -24
  44. package/dist/server/types.js +3 -1
  45. package/guide/AGENT_SETUP.md +3 -1
  46. package/guide/packs/foundations.md +35 -0
  47. package/guide/topics/asks.md +2 -1
  48. package/guide/topics/feedback.md +5 -3
  49. package/guide/topics/html.md +2 -2
  50. package/guide/topics/http.md +3 -1
  51. package/guide/topics/system.md +157 -0
  52. package/package.json +12 -12
  53. package/skills/mockpit/SKILL.md +7 -4
  54. package/viewer/dist/index.html +9 -8
package/README.md CHANGED
@@ -7,19 +7,7 @@ The agent publishes a screen in its states and a few variants, then ends its tur
7
7
  them from pictures, tune knobs, comment on parts and keep the runners-up. One Send gives the
8
8
  agent the whole reply. Nothing it calls waits for you.
9
9
 
10
- <table>
11
- <tr>
12
- <td width="50%" valign="top">
13
- <picture>
14
- <source media="(prefers-color-scheme: dark)" srcset="docs/mockpit-dark.png">
15
- <img width="100%" alt="The Writer design asking Quiet, dark or editorial?, with the three variants as picture answers beside the stage" src="docs/mockpit-light.png">
16
- </picture>
17
- </td>
18
- <td width="50%" valign="top">
19
- <img width="100%" alt="Animated demo: the user keeps Dark, hides Editorial, picks Quiet, answers where the trim goes and presses Send; the reply shows Not seen yet, then Delivered, and the agent publishes version 2" src="docs/mockpit-demo.gif">
20
- </td>
21
- </tr>
22
- </table>
10
+ <a href="docs/mockpit-demo.gif"><img width="100%" alt="Animated demo: on HALDE, a shop design with three product grids, the pointer hovers Gallery and Editorial, each previewing on the stage, then picks Gallery" src="docs/mockpit-demo.gif"></a>
23
11
 
24
12
  ## Quickstart
25
13
 
@@ -44,9 +32,9 @@ Then ask it to "design this up on mockpit". Claude Code, Codex, OpenCode, Pi and
44
32
  ## A turn
45
33
 
46
34
  ```sh
47
- mockpit publish --design writer --state Writing --variant quiet --html quiet.html
48
- mockpit publish --design writer --state Writing --variant dark --html dark.html
49
- mockpit ask --design writer "Quiet or dark?" --option Quiet=quiet --option Dark=dark
35
+ mockpit publish --design shop --state Grid --variant index --html index.html
36
+ mockpit publish --design shop --state Grid --variant gallery --html gallery.html
37
+ mockpit ask --design shop "Which grid?" --option Index=index --option Gallery=gallery
50
38
  # the agent says where to look and stops. After your Send:
51
39
  mockpit feedback
52
40
  ```
@@ -64,18 +52,18 @@ mockpit feedback
64
52
  Studio puts every variant side by side. Home groups a project's ideas, screens, flows and
65
53
  components, and Needs you lists what's waiting on you.
66
54
 
67
- ## What it shows
68
-
69
- Html with parts and knobs, plus markdown, diff, terminal, image, mermaid, json and code. All of
70
- it renders in a sandboxed frame.
71
-
72
55
  <table>
73
56
  <tr>
74
- <td width="50%"><img src="docs/surfaces/01-html.png" width="100%" alt="html surface"></td>
75
- <td width="50%"><img src="docs/surfaces/03-diff.png" width="100%" alt="diff surface"></td>
57
+ <td width="50%"><a href="docs/mockpit-question.png"><img src="docs/mockpit-question.png" width="100%" alt="The HALDE shop asking Which grid?, with Index, Gallery and Editorial as picture answers beside the stage"></a></td>
58
+ <td width="50%"><a href="docs/mockpit-preview.png"><img src="docs/mockpit-preview.png" width="100%" alt="The product page previewing the Bone answer to What colour behind the products? before it is picked"></a></td>
76
59
  </tr>
77
60
  </table>
78
61
 
62
+ ## What it shows
63
+
64
+ Html with parts and knobs, plus markdown, diff, terminal, image, mermaid, json and code. All of
65
+ it renders in a sandboxed frame.
66
+
79
67
  ## Agent side
80
68
 
81
69
  Eleven verbs, the same on the zero-dependency CLI, MCP (stdio, or HTTP at `/mcp`) and plain
package/bin/initDesign.js CHANGED
@@ -14,17 +14,6 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { basename, join, relative } from "node:path";
16
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
17
  // Where repos actually keep their global stylesheet. Ordered by how likely the
29
18
  // first hit is to be THE token file, because the first file with the most
30
19
  // custom properties wins.
@@ -262,7 +251,6 @@ function hasTailwind(cwd) {
262
251
  * builtin kit with no palette, which is a perfectly good starting point.
263
252
  */
264
253
  export async function detectDesignSystem(cwd) {
265
- const { paletteFromCssVars } = await loadThemes();
266
254
  const budget = { left: MAX_FILES };
267
255
  const cssFiles = findCssFiles(cwd, budget);
268
256
 
@@ -309,7 +297,6 @@ export async function detectDesignSystem(cwd) {
309
297
  cssVars: best ? best.count : 0,
310
298
  fonts: [...fonts].slice(0, 8),
311
299
  },
312
- palette: cssVars ? paletteFromCssVars(cssVars) : null,
313
300
  cssVars,
314
301
  // Where the tokens came from, relative to the repo — `init` prints it so
315
302
  // the operator can see which file was imported.
@@ -494,6 +481,7 @@ export function readTokenFiles(files) {
494
481
  if (isMap(json)) collectTokens(json, [], undefined, all);
495
482
  }
496
483
  const values = {};
484
+ const types = {};
497
485
  let count = 0;
498
486
  for (const [name, token] of all) {
499
487
  let { type, value } = token;
@@ -508,9 +496,12 @@ export function readTokenFiles(files) {
508
496
  const text = ref ? `{${ref}}` : tokenText(value);
509
497
  if (text === null) continue;
510
498
  count++;
511
- if (count <= TOKENS_MAX) values[name] = text.slice(0, VALUE_MAX);
499
+ if (count <= TOKENS_MAX) {
500
+ values[name] = text.slice(0, VALUE_MAX);
501
+ types[name] = type;
502
+ }
512
503
  }
513
- return count ? { count, values } : null;
504
+ return count ? { count, values, types } : null;
514
505
  }
515
506
 
516
507
  // tsconfig is JSONC: comments and trailing commas, outside strings.
@@ -613,10 +604,14 @@ export function readDesignFiles(cwd, css = "") {
613
604
  cssVars:
614
605
  tokens.count === names.length && names.every((n) => declared.has(n.replace(/\./g, "-"))),
615
606
  values: tokens.values,
607
+ types: tokens.types,
616
608
  };
617
609
  // Tokens are the one unbounded part; shed them until the blob fits.
618
610
  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];
611
+ for (const n of names.splice(Math.floor(names.length / 2))) {
612
+ delete files.tokens.values[n];
613
+ delete files.tokens.types[n];
614
+ }
620
615
  }
621
616
  }
622
617
  const shadcn = readShadcn(cwd);
package/bin/mockpit.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  mkdirSync,
7
7
  readdirSync,
8
8
  readFileSync,
9
+ statSync,
9
10
  writeFileSync,
10
11
  writeSync,
11
12
  } from "node:fs";
@@ -13,7 +14,7 @@ import { homedir, tmpdir, userInfo } from "node:os";
13
14
  import { dirname, join } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { parseArgs } from "node:util";
16
- import { findBrowser, queuePoster } from "./poster.js";
17
+ import { findBrowser, queuePoster, queueRetakes } from "./poster.js";
17
18
  import { serveUrl } from "./serveUrl.js";
18
19
 
19
20
  const BASE = (process.env.MOCKPIT_URL ?? "http://localhost:8228").replace(/\/$/, "");
@@ -53,8 +54,8 @@ the loop (never blocks on the user):
53
54
 
54
55
  verbs:
55
56
  mockpit init [--project name] [--kit <id>] [--kit-url <url> --kit-doc <file|text>]
56
- detect the repo's design system, store
57
- palette/kit/icon sets, write .mockpit/starter.html
57
+ read the repo's tokens, kit and icon sets
58
+ into the project's system, write .mockpit/starter.html
58
59
  mockpit publish --design <slug> --html <file> [options]
59
60
  create or version one variant of one state
60
61
  --state <label> state, in the user's words (omit for a single-state design)
@@ -92,7 +93,8 @@ verbs:
92
93
  mockpit ask --design <slug> "<question>" --option <label[=variant]> ...
93
94
  ask the user; bind options to variants
94
95
  --option <l[=v]> an option (repeatable); "=variant" binds it to a variant,
95
- "=variant@3" to its version 3, "=knob:path=value" sets one knob
96
+ "=variant@3" to its version 3, "=knob:path=value" sets one knob,
97
+ "=palette:id" chooses a palette of the project's system
96
98
  --recommend <l> the option you would pick, by label; a quiet mark on it
97
99
  --scope <s> design|state|part (default design)
98
100
  --state <label> the state a state-scoped question is about
@@ -118,6 +120,9 @@ verbs:
118
120
  rename a project
119
121
  mockpit edit --project <name> --area-order "Shell,Food"
120
122
  the order Home lists areas in
123
+ mockpit system [--sheet f.css] [--classes j] [--tokens j] [--scales j] [--palettes j] ...
124
+ the project's system: read it, or change a part
125
+ (applies to every design at once)
121
126
  mockpit export --design <slug> [--state s] [--variant v] [--out <dir>]
122
127
  write the html in use + history per state
123
128
  to .mockpit/accepted/<design>/<state>/
@@ -169,7 +174,7 @@ environment:
169
174
  const COMMAND_HELP = {
170
175
  init: `mockpit init [--project <name>] [--kit <id>]
171
176
  [--kit-url <https url> [--kit-script <url>] --kit-doc <file.md|text>]
172
- Detect the repo's design system, store palette + kit for the project, add the
177
+ Read the repo's tokens and kit into the project's system, add the
173
178
  Iconify sets matching its icon packages, and write .mockpit/starter.html
174
179
  (gitignored). --kit makes a bundled or project kit the default for every html
175
180
  surface. --kit-url defines the project's own kit (id from --kit, else
@@ -178,7 +183,8 @@ const COMMAND_HELP = {
178
183
  mockpit rules --mine
179
184
  Guidance on top of the core brief, highest first: the project's
180
185
  .mockpit/rules.md (committed with the repo), your ~/.mockpit/rules.md, then
181
- the packs either names (\`packs: [design-craft]\` in its frontmatter). A file
186
+ the packs either names (\`packs: [design-craft, foundations]\` in its frontmatter:
187
+ design-craft for how to design, foundations for starting a project's system). A file
182
188
  has "## Rules" (always followed) and "## Suggestions" (aimed for).
183
189
  This sends the file as it is on disk; a missing file clears the stored copy.
184
190
  init sends the project's too. A local server reads yours live, so --mine is
@@ -297,11 +303,24 @@ mockpit edit --project <name> --area-order "Shell,Food,Chat"
297
303
  edit of it stands, so the draft is then not applied and the result says so.
298
304
  --first-state names a design's one unnamed state, and every variant it holds goes
299
305
  there, so the design can then have more states.
300
- --rename moves the project's designs, sessions, flows and design settings to the
306
+ --rename moves the project's designs, sessions, flows, system and settings to the
301
307
  new name (refused if that project has designs); pass --project <new> or set
302
308
  MOCKPIT_PROJECT afterwards if the repo still resolves to the old name.
303
309
  --area-order lists the project's areas in the order Home shows them; areas it
304
310
  leaves out follow alphabetically, "" goes back to alphabetical.`,
311
+ system: `mockpit system [--project <name>] [--json]
312
+ mockpit system [--sheet <file.css>|""] [--classes <json>] [--fonts <json>] [--tokens <json>]
313
+ [--scales <json>] [--roles <json>] [--palettes <json>] [--pairs <json>] [--as <json>]
314
+ [--knobs <json>] [--remove <json>]
315
+ mockpit system --restore <rev>
316
+ The project's system: a shared stylesheet, typed tokens, type and space scales, colour
317
+ palettes and project knobs that every render of the project wears. With no flags it
318
+ prints the system, its checks and its latest revisions (--json: all of it). Each flag
319
+ replaces that part and leaves the rest; --tokens, --scales and --palettes upsert by
320
+ name or id, --remove '{"tokens":["x"]}' takes them out. A JSON flag may name a file.
321
+ Every change is a new revision the user can restore. The repo's own tokens (from
322
+ mockpit init) can't be written here: change the code and rerun init.
323
+ mockpit guide --topic system has the contract.`,
305
324
  run: `mockpit run <file|-> [--session <id>] [--project <name>] [--json]
306
325
  Run a script (the body of an async function, plain JavaScript) in the server's
307
326
  sandbox against the mockpit API; see mockpit guide --topic scripts. Prints each
@@ -322,8 +341,8 @@ mockpit edit --project <name> --area-order "Shell,Food,Chat"
322
341
  Upload an asset; prints its id and URL. Use the id as an image surface's assetId.`,
323
342
  guide: `mockpit guide [--topic <id>] [--project <name>]
324
343
  No flag prints the brief: the loop, parts, questions and knobs, feedback, and
325
- this project's palette, kit and icons. --topic prints one reference topic
326
- (knobs, asks, surfaces, html, feedback, http, scripts).`,
344
+ this project's system, kit and icons. --topic prints one reference topic
345
+ (system, knobs, asks, surfaces, html, feedback, http, scripts).`,
327
346
  };
328
347
 
329
348
  // `console.log(...)` on a pipe is asynchronous, so exiting on the next line
@@ -728,20 +747,55 @@ async function installIconSets(project, prefixes) {
728
747
  errors.push({ prefix, message: err instanceof Error ? err.message : String(err) });
729
748
  }
730
749
  }
731
- if (added.length === 0) return { added: [], errors, designSystem: null };
732
- const stored = (await api(`${projectPath(project)}/design-system`)) ?? {};
750
+ if (added.length === 0) return { added: [], errors, system: null };
751
+ const stored = (await api(`${projectPath(project)}/system`)) ?? {};
733
752
  const keep = (stored.iconSets ?? []).filter((s) => !added.some((a) => a.prefix === s.prefix));
734
- const designSystem = await api(`${projectPath(project)}/design-system`, {
753
+ await api(`${projectPath(project)}/system`, {
735
754
  method: "PUT",
736
- body: JSON.stringify({ ...stored, iconSets: [...keep, ...added] }),
755
+ body: JSON.stringify({ iconSets: [...keep, ...added] }),
737
756
  });
757
+ const system = await api(`${projectPath(project)}/system`);
738
758
  return {
739
- added: designSystem.iconSets.filter((s) => added.some((a) => a.prefix === s.prefix)),
759
+ added: system.iconSets.filter((s) => added.some((a) => a.prefix === s.prefix)),
740
760
  errors,
741
- designSystem,
761
+ system,
742
762
  };
743
763
  }
744
764
 
765
+ // Every page of the project looks different after a system change, so its stills are
766
+ // taken again in the background; until then the overviews run the pages live.
767
+ function retakeStills(project, session) {
768
+ queueRetakes({ base: BASE, token: TOKEN, client: HEADERS["x-mockpit-client"], session, project });
769
+ }
770
+
771
+ // The system at a glance; `--json` prints all of it.
772
+ function printSystem(s) {
773
+ const palettes = s.color?.palettes ?? [];
774
+ const lines = [
775
+ `${s.project} · r${s.rev}${s.kit && s.kit !== "none" ? ` · kit ${s.kit}` : ""}`,
776
+ s.sheet
777
+ ? `sheet ${(s.sheet.css.length / 1000).toFixed(1)} KB · ${s.sheet.classes.length} classes`
778
+ : null,
779
+ s.fonts?.length ? `fonts ${s.fonts.map((f) => f.family).join(", ")}` : null,
780
+ s.scales?.length
781
+ ? `scales ${s.scales.map((x) => `${x.id} (${x.steps.join(" ")})`).join(", ")}`
782
+ : null,
783
+ s.tokens?.length
784
+ ? `tokens ${s.tokens.filter((t) => !t.scale).length} (+${s.tokens.filter((t) => t.scale).length} from scales)`
785
+ : null,
786
+ s.color?.roles?.length ? `roles ${s.color.roles.map((r) => r.name).join(", ")}` : null,
787
+ palettes.length
788
+ ? `palettes ${palettes.map((p) => `${p.label} (${p.status})`).join(", ")}`
789
+ : null,
790
+ Object.keys(s.knobs ?? {}).length ? `knobs ${Object.keys(s.knobs).join(", ")}` : null,
791
+ ].filter(Boolean);
792
+ for (const l of lines) console.log(l);
793
+ for (const c of s.checks ?? []) console.log(`check: ${c}`);
794
+ for (const r of (s.revisions ?? []).slice(0, 5))
795
+ console.log(`r${r.rev} ${r.at.slice(0, 16)} ${r.summary}`);
796
+ if (s.url) console.log(s.url);
797
+ }
798
+
745
799
  // `--doc` is a file when one exists at that path, else the cheat sheet itself.
746
800
  function readKitDoc(doc) {
747
801
  if (doc === undefined) return undefined;
@@ -754,9 +808,9 @@ function readKitDoc(doc) {
754
808
  // The server checks the URLs against the CDN allowlist and the doc's length;
755
809
  // its one-line error is the message.
756
810
  function putProjectKit(project, id, { url, script, doc }) {
757
- return api(`${projectPath(project)}/kits/${encodeURIComponent(id)}`, {
811
+ return api(`${projectPath(project)}/system`, {
758
812
  method: "PUT",
759
- body: JSON.stringify({ href: url, script, doc: readKitDoc(doc) }),
813
+ body: JSON.stringify({ projectKits: [{ id, href: url, script, doc: readKitDoc(doc) }] }),
760
814
  });
761
815
  }
762
816
 
@@ -902,6 +956,30 @@ const DESIGN_FLAGS = {
902
956
  session: { type: "string" },
903
957
  };
904
958
 
959
+ // `mockpit export` with no design: the project's system as files code can import.
960
+ async function exportSystem(flags) {
961
+ const project = resolveProject(flags).name;
962
+ const data = await api(
963
+ `/api/projects/${encodeURIComponent(project)}/system/export`,
964
+ {},
965
+ { report: "die", fix: "mockpit system" },
966
+ );
967
+ if (flags.json) return out(data);
968
+ const dir = flags.out ? flags.out : join(process.cwd(), ".mockpit", "system");
969
+ mkdirSync(dir, { recursive: true });
970
+ const files = [];
971
+ const write = (name, text) => {
972
+ if (!text) return;
973
+ writeFileSync(join(dir, name), text);
974
+ files.push(name);
975
+ };
976
+ write("sheet.css", data.sheet);
977
+ write("tokens.css", data.css);
978
+ const { sheet: _s, css: _c, ...rest } = data;
979
+ write("system.json", JSON.stringify(rest, null, 2) + "\n");
980
+ if (!flags.quiet) console.log(`${project} system r${data.rev} → ${dir} (${files.join(", ")})`);
981
+ }
982
+
905
983
  function requireDesign(flags, example) {
906
984
  const slug = flags.design ? slugify(flags.design) : "";
907
985
  if (!slug) die("--design needs a slug", example);
@@ -1208,7 +1286,14 @@ async function publishDesign() {
1208
1286
  // "Before=quiet@2" to its version 2 ("=@2" names the state's only variant);
1209
1287
  // "Above=knob:trim.position=top" sets one knob. The value is JSON when it
1210
1288
  // parses (17, true), else a string, so slider and toggle values keep their type.
1289
+ // "Grape=palette:grape" chooses a palette of the project's system.
1211
1290
  function parseOption(raw) {
1291
+ const palette = raw.indexOf("=palette:");
1292
+ if (palette > 0) {
1293
+ const id = raw.slice(palette + "=palette:".length);
1294
+ if (!id) die(`option "${raw}" needs palette:<id>`, "--option Grape=palette:grape");
1295
+ return { label: raw.slice(0, palette), palette: id };
1296
+ }
1212
1297
  const knob = raw.indexOf("=knob:");
1213
1298
  if (knob > 0) {
1214
1299
  const pair = raw.slice(knob + "=knob:".length);
@@ -1530,7 +1615,7 @@ const commands = {
1530
1615
  child.on("exit", (code) => process.exit(code ?? 0));
1531
1616
  },
1532
1617
 
1533
- // Detect the repo's design system once, store it on the project, and leave a
1618
+ // Read the repo's tokens and kit once into the project's system, and leave a
1534
1619
  // starter file behind — so every later publish is markup only, with no CSS or
1535
1620
  // icon paths pasted into the agent's context.
1536
1621
  async init() {
@@ -1553,7 +1638,7 @@ const commands = {
1553
1638
  const designSystem = await detectDesignSystem(process.cwd());
1554
1639
  const d = designSystem.detected;
1555
1640
  // `detected` is always an object, so emptiness has to be read off the
1556
- // fields — otherwise a repo with no design system printed a blank line.
1641
+ // fields — otherwise a repo with nothing to read printed a blank line.
1557
1642
  const stripped = designSystem.strippedImports?.length
1558
1643
  ? ` (stripped: ${designSystem.strippedImports.join(", ")})`
1559
1644
  : "";
@@ -1573,14 +1658,18 @@ const commands = {
1573
1658
  found.length ? found.join(" · ") : "nothing detected — using the built-in palette",
1574
1659
  );
1575
1660
  let kit = flags.kit ?? designSystem.kit ?? "builtin";
1576
- // The project kit is stored first so the design PUT can name it as default.
1661
+ // A project kit goes in the same write, so the system can name it as default.
1662
+ let projectKits;
1577
1663
  if (flags["kit-url"] !== undefined) {
1578
1664
  kit = flags.kit ?? "project";
1579
- await putProjectKit(project, kit, {
1580
- url: flags["kit-url"],
1581
- script: flags["kit-script"],
1582
- doc: flags["kit-doc"],
1583
- });
1665
+ projectKits = [
1666
+ {
1667
+ id: kit,
1668
+ href: flags["kit-url"],
1669
+ script: flags["kit-script"],
1670
+ doc: readKitDoc(flags["kit-doc"]),
1671
+ },
1672
+ ];
1584
1673
  }
1585
1674
  say("kit:", kit);
1586
1675
  const files = designSystem.designFiles;
@@ -1598,18 +1687,45 @@ const commands = {
1598
1687
  );
1599
1688
  }
1600
1689
 
1601
- let stored = await api(`${projectPath(project)}/design-system`, {
1690
+ const wrote = await api(`${projectPath(project)}/system`, {
1602
1691
  method: "PUT",
1603
1692
  body: JSON.stringify({
1604
- detected: designSystem.detected ?? null,
1605
- palette: designSystem.palette ?? null,
1606
- cssVars: designSystem.cssVars ?? "",
1607
- tailwindCss: designSystem.tailwindCss ?? "",
1608
- strippedImports: designSystem.strippedImports ?? [],
1693
+ repo: {
1694
+ detected: designSystem.detected ?? null,
1695
+ cssVars: designSystem.cssVars ?? "",
1696
+ tailwindCss: designSystem.tailwindCss ?? "",
1697
+ strippedImports: designSystem.strippedImports ?? [],
1698
+ designFiles: designSystem.designFiles ?? null,
1699
+ },
1609
1700
  kit,
1610
- designFiles: designSystem.designFiles ?? null,
1701
+ ...(projectKits ? { projectKits } : {}),
1611
1702
  }),
1612
1703
  });
1704
+ if (wrote?.changed?.length) {
1705
+ const session = await resolveSession(flags);
1706
+ if (session) retakeStills(project, session);
1707
+ }
1708
+ const derived = wrote?.derived;
1709
+ if (derived) {
1710
+ say(
1711
+ "system:",
1712
+ [
1713
+ `r${wrote.rev}`,
1714
+ derived.roles
1715
+ ? `${derived.roles} colour role${derived.roles === 1 ? "" : "s"} (palette repo)`
1716
+ : null,
1717
+ derived.tokens
1718
+ ? `${derived.tokens} token${derived.tokens === 1 ? "" : "s"}, repo-owned`
1719
+ : null,
1720
+ derived.untyped?.length
1721
+ ? `${derived.untyped.length} untyped, from the raw block only`
1722
+ : null,
1723
+ ]
1724
+ .filter(Boolean)
1725
+ .join(" · "),
1726
+ );
1727
+ }
1728
+ let stored = await api(`${projectPath(project)}/system`);
1613
1729
 
1614
1730
  // Sets the repo already uses are added the same way `icons add` does; one
1615
1731
  // that cannot be fetched (offline) is reported, never fatal to init.
@@ -1630,7 +1746,7 @@ const commands = {
1630
1746
  if (added) say("icons:", `${f.prefix} (${added.count} icons, from ${f.from})`);
1631
1747
  else say("icons:", `${f.prefix} not added: ${error?.message ?? "unknown error"}`);
1632
1748
  }
1633
- if (result.designSystem) stored = result.designSystem;
1749
+ if (result.system) stored = result.system;
1634
1750
  }
1635
1751
  const iconSets = [
1636
1752
  ...new Set([...(stored?.iconSets ?? []).map((s) => s.prefix), ...BUNDLED_ICON_SETS]),
@@ -1647,7 +1763,7 @@ const commands = {
1647
1763
  say("rules:", rulesSummary(await sendRules(rulesFile, `${projectPath(project)}/rules`)));
1648
1764
  }
1649
1765
  say("next:", "mockpit guide");
1650
- if (flags.json) out({ project, designSystem: stored, starter });
1766
+ if (flags.json) out({ project, system: stored, derived: derived ?? null, starter });
1651
1767
  },
1652
1768
 
1653
1769
  async publish() {
@@ -1808,6 +1924,7 @@ const commands = {
1808
1924
  ? ` · ${list.open} open question${list.open === 1 ? "" : "s"}`
1809
1925
  : " · nothing waiting on the user"),
1810
1926
  );
1927
+ if (list.system) console.log(` system ${list.system}`);
1811
1928
  for (const m of designs) {
1812
1929
  const states = m.states.length ? m.states.join(" / ") : "single state";
1813
1930
  console.log(
@@ -1820,7 +1937,8 @@ const commands = {
1820
1937
  console.log(
1821
1938
  `Decided, ${list.decisionList.total}. Treat as settled unless the user says otherwise:`,
1822
1939
  );
1823
- for (const d of list.decisionList.lines) console.log(` ${d.design}: ${d.text}`);
1940
+ for (const d of list.decisionList.lines)
1941
+ console.log(` ${d.system ? "project" : d.design}: ${d.text}`);
1824
1942
  if (list.decisionList.more) console.log(` ${list.decisionList.more} (--decisions)`);
1825
1943
  }
1826
1944
  },
@@ -1834,6 +1952,7 @@ const commands = {
1834
1952
  out: { type: "string" },
1835
1953
  },
1836
1954
  });
1955
+ if (!flags.design) return exportSystem(flags);
1837
1956
  const slug = requireDesign(flags, "mockpit export --design writer");
1838
1957
  const project = resolveProject(flags).name;
1839
1958
  const data = await api(
@@ -2268,14 +2387,14 @@ const commands = {
2268
2387
  if (sub === "add") {
2269
2388
  const result = await installIconSets(project, sets);
2270
2389
  if (result.errors.length) fail(result.errors.map((e) => e.message).join("; "));
2271
- if (flags.json) return out(result.designSystem);
2390
+ if (flags.json) return out(result.system);
2272
2391
  for (const a of result.added) {
2273
2392
  if (!flags.quiet) console.log(`added ${a.prefix} (${a.count} icons)`);
2274
2393
  }
2275
2394
  return;
2276
2395
  }
2277
2396
  if (sub === "remove") {
2278
- const stored = await api(`${projectPath(project)}/design-system`);
2397
+ const stored = await api(`${projectPath(project)}/system`);
2279
2398
  const installed = stored?.iconSets ?? [];
2280
2399
  for (const set of sets) {
2281
2400
  if (installed.some((s) => s.prefix === set)) continue;
@@ -2285,14 +2404,11 @@ const commands = {
2285
2404
  : `${set} is not installed for ${project}`,
2286
2405
  );
2287
2406
  }
2288
- const designSystem = await api(`${projectPath(project)}/design-system`, {
2407
+ const result = await api(`${projectPath(project)}/system`, {
2289
2408
  method: "PUT",
2290
- body: JSON.stringify({
2291
- ...stored,
2292
- iconSets: installed.filter((s) => !sets.includes(s.prefix)),
2293
- }),
2409
+ body: JSON.stringify({ iconSets: installed.filter((s) => !sets.includes(s.prefix)) }),
2294
2410
  });
2295
- if (flags.json) return out(designSystem);
2411
+ if (flags.json) return out(result);
2296
2412
  if (!flags.quiet) console.log(`removed ${sets.join(", ")}`);
2297
2413
  return;
2298
2414
  }
@@ -2319,21 +2435,93 @@ const commands = {
2319
2435
  const { name: project } = resolveProject(flags);
2320
2436
  if ((sub !== "add" && sub !== "remove") || !id)
2321
2437
  fail("usage: mockpit kit add <id> --url <url> --doc <file|text> | mockpit kit remove <id>");
2322
- const designSystem =
2438
+ const result =
2323
2439
  sub === "add"
2324
2440
  ? await putProjectKit(project, id, flags)
2325
- : await api(`${projectPath(project)}/kits/${encodeURIComponent(id)}`, {
2326
- method: "DELETE",
2441
+ : await api(`${projectPath(project)}/system`, {
2442
+ method: "PUT",
2443
+ body: JSON.stringify({ removeKits: [id] }),
2327
2444
  });
2328
- if (flags.json) return out(designSystem);
2445
+ if (flags.json) return out(result);
2329
2446
  if (!flags.quiet) console.log(`${sub === "add" ? "added" : "removed"} kit ${id} (${project})`);
2330
2447
  },
2331
2448
 
2449
+ // The project's system: no flags reads it, any flag writes that part.
2450
+ async system() {
2451
+ const JSON_FLAGS = [
2452
+ "classes",
2453
+ "fonts",
2454
+ "tokens",
2455
+ "scales",
2456
+ "roles",
2457
+ "palettes",
2458
+ "pairs",
2459
+ "as",
2460
+ "knobs",
2461
+ "remove",
2462
+ ];
2463
+ const { values: flags } = parse({
2464
+ options: {
2465
+ project: { type: "string" },
2466
+ session: { type: "string" },
2467
+ sheet: { type: "string" },
2468
+ restore: { type: "string" },
2469
+ ...Object.fromEntries(JSON_FLAGS.map((f) => [f, { type: "string" }])),
2470
+ },
2471
+ });
2472
+ const project = resolveProject(flags).name;
2473
+ const body = {};
2474
+ if (flags.sheet !== undefined) {
2475
+ // CSS text has a block; anything else is a path, which must be a file.
2476
+ const sheet = flags.sheet;
2477
+ if (sheet === "") body.sheet = null;
2478
+ else if (sheet.includes("{")) body.sheet = sheet;
2479
+ else if (!existsSync(sheet))
2480
+ die(`--sheet: no file at ${sheet}`, "mockpit system --sheet sheet.css");
2481
+ else if (!statSync(sheet).isFile())
2482
+ die(`--sheet: ${sheet} is not a file`, "mockpit system --sheet sheet.css");
2483
+ else body.sheet = readFileSync(sheet, "utf8");
2484
+ }
2485
+ for (const f of JSON_FLAGS) {
2486
+ if (flags[f] === undefined) continue;
2487
+ const raw = existsSync(flags[f]) ? readFileSync(flags[f], "utf8") : flags[f];
2488
+ try {
2489
+ body[f] = JSON.parse(raw);
2490
+ } catch {
2491
+ die(`--${f} must be JSON (or a JSON file)`, `mockpit guide --topic system`);
2492
+ }
2493
+ }
2494
+ if (flags.restore !== undefined) body.restore = Number(flags.restore);
2495
+ if (Object.keys(body).length === 0) {
2496
+ const system = await api(`${projectPath(project)}/system`, {}, { report: "die" });
2497
+ if (flags.json || !process.stdout.isTTY) return out(system);
2498
+ printSystem(system);
2499
+ return;
2500
+ }
2501
+ const session = (await resolveSession(flags, { create: true })) ?? undefined;
2502
+ const result = await api(
2503
+ `${projectPath(project)}/system`,
2504
+ { method: "PUT", body: JSON.stringify({ ...body, session }) },
2505
+ { report: "die", fix: "mockpit guide --topic system" },
2506
+ );
2507
+ if (result.changed?.length) retakeStills(project, session);
2508
+ if (flags.json) return out(result);
2509
+ if (flags.quiet) return;
2510
+ console.log(
2511
+ result.changed?.length
2512
+ ? `r${result.rev}: ${result.changed.join("; ")}`
2513
+ : `r${result.rev}: nothing changed`,
2514
+ );
2515
+ for (const c of result.checks ?? []) console.log(`check: ${c}`);
2516
+ console.log(result.url);
2517
+ printFeedback(result.feedback);
2518
+ },
2519
+
2332
2520
  async guide() {
2333
2521
  const { values: flags } = parse({
2334
2522
  options: { topic: { type: "string" }, project: { type: "string" } },
2335
2523
  });
2336
- if (flags.topic !== undefined) return printTopic(flags.topic);
2524
+ if (flags.topic !== undefined) return printTopic(flags.topic, flags);
2337
2525
  return printBrief(flags);
2338
2526
  },
2339
2527
 
@@ -2400,7 +2588,7 @@ const commands = {
2400
2588
  // no server running.
2401
2589
  const TOPICS_DIR = join(ROOT, "guide", "topics");
2402
2590
 
2403
- async function printTopic(topic) {
2591
+ async function printTopic(topic, flags = {}) {
2404
2592
  const topics = readdirSync(TOPICS_DIR)
2405
2593
  .filter((f) => f.endsWith(".md"))
2406
2594
  .map((f) => f.slice(0, -3))
@@ -2411,11 +2599,13 @@ async function printTopic(topic) {
2411
2599
  `mockpit guide --topic ${topics[0]}`,
2412
2600
  );
2413
2601
  }
2414
- const path = `/agent-howto?${new URLSearchParams({ topic })}`;
2602
+ const path = `/agent-howto?${new URLSearchParams(
2603
+ topic === "system" ? { topic, project: resolveProject(flags).name } : { topic },
2604
+ )}`;
2415
2605
  console.log(await fetchTextWithFallback(path, join(TOPICS_DIR, `${topic}.md`)));
2416
2606
  }
2417
2607
 
2418
- // The brief is rendered from the project's stored design settings, so the
2608
+ // The brief is rendered from the project's stored system, so the
2419
2609
  // server's copy is the real one. With no server it still has to print: an
2420
2610
  // agent reads it before anything else, so render the generic brief locally
2421
2611
  // (the same code the server runs) and say where the project-aware one comes from.
@@ -2428,7 +2618,7 @@ async function printBrief(flags) {
2428
2618
  const { renderBriefGuide } = await import(entrypoint("server", "designGuide.ts"));
2429
2619
  console.log(renderBriefGuide(null));
2430
2620
  console.error(
2431
- `note: no mockpit at ${BASE}; this is the generic brief. Run mockpit serve, then mockpit init for this project's palette, kit and icons.`,
2621
+ `note: no mockpit at ${BASE}; this is the generic brief. Run mockpit serve, then mockpit init for this project's system, kit and icons.`,
2432
2622
  );
2433
2623
  }
2434
2624