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
@@ -8,12 +8,16 @@ import { decodeBase64 } from "./base64.js";
8
8
  import { draftHolds, designDetailView, postersFor, treeNodeView, designSummaryView, placeOf, sessionRowView, surfaceRef, } from "./apiViews.js";
9
9
  import { askNudges } from "./askNudges.js";
10
10
  import { EventBus } from "./events.js";
11
- import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
11
+ import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, renderSystemTopic, unknownTopicMessage, } from "./designGuide.js";
12
12
  import { buildFeedbackBatches } from "./feedbackBatch.js";
13
13
  import { guidanceSections, MAX_RULES_CHARS, parseRulesFile, RULES_SETTING, USER_RULES_SETTING, } from "./guidance.js";
14
14
  import { HANDOFF_NOTE, handoffReady, OWNER_BUSY_MS, ownerActiveAt, sessionName, supersedes, } from "./handoff.js";
15
15
  import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
16
16
  import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
17
+ import { activePalette, applySystemWrite, defaultPalette, describeChange, systemDiffers, emptySystem, restoreSystem, SYSTEM_WRITE_KEYS, PALETTE_STATUSES, hasSystemContent, projectKnobValues, systemAfterAnswers, systemExport, systemLine, } from "./system.js";
18
+ import { pageChecks, pairResults, purposeNudge, systemChecks } from "./systemChecks.js";
19
+ import { specimenHtml } from "./systemSpecimen.js";
20
+ import { checkCdnUrl } from "./cdn.js";
17
21
  import { agentDecisionList, applyDecisionEdits, deriveDecisionLines, fullAgentDecisionList, parseDecisionEdits, } from "./decisionList.js";
18
22
  import { chosenFills, decisionsFor, settledValues } from "./decisions.js";
19
23
  import { checkAnswerFill, checkAnswerStyle, MAX_ANSWER_ADDITIONS, withAnswerAdditions, } from "./answerAdditions.js";
@@ -656,29 +660,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
656
660
  const stored = await store.getSetting("mode");
657
661
  return isMode(stored) ? stored : DEFAULT_MODE;
658
662
  }
659
- // A project's imported design system (settings key `design:<project>`), or
660
- // null when `mockpit init` has never run for it.
661
- async function designSystemFor(project) {
662
- const raw = await store.getSetting(`design:${project}`);
663
- if (!raw)
664
- return null;
665
- try {
666
- const designSystem = JSON.parse(raw);
667
- return {
668
- ...designSystem,
669
- tailwindCss: typeof designSystem.tailwindCss === "string" ? designSystem.tailwindCss : "",
670
- strippedImports: Array.isArray(designSystem.strippedImports)
671
- ? designSystem.strippedImports
672
- : [],
673
- iconSets: Array.isArray(designSystem.iconSets) ? designSystem.iconSets : [],
674
- projectKits: Array.isArray(designSystem.projectKits) ? designSystem.projectKits : [],
675
- designFiles: designSystem.designFiles ?? null,
676
- };
677
- }
678
- catch {
679
- return null;
680
- }
681
- }
663
+ // A project's system, or null when nothing was ever written to it.
664
+ const systemFor = (project) => store.getSystem(project);
665
+ // The revision a version is published under; absent while there is none.
666
+ const systemRevOf = async (project) => (await store.getSystem(project))?.rev;
682
667
  // Parsed installed icon sets by asset id. Asset bytes are immutable (the id
683
668
  // is their hash), so an entry never goes stale; the bound only caps memory.
684
669
  const iconSetCache = new Map();
@@ -699,9 +684,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
699
684
  }
700
685
  // The project's installed sets first, so installing a set under a bundled
701
686
  // prefix (a newer lucide) overrides the bundled copy.
702
- async function iconSetsFor(designSystem) {
687
+ async function iconSetsFor(system) {
703
688
  const out = [];
704
- for (const ref of designSystem?.iconSets ?? []) {
689
+ for (const ref of system?.iconSets ?? []) {
705
690
  const set = await installedIconSet(ref.assetId);
706
691
  if (set)
707
692
  out.push({ prefix: ref.prefix, set, source: "installed" });
@@ -712,8 +697,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
712
697
  }
713
698
  return out;
714
699
  }
715
- async function iconResolverFor(designSystem) {
716
- const sets = await iconSetsFor(designSystem);
700
+ async function iconResolverFor(system) {
701
+ const sets = await iconSetsFor(system);
717
702
  return { resolve: resolverFor(sets.map((s) => s.set)), prefixes: sets.map((s) => s.prefix) };
718
703
  }
719
704
  // Unknown icon names in a write's html surfaces, said at write time so the
@@ -722,7 +707,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
722
707
  const html = surfaces.flatMap((s) => s.kind === "html" && mayHaveIcons(s.html) ? [s.html] : []);
723
708
  if (html.length === 0)
724
709
  return [];
725
- const { resolve, prefixes } = await iconResolverFor(await designSystemFor(project));
710
+ const { resolve, prefixes } = await iconResolverFor(await systemFor(project));
726
711
  const unknown = new Set();
727
712
  for (const h of html)
728
713
  for (const u of expandIcons(h, resolve).unknown)
@@ -734,13 +719,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
734
719
  async function briefFor(project) {
735
720
  try {
736
721
  const name = project ?? (await store.listProjects())[0]?.name ?? DEFAULT_PROJECT;
737
- return renderBriefGuide(await designSystemFor(name), await guidanceFor(name));
722
+ return renderBriefGuide(await systemFor(name), await guidanceFor(name), await componentIndex(name));
738
723
  }
739
724
  catch (err) {
740
725
  console.warn("[mockpit] project design unavailable for the brief", err);
741
726
  return renderBriefGuide(null);
742
727
  }
743
728
  }
729
+ // The `system` topic: its contract and this project's full lists.
730
+ async function systemTopicFor(project) {
731
+ const name = project || (await store.listProjects())[0]?.name || DEFAULT_PROJECT;
732
+ return renderSystemTopic(topics.system ?? "", await systemFor(name), await componentIndex(name));
733
+ }
744
734
  // The user's file wins over the stored copy when the host can read it: the
745
735
  // copy exists for hosts that can't.
746
736
  async function userRulesText() {
@@ -861,7 +851,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
861
851
  }
862
852
  // The kit ids a surface of this project may name beyond the bundled ones.
863
853
  const kitScope = async (project) => ({
864
- projectKits: ((await designSystemFor(project))?.projectKits ?? []).map((k) => k.id),
854
+ projectKits: ((await systemFor(project))?.projectKits ?? []).map((k) => k.id),
865
855
  });
866
856
  async function parseSurfaceInput(body, ctx, scope, base) {
867
857
  if (body.html !== undefined) {
@@ -994,12 +984,24 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
994
984
  if (!post || post.design !== design.id)
995
985
  return fail(404, "variant not found");
996
986
  const session = typeof body?.session === "string" ? body.session : "";
997
- if (publisherOf(post) !== session) {
987
+ const rev = (await systemFor(design.project))?.rev ?? 0;
988
+ // A still mockpit took under an older system no longer shows; any agent's session
989
+ // may retake it, since a system write changes every variant's look at once.
990
+ const page = post.surfaces.find((s) => s.kind === "html");
991
+ const retake = page?.kind === "html" &&
992
+ !!page.poster &&
993
+ page.posterMode !== undefined &&
994
+ (page.posterSystem === undefined ? rev > 1 : page.posterSystem !== rev);
995
+ if (publisherOf(post) !== session &&
996
+ !(retake && session && (await store.getSession(session)))) {
998
997
  return fail(403, `v${post.version} of ${post.variant} was published by another session`);
999
998
  }
1000
999
  if (body?.version !== post.version || (body?.rewrites ?? 0) !== (post.rewrites ?? 0)) {
1001
1000
  return fail(409, `${post.variant} has changed since this still was taken`);
1002
1001
  }
1002
+ if (body?.system !== undefined && body.system !== rev) {
1003
+ return fail(409, `${design.project}'s system has changed since this still was taken`);
1004
+ }
1003
1005
  const poster = str(body?.poster, MAX_LABEL);
1004
1006
  if (!poster)
1005
1007
  return fail(400, '"poster" must be the asset id of an uploaded image');
@@ -1007,7 +1009,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
1007
1009
  if (!asset?.contentType.startsWith("image/")) {
1008
1010
  return fail(400, `poster "${poster}" is not an uploaded image`);
1009
1011
  }
1010
- const attached = await store.setPosterOnce(post.id, post.version, poster, isMode(body?.mode) ? body.mode : undefined);
1012
+ const attached = await store.setPosterOnce(post.id, post.version, poster, isMode(body?.mode) ? body.mode : undefined, rev);
1011
1013
  if (!attached)
1012
1014
  return fail(409, `${post.variant} already has a poster`);
1013
1015
  bus.broadcast({ type: "design-updated", id: design.id, project: design.project });
@@ -1266,8 +1268,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
1266
1268
  part: sl.part,
1267
1269
  options: sl.fills.map((f) => ({ label: f.label ?? f.name, set: { [sl.part]: f.name } })),
1268
1270
  }));
1271
+ const system = await systemFor(design.project);
1272
+ const pages = post.surfaces.flatMap((x) => (x.kind === "html" ? [x.html] : []));
1273
+ const purpose = design.kind === "component" ? purposeNudge(design.slug, design.notes?.text) : null;
1269
1274
  const nudges = [
1270
1275
  ...(extra.nudges ?? []),
1276
+ ...(system && pages.length ? pageChecks(system, pages) : []),
1277
+ ...(purpose ? [purpose] : []),
1271
1278
  ...(unbound ? [unbound.nudge] : []),
1272
1279
  ...(slotAsks.length
1273
1280
  ? [
@@ -1420,6 +1427,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
1420
1427
  session?.project ||
1421
1428
  projectFromCwd(session?.cwd) ||
1422
1429
  resolveProject(undefined, str(body.cwd, 4096)));
1430
+ {
1431
+ const projectKnobs = (await systemFor(project))?.knobs ?? {};
1432
+ const clash = [...Object.keys(knobs.value), ...Object.keys(variantKnobs.value)].find((p) => Object.hasOwn(projectKnobs, p));
1433
+ if (clash) {
1434
+ return fail(400, `knob "${clash}" is a project knob of ${project}'s system; name the design's knob something else`);
1435
+ }
1436
+ }
1423
1437
  let slug = design?.slug ?? slugify(slugIn);
1424
1438
  design ??= await store.findDesign(project, slug);
1425
1439
  // Told a design by its title, the agent means that design, not a new one named
@@ -1706,6 +1720,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
1706
1720
  from,
1707
1721
  prompt,
1708
1722
  author,
1723
+ system: await systemRevOf(project),
1709
1724
  });
1710
1725
  if (!created)
1711
1726
  return fail(404, "session not found");
@@ -1737,6 +1752,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
1737
1752
  from,
1738
1753
  prompt,
1739
1754
  author,
1755
+ system: await systemRevOf(project),
1740
1756
  });
1741
1757
  if (!amended) {
1742
1758
  return fail(409, `${variant} has a newer version than v${target.version}`, {
@@ -1785,6 +1801,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
1785
1801
  prompt,
1786
1802
  author,
1787
1803
  ...(session ? { publishedBy: session.id } : {}),
1804
+ system: await systemRevOf(project),
1788
1805
  });
1789
1806
  if (!updated)
1790
1807
  return fail(404, "variant not found");
@@ -2171,7 +2188,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2171
2188
  return out;
2172
2189
  }
2173
2190
  // --- the decision list ---
2174
- const derivedDecisions = async (project, designs) => deriveDecisionLines(designs ?? (await store.listDesigns(project)), await store.listDecisionSources(project));
2191
+ const derivedDecisions = async (project, designs) => deriveDecisionLines(designs ?? (await store.listDesigns(project)), await store.listDecisionSources(project), await systemFor(project));
2175
2192
  const decisionEdits = async (project) => parseDecisionEdits(await store.getSetting(`${DECISION_EDITS_SETTING}:${project}`));
2176
2193
  const decisionLines = async (project, designs) => applyDecisionEdits(await derivedDecisions(project, designs), await decisionEdits(project));
2177
2194
  // What every agent read carries, once the project has any: the first thing a
@@ -2227,7 +2244,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2227
2244
  const project = str(query.project, MAX_TITLE);
2228
2245
  const designs = await store.listDesigns(project);
2229
2246
  const mode = await workspaceMode();
2230
- const heads = headsByDesign(postersFor(await store.listPostHeads(project), mode));
2247
+ const heads = headsByDesign(postersFor(await store.listPostHeads(project), mode, project ? ((await systemFor(project))?.rev ?? 0) : undefined));
2231
2248
  const rows = [];
2232
2249
  const pending = [];
2233
2250
  for (const m of designs) {
@@ -2236,8 +2253,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2236
2253
  pending.push(pendingOf(m));
2237
2254
  }
2238
2255
  const open = rows.reduce((n, r) => n + r.open, 0);
2256
+ const system = project && !query.noDecisions ? await systemFor(project) : null;
2239
2257
  return ok({
2240
2258
  ...(project ? { project, flows: await flowsView(project, designs) } : {}),
2259
+ ...(system && hasSystemContent(system)
2260
+ ? { system: systemLine(system, systemChecks(system).length) }
2261
+ : {}),
2241
2262
  ...(project && !query.noDecisions
2242
2263
  ? await decisionListField(project, undefined, designs)
2243
2264
  : {}),
@@ -2257,7 +2278,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2257
2278
  return fail(400, 'provide "project"');
2258
2279
  const designs = await store.listDesigns(project);
2259
2280
  const mode = await workspaceMode();
2260
- const heads = headsByDesign(postersFor(await store.listPostHeads(project), mode));
2281
+ const system = await systemFor(project);
2282
+ const rev = system?.rev ?? 0;
2283
+ const heads = headsByDesign(postersFor(await store.listPostHeads(project), mode, rev));
2261
2284
  const nodes = [];
2262
2285
  const pending = [];
2263
2286
  for (const m of designs) {
@@ -2265,7 +2288,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2265
2288
  // Only embeds and fills need the html: most of a workspace's bytes are
2266
2289
  // pages with neither, which this list has no reason to read.
2267
2290
  const marked = designHeads.some((p) => p.surfaces.some((x) => x.marked));
2268
- const posts = marked ? postersFor(await store.listPosts({ designId: m.id }), mode) : null;
2291
+ const posts = marked
2292
+ ? postersFor(await store.listPosts({ designId: m.id }), mode, rev)
2293
+ : null;
2269
2294
  const views = posts ? await embedViews(m, posts) : new Map();
2270
2295
  const embeds = new Set();
2271
2296
  for (const p of posts ?? []) {
@@ -2313,6 +2338,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2313
2338
  }
2314
2339
  return ok({
2315
2340
  project,
2341
+ systemRev: rev,
2342
+ // Lights the System place in Home.
2343
+ hasSystem: hasSystemContent(system),
2316
2344
  tree: nodes,
2317
2345
  flows: await flowsView(project, designs),
2318
2346
  areaOrder: await areaOrderOf(project),
@@ -2345,7 +2373,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2345
2373
  const design = await resolveDesign(ref, query.project, query.session);
2346
2374
  if (isResult(design))
2347
2375
  return design;
2348
- const posts = postersFor(await store.listPosts({ designId: design.id }), await workspaceMode());
2376
+ const posts = postersFor(await store.listPosts({ designId: design.id }), await workspaceMode(), (await systemFor(design.project))?.rev ?? 0);
2349
2377
  return ok({
2350
2378
  ...(query.noDecisions ? {} : await decisionListField(design.project, design.slug)),
2351
2379
  ...designDetailView(design, posts, {
@@ -2355,6 +2383,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2355
2383
  embeds: await embedViews(design, posts),
2356
2384
  ...(await shortlistReach(design)),
2357
2385
  }),
2386
+ ...(await systemPalettesField(design.project, query.viewer === true)),
2358
2387
  pending: pendingOf(design),
2359
2388
  });
2360
2389
  }
@@ -2364,6 +2393,49 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2364
2393
  const agent = await projectAgent(design.project);
2365
2394
  return agent ? { repliesGoTo: agent.title ?? agent.agent } : {};
2366
2395
  }
2396
+ // What a design view says about the system its frames wear: the revision, and for
2397
+ // the viewer the palettes to switch between (each a swatch of its first roles).
2398
+ async function systemPalettesField(project, viewer) {
2399
+ const system = await systemFor(project);
2400
+ const roles = (system?.color.roles ?? []).slice(0, 4).map((r) => r.name);
2401
+ return {
2402
+ systemRev: system?.rev ?? 0,
2403
+ ...(viewer && system && system.color.palettes.length > 1
2404
+ ? {
2405
+ palettes: system.color.palettes.map((p) => ({
2406
+ id: p.id,
2407
+ label: p.label || p.id,
2408
+ status: p.status,
2409
+ swatch: {
2410
+ light: roles.map((r) => p.light[r] ?? "transparent"),
2411
+ dark: roles.map((r) => p.dark[r] ?? "transparent"),
2412
+ },
2413
+ })),
2414
+ }
2415
+ : {}),
2416
+ };
2417
+ }
2418
+ // Without a design, export hands the project's system to code.
2419
+ async function exportSystemFlow(projectArg) {
2420
+ const project = str(projectArg, MAX_TITLE);
2421
+ if (!project)
2422
+ return fail(400, 'provide "project", or "design" to export one');
2423
+ const system = await systemFor(project);
2424
+ if (!system || !hasSystemContent(system)) {
2425
+ return fail(404, `${project} has no system yet; start one with \`system\``);
2426
+ }
2427
+ const designs = await store.listDesigns(project);
2428
+ return ok({
2429
+ ...systemExport(system),
2430
+ components: designs
2431
+ .filter((d) => d.kind === "component" && !d.archivedAt)
2432
+ .map((d) => ({
2433
+ slug: d.slug,
2434
+ purpose: (d.notes?.text ?? "").split("\n")[0].trim(),
2435
+ source: d.source ?? null,
2436
+ })),
2437
+ });
2438
+ }
2367
2439
  async function exportFlow(ref, query, ctx) {
2368
2440
  const design = await resolveDesign(ref, query.project);
2369
2441
  if (isResult(design))
@@ -2451,7 +2523,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2451
2523
  });
2452
2524
  }
2453
2525
  // --- asks ---
2454
- function sanitizeAsk(raw, design, posts, taken) {
2526
+ function sanitizeAsk(raw, design, posts, taken, palettes = new Set()) {
2455
2527
  if (!raw || typeof raw !== "object")
2456
2528
  return "each ask must be an object";
2457
2529
  const text = str(raw.text, MAX_COMMENT_TEXT);
@@ -2524,6 +2596,17 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2524
2596
  set = checked.value;
2525
2597
  }
2526
2598
  const pick = opt.recommended === true ? { recommended: true } : {};
2599
+ let palette;
2600
+ if (opt.palette !== undefined) {
2601
+ palette = str(opt.palette, 48);
2602
+ if (!palette || !palettes.has(palette)) {
2603
+ return `option "${label}" names palette "${String(opt.palette)}", which the project's system doesn't have${palettes.size ? ` (${[...palettes].join(", ")})` : ""}`;
2604
+ }
2605
+ if (variant || opt.version !== undefined)
2606
+ return `option "${label}": a palette option pictures every variant; name no variant`;
2607
+ if (scope === "part")
2608
+ return `option "${label}": a palette is the whole page's; ask with scope "design" or "state"`;
2609
+ }
2527
2610
  if (opt.version !== undefined) {
2528
2611
  const pinned = versionOption(opt.version, label, variant, { scope, state }, design, posts);
2529
2612
  if (typeof pinned === "string")
@@ -2536,6 +2619,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2536
2619
  label,
2537
2620
  ...(variant ? { variant } : {}),
2538
2621
  ...(set ? { set } : {}),
2622
+ ...(palette ? { palette } : {}),
2539
2623
  ...pick,
2540
2624
  });
2541
2625
  }
@@ -2603,9 +2687,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2603
2687
  // Takes what answers bring off their options (style, fill, html), checked before
2604
2688
  // anything is written. Options then carry only what any option does: a `set`, or a
2605
2689
  // `variant` whose `version` is filled in once its whole page exists.
2606
- function answersBringing(raw, design, posts) {
2690
+ function answersBringing(raw, design, posts, system) {
2607
2691
  const knobs = { ...design.knobs };
2608
2692
  const additions = [...design.answerAdditions];
2693
+ const project = {
2694
+ knobs: { ...system?.knobs },
2695
+ additions: [...(system?.additions ?? [])],
2696
+ changed: false,
2697
+ };
2698
+ const nextRev = (system?.rev ?? 0) + 1;
2699
+ const revised = [];
2609
2700
  const pages = [];
2610
2701
  const renders = posts.flatMap((p) => (postAtVersion(p, currentVersion(p)) ?? p).surfaces.flatMap((s) => s.kind === "html" ? [s.html] : []));
2611
2702
  const entries = [];
@@ -2650,6 +2741,36 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2650
2741
  return `option "${name}": style and fill go with a set of one new value`;
2651
2742
  }
2652
2743
  const knob = set[0][0];
2744
+ if (system && Object.hasOwn(system.knobs, knob)) {
2745
+ if (fill !== undefined)
2746
+ return `option "${name}": "${knob}" is a project knob; it takes style, not fill`;
2747
+ const bad = typeof style === "string" ? checkAnswerStyle(style) : "style must be a string";
2748
+ if (bad)
2749
+ return `option "${name}": ${bad}`;
2750
+ const at = project.additions.findIndex((a) => a.knob === knob && a.value === value);
2751
+ if (at >= 0) {
2752
+ // Asked again with a new style, the value stays and its style is revised.
2753
+ if (project.additions[at].style !== style) {
2754
+ project.additions[at] = { knob, value, style, rev: nextRev };
2755
+ project.changed = true;
2756
+ revised.push(`${knob}=${value}`);
2757
+ }
2758
+ options.push(rest);
2759
+ continue;
2760
+ }
2761
+ const config = explicitKnob(system.knobs[knob]);
2762
+ if (config.type !== "select") {
2763
+ return `option "${name}": project knob "${knob}" is a ${config.type}, which already carries its value; send the set alone`;
2764
+ }
2765
+ const known = (config.options ?? []).map((v) => typeof v === "object" && v !== null && "value" in v ? String(v.value) : String(v));
2766
+ if (!known.includes(value)) {
2767
+ project.knobs[knob] = { ...config, options: [...(config.options ?? []), value] };
2768
+ }
2769
+ project.additions.push({ knob, value, style, rev: nextRev });
2770
+ project.changed = true;
2771
+ options.push(rest);
2772
+ continue;
2773
+ }
2653
2774
  const addition = { knob, value };
2654
2775
  if (style !== undefined) {
2655
2776
  const bad = typeof style === "string" ? checkAnswerStyle(style) : "style must be a string";
@@ -2668,11 +2789,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2668
2789
  }
2669
2790
  addition.fill = fill;
2670
2791
  }
2671
- const same = additions.find((a) => a.knob === knob && a.value === value);
2672
- if (same) {
2673
- // Asking again with what is already there is how a question grows.
2792
+ const at = additions.findIndex((a) => a.knob === knob && a.value === value);
2793
+ if (at >= 0) {
2794
+ // Asking again with what is already there is how a question grows; with a
2795
+ // new style or fill, the value keeps its name and what it draws is revised.
2796
+ const same = additions[at];
2674
2797
  if (same.style !== addition.style || same.fill !== addition.fill) {
2675
- return `option "${name}": "${value}" already means something else; use a new value`;
2798
+ additions[at] = { ...addition, rev: (same.rev ?? 1) + 1 };
2799
+ revised.push(`${knob}=${value}`);
2676
2800
  }
2677
2801
  options.push(rest);
2678
2802
  continue;
@@ -2697,7 +2821,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2697
2821
  }
2698
2822
  entries.push({ ...entry, options });
2699
2823
  }
2700
- return { entries, knobs, additions, pages };
2824
+ return { entries, knobs, additions, pages, project, revised };
2701
2825
  }
2702
2826
  // A whole page an answer brought: a version the user only meets if they choose it.
2703
2827
  // Asked again with the same page, the question keeps the version it already has.
@@ -2724,6 +2848,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2724
2848
  embeds,
2725
2849
  from: seen.version,
2726
2850
  ...(sessionId ? { publishedBy: sessionId } : {}),
2851
+ system: await systemRevOf(design.project),
2727
2852
  });
2728
2853
  if (!updated)
2729
2854
  return fail(404, "variant not found");
@@ -2774,9 +2899,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2774
2899
  if (raw.length > MAX_ASKS)
2775
2900
  return fail(400, `at most ${MAX_ASKS} asks per call`);
2776
2901
  const posts = await store.listPosts({ designId: design.id });
2777
- const brought = answersBringing(raw, design, posts);
2902
+ const system = await systemFor(design.project);
2903
+ const brought = answersBringing(raw, design, posts, system);
2778
2904
  if (typeof brought === "string")
2779
2905
  return fail(400, brought);
2906
+ // Options may set the project's knobs too, and choose its palettes.
2907
+ const askKnobs = { ...brought.project.knobs, ...brought.knobs };
2908
+ const palettes = new Set((system?.color.palettes ?? []).map((p) => p.id));
2780
2909
  const asks = [...design.asks];
2781
2910
  const taken = new Set(asks.map((a) => a.id));
2782
2911
  const added = [];
@@ -2784,7 +2913,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2784
2913
  const standings = { ...design.standings };
2785
2914
  const stillHidden = [];
2786
2915
  for (const entry of brought.entries) {
2787
- const ask = sanitizeAsk(entry, { ...design, knobs: brought.knobs }, posts, taken);
2916
+ const ask = sanitizeAsk(entry, { ...design, knobs: askKnobs }, posts, taken, palettes);
2788
2917
  if (typeof ask === "string")
2789
2918
  return fail(400, ask);
2790
2919
  const at = asks.findIndex((a) => a.id === ask.id);
@@ -2814,7 +2943,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2814
2943
  // would wait where they no longer look. Asked again, a question it left
2815
2944
  // unanswered is simply open again: guidance about what to ask lives in the
2816
2945
  // conversation, not in a refusal here.
2817
- const additionsGrew = brought.additions.length > design.answerAdditions.length;
2946
+ const additionsGrew = brought.additions.length > design.answerAdditions.length || brought.revised.length > 0;
2818
2947
  const updated = await store.updateDesign(design.id, {
2819
2948
  asks,
2820
2949
  sessionId,
@@ -2828,6 +2957,41 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2828
2957
  // Every render carries these now, and frames key on `rewrites`: open ones reload.
2829
2958
  if (additionsGrew)
2830
2959
  await store.bumpRewrites(design.id);
2960
+ if (system && brought.project.changed) {
2961
+ const made = brought.project.additions.filter((a) => a.rev === system.rev + 1);
2962
+ const summary = `answer styles for ${made.map((a) => `${a.knob}=${a.value}`).join(", ")}`;
2963
+ await serialSystem(design.project, async () => {
2964
+ // Another write may have landed since the read above: lay these over it.
2965
+ const fresh = (await systemFor(design.project)) ?? system;
2966
+ const rev = fresh.rev + 1;
2967
+ const key = (a) => `${a.knob}\u0000${a.value}`;
2968
+ const ours = new Set(made.map(key));
2969
+ const knobs = { ...fresh.knobs };
2970
+ for (const [path, config] of Object.entries(brought.project.knobs)) {
2971
+ if (JSON.stringify(config) !== JSON.stringify(system.knobs[path]))
2972
+ knobs[path] = config;
2973
+ }
2974
+ const next = {
2975
+ ...fresh,
2976
+ knobs,
2977
+ additions: [
2978
+ ...fresh.additions.filter((a) => !ours.has(key(a))),
2979
+ ...made.map((a) => ({ ...a, rev })),
2980
+ ],
2981
+ rev,
2982
+ updatedAt: new Date().toISOString(),
2983
+ };
2984
+ const saved = await store.putSystem(next, { sessionId: sessionId ?? null, summary });
2985
+ clearRenderCache();
2986
+ bus.broadcast({
2987
+ type: "system-changed",
2988
+ project: design.project,
2989
+ rev: saved.rev,
2990
+ summary,
2991
+ by: "agent",
2992
+ });
2993
+ });
2994
+ }
2831
2995
  bus.broadcast({ type: "design-updated", id: design.id, project: design.project });
2832
2996
  // The questions also land in the thread, so the user reads them where they answer.
2833
2997
  const session = sessionId ? await store.getSession(sessionId) : null;
@@ -2858,6 +3022,21 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
2858
3022
  ...(stillHidden.length
2859
3023
  ? [`The user hid ${stillHidden.join(", ")}: they stay folded away. Offer something else.`]
2860
3024
  : []),
3025
+ ...(brought.revised.length
3026
+ ? [
3027
+ `revised what ${brought.revised.join(", ")} draw${brought.revised.length === 1 ? "s" : ""}: the value keeps its name, every render wearing it changes`,
3028
+ ]
3029
+ : []),
3030
+ ...(system
3031
+ ? added.flatMap((a) => a.options.flatMap((o) => {
3032
+ const style = raw
3033
+ .flatMap((e) => e?.options ?? [])
3034
+ .find((x) => x?.label === o.label)?.style;
3035
+ return typeof style === "string"
3036
+ ? pageChecks(system, [`<style>${style}</style>`], `option "${o.label}"'s style`)
3037
+ : [];
3038
+ }))
3039
+ : []),
2861
3040
  ];
2862
3041
  return ok({
2863
3042
  design: design.slug,
@@ -3078,7 +3257,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
3078
3257
  const borrowed = sanitizeBorrowed(pick("borrowed"), posts);
3079
3258
  if (typeof borrowed === "string")
3080
3259
  return borrowed;
3081
- const tuned = checkKnobValues(pick("tuned"), design, posts, "tuned");
3260
+ const projectKnobs = (await systemFor(design.project))?.knobs;
3261
+ const tuned = checkKnobValues(pick("tuned"), { knobs: { ...projectKnobs, ...design.knobs } }, posts, "tuned");
3082
3262
  if (!tuned.ok)
3083
3263
  return tuned.error;
3084
3264
  const comments = sanitizePartComments(pick("comments"), design, posts);
@@ -3377,26 +3557,59 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
3377
3557
  const standings = sentStandings(design, draft.answers);
3378
3558
  if (Object.keys(standings.payload).length)
3379
3559
  payload.standings = standings.payload;
3380
- if (verdict) {
3381
- const target = posts.find((p) => p.state === verdict.state && p.variant === verdict.variant);
3382
- const pinned = verdict.kind === "use" && target ? flips.accept.get(target.id) : undefined;
3383
- payload.verdict = pinned === undefined ? verdict : { ...verdict, version: pinned };
3384
- }
3385
- const comment = await store.commitReply({
3386
- designId: design.id,
3387
- sessionId,
3388
- text: text ?? "",
3389
- payload,
3390
- asks,
3391
- accept: [...flips.accept].map(([id, version]) => ({ id, version })),
3392
- archive: [...flips.archive],
3393
- release: [...flips.release],
3394
- standings: standings.next,
3395
- ...(routed ? { owner: sessionId } : {}),
3560
+ // Serialized with the project's other system writes: the revision it makes is
3561
+ // built on the one it read.
3562
+ const { comment, systemWrite } = await serialSystem(design.project, async () => {
3563
+ const system = await systemFor(design.project);
3564
+ const decided = system
3565
+ ? systemAfterAnswers(system, design.asks, draft.answers, standings.payload)
3566
+ : null;
3567
+ const systemWrite = decided
3568
+ ? {
3569
+ next: { ...decided.system, rev: system.rev + 1, updatedAt: new Date().toISOString() },
3570
+ summary: decided.changes.join("; "),
3571
+ }
3572
+ : undefined;
3573
+ if (systemWrite) {
3574
+ payload.system = {
3575
+ project: design.project,
3576
+ rev: systemWrite.next.rev,
3577
+ changes: decided.changes,
3578
+ };
3579
+ }
3580
+ if (verdict) {
3581
+ const target = posts.find((p) => p.state === verdict.state && p.variant === verdict.variant);
3582
+ const pinned = verdict.kind === "use" && target ? flips.accept.get(target.id) : undefined;
3583
+ payload.verdict = pinned === undefined ? verdict : { ...verdict, version: pinned };
3584
+ }
3585
+ const comment = await store.commitReply({
3586
+ designId: design.id,
3587
+ sessionId,
3588
+ text: text ?? "",
3589
+ payload,
3590
+ asks,
3591
+ accept: [...flips.accept].map(([id, version]) => ({ id, version })),
3592
+ archive: [...flips.archive],
3593
+ release: [...flips.release],
3594
+ standings: standings.next,
3595
+ ...(routed ? { owner: sessionId } : {}),
3596
+ ...(systemWrite ? { system: systemWrite } : {}),
3597
+ });
3598
+ return { comment, systemWrite };
3396
3599
  });
3397
3600
  if (!comment)
3398
3601
  return fail(409, `${design.slug} has no agent session to reply to`);
3399
3602
  announceComment(comment);
3603
+ if (systemWrite) {
3604
+ clearRenderCache();
3605
+ bus.broadcast({
3606
+ type: "system-changed",
3607
+ project: design.project,
3608
+ rev: systemWrite.next.rev,
3609
+ summary: systemWrite.summary,
3610
+ by: "user",
3611
+ });
3612
+ }
3400
3613
  bus.broadcast({ type: "design-updated", id: design.id, project: design.project });
3401
3614
  bus.broadcast({ type: "draft-updated", designId: design.id });
3402
3615
  void announceEmbedders(design).catch((err) => console.warn("[mockpit] embedders", err));
@@ -3647,7 +3860,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
3647
3860
  }))
3648
3861
  : [],
3649
3862
  };
3650
- const asks = design.asks.map(({ dismissed: _, ...a }) => a);
3863
+ const asks = design.asks.map(({ dismissed: _dismissed, ...a }) => a);
3651
3864
  const updated = await store.updateDesign(design.id, { asks, archivedAt: null });
3652
3865
  if (!updated)
3653
3866
  return fail(404, "design not found");
@@ -4183,6 +4396,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
4183
4396
  ...(post.description ? { description: post.description } : {}),
4184
4397
  embeds,
4185
4398
  copiedFrom: { design: design.id, state: post.state, variant: post.variant, version },
4399
+ // A copy looks as its original did.
4400
+ contract: postAtVersion(post, version)?.contract ?? 1,
4401
+ ...(postAtVersion(post, version)?.system === undefined
4402
+ ? {}
4403
+ : { system: postAtVersion(post, version).system }),
4186
4404
  });
4187
4405
  if (!created)
4188
4406
  return fail(409, `${post.variant} has no agent session to copy it under`);
@@ -4636,6 +4854,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
4636
4854
  from: version,
4637
4855
  prompt: `restored v${version}`,
4638
4856
  author: "user",
4857
+ // Restored, it looks as it did.
4858
+ contract: old.contract ?? 1,
4859
+ ...(old.system === undefined ? {} : { system: old.system }),
4639
4860
  });
4640
4861
  if (!updated)
4641
4862
  return fail(404, "variant not found");
@@ -4760,6 +4981,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
4760
4981
  // Flows are declared apart from kind, so a page that stops being one stays
4761
4982
  // in them until the agent takes it out.
4762
4983
  const nudges = [...titleNudges(title)];
4984
+ const purpose = next.kind === "component" ? purposeNudge(next.slug, next.notes?.text) : null;
4985
+ if (purpose)
4986
+ nudges.push(purpose);
4763
4987
  if (notesKept) {
4764
4988
  nudges.push("The user edited these notes by hand, so your draft was not applied: work from theirs, in notes.text. They stand until the user clears them");
4765
4989
  }
@@ -5670,6 +5894,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
5670
5894
  body: flag(c, "body"),
5671
5895
  history: flag(c, "history"),
5672
5896
  noDecisions: withoutDecisions(c),
5897
+ viewer: isViewerRequest(c.req.raw),
5673
5898
  }));
5674
5899
  });
5675
5900
  app.get("/api/designs/:id/export", async (c) => {
@@ -5844,6 +6069,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
5844
6069
  return c.text(withOrigin(await briefFor(c.req.query("project")), c));
5845
6070
  if (!isGuideTopic(topic))
5846
6071
  return c.json({ error: unknownTopicMessage(topic), topics: GUIDE_TOPICS }, 400);
6072
+ if (topic === "system")
6073
+ return c.text(withOrigin(await systemTopicFor(c.req.query("project")), c));
5847
6074
  return c.text(withOrigin(topics[topic] ?? "", c));
5848
6075
  });
5849
6076
  // Opt-in html kits available on this workspace (id, label, summary, classes) —
@@ -5851,7 +6078,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
5851
6078
  // `?project=`, that project's own kits follow the bundled ones.
5852
6079
  app.get("/api/kits", async (c) => {
5853
6080
  const project = c.req.query("project");
5854
- return c.json(kitSummaries(project ? ((await designSystemFor(project))?.projectKits ?? []) : []));
6081
+ return c.json(kitSummaries(project ? ((await systemFor(project))?.projectKits ?? []) : []));
5855
6082
  });
5856
6083
  // --- theme: one palette, so the workspace setting is just its mode ---
5857
6084
  app.get("/api/theme", async (c) => c.json({ mode: await workspaceMode() }));
@@ -5864,8 +6091,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
5864
6091
  bus.broadcast({ type: "theme-changed", mode });
5865
6092
  return c.json({ mode });
5866
6093
  });
5867
- // The project's design system, imported by `mockpit init` and injected into
5868
- // every html surface of the project (see renderHtmlPage).
6094
+ // The project's decision list (server/decisionList.ts).
5869
6095
  app.get("/api/projects/:name/decisions", async (c) => send(c, await decisionsFlow({ project: c.req.param("name") }, flowCtx(c))));
5870
6096
  app.put("/api/projects/:name/decisions", async (c) => send(c, await editDecisionFlow(c.req.param("name"), await jsonBody(c), flowCtx(c))));
5871
6097
  app.get("/api/projects/:name/tree", async (c) => send(c, await treeFlow({
@@ -5875,153 +6101,402 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
5875
6101
  })));
5876
6102
  app.get("/api/projects/:name/flows", async (c) => send(c, await flowOutlineFlow(c.req.param("name"))));
5877
6103
  app.patch("/api/projects/:name", async (c) => send(c, await editProjectFlow(c.req.param("name"), await jsonBody(c), flowCtx(c))));
5878
- app.get("/api/projects/:name/design-system", async (c) => c.json(await designSystemFor(c.req.param("name"))));
6104
+ app.get("/api/projects/:name/system", async (c) => send(c, await systemFlow(c.req.param("name"), undefined, flowCtx(c))));
6105
+ app.put("/api/projects/:name/system", async (c) => send(c, await systemFlow(c.req.param("name"), await jsonBody(c), flowCtx(c))));
6106
+ app.post("/api/projects/:name/system/decide", async (c) => send(c, await systemDecideFlow(c.req.param("name"), await jsonBody(c), flowCtx(c))));
6107
+ // The System page's specimen: the scales in the project's fonts, as a sandboxed
6108
+ // document. Addressed by project name, so it sits under /api (never public),
6109
+ // not /s/.
6110
+ app.get("/api/projects/:name/system/specimen", async (c) => {
6111
+ const system = await systemFor(c.req.param("name"));
6112
+ if (!system)
6113
+ return c.text("No system", 404);
6114
+ const mode = c.req.query("mode") === "dark"
6115
+ ? "dark"
6116
+ : c.req.query("mode") === "light"
6117
+ ? "light"
6118
+ : undefined;
6119
+ const palette = activePalette(system, c.req.query("p"))?.id;
6120
+ const page = renderHtmlPage({
6121
+ title: "System",
6122
+ html: specimenHtml(system),
6123
+ origin: new URL(c.req.url).origin,
6124
+ theme: themeById(DEFAULT_THEME_ID),
6125
+ mode,
6126
+ system,
6127
+ palette,
6128
+ });
6129
+ const { doc } = await cdnInliner.inline(page, "");
6130
+ surfaceDocHeaders(c, false);
6131
+ return c.html(doc);
6132
+ });
6133
+ app.get("/api/projects/:name/system/export", async (c) => send(c, await exportSystemFlow(c.req.param("name"))));
6134
+ app.get("/api/projects/:name/system/revisions", async (c) => c.json({ revisions: await store.listSystemRevisions(c.req.param("name")) }));
6135
+ app.get("/api/projects/:name/system/revisions/:rev", async (c) => {
6136
+ const system = await store.getSystemRevision(c.req.param("name"), Number(c.req.param("rev")));
6137
+ return system ? c.json(system) : c.json({ error: "no such revision" }, 404);
6138
+ });
5879
6139
  // A project's rules file (.mockpit/rules.md, sent by `mockpit rules` and
5880
6140
  // `mockpit init`) and the user's own (`mockpit rules --mine`).
5881
6141
  app.get("/api/projects/:name/rules", async (c) => c.json({ text: (await store.getSetting(`${RULES_SETTING}:${c.req.param("name")}`)) ?? "" }));
5882
6142
  app.put("/api/projects/:name/rules", async (c) => send(c, await saveRules(`${RULES_SETTING}:${c.req.param("name")}`, await jsonBody(c))));
5883
6143
  app.get("/api/rules", async (c) => c.json({ text: (await userRulesText()) ?? "" }));
5884
6144
  app.put("/api/rules", async (c) => send(c, await saveRules(USER_RULES_SETTING, await jsonBody(c))));
5885
- app.put("/api/projects/:name/design-system", async (c) => {
5886
- const body = await c.req.json().catch(() => null);
5887
- if (!body || typeof body !== "object")
5888
- return c.json({ error: "invalid JSON body" }, 400);
5889
- const project = c.req.param("name");
5890
- // `mockpit init` re-detects the repo and PUTs without iconSets; that must
5891
- // not uninstall what `mockpit icons add` installed.
5892
- const previous = await designSystemFor(project);
5893
- let iconSets = previous?.iconSets ?? [];
5894
- if (body.iconSets !== undefined) {
5895
- const checked = await checkIconSets(body.iconSets);
5896
- if (typeof checked === "string")
5897
- return c.json({ error: checked }, 400);
5898
- iconSets = checked;
6145
+ // --- the project's system ---
6146
+ // Writes to one project's system run one at a time: each reads the revision
6147
+ // it bumps.
6148
+ const systemQueue = new Map();
6149
+ function serialSystem(project, fn) {
6150
+ const prev = systemQueue.get(project) ?? Promise.resolve();
6151
+ const run = prev.then(fn, fn);
6152
+ const settled = run.catch(() => { });
6153
+ systemQueue.set(project, settled);
6154
+ void settled.then(() => {
6155
+ if (systemQueue.get(project) === settled)
6156
+ systemQueue.delete(project);
6157
+ });
6158
+ return run;
6159
+ }
6160
+ // Who hears the user's hand on the system: the agent that last wrote it if it is
6161
+ // listening, else one listening in the project, else that writer or the project's
6162
+ // latest session, which hears it on its next call. Null: no agent to tell yet.
6163
+ async function systemReplySession(project) {
6164
+ const writer = (await store.listSystemRevisions(project)).find((r) => r.sessionId)?.sessionId ?? null;
6165
+ if (writer && listening.has(writer))
6166
+ return writer;
6167
+ for (const id of listening.keys()) {
6168
+ if ((await store.getSession(id))?.project === project)
6169
+ return id;
5899
6170
  }
5900
- // Same rule as iconSets: a PUT that omits projectKits keeps them.
5901
- let projectKits = previous?.projectKits ?? [];
5902
- if (body.projectKits !== undefined) {
5903
- const checked = checkProjectKits(body.projectKits);
5904
- if (typeof checked === "string")
5905
- return c.json({ error: checked }, 400);
5906
- projectKits = checked;
5907
- }
5908
- const kitIds = ["tailwind", "none", ...KIT_IDS, ...projectKits.map((k) => k.id)];
5909
- const kit = body.kit === undefined || body.kit === null ? "none" : body.kit;
5910
- if (typeof kit !== "string" || !kitIds.includes(kit))
5911
- return c.json({ error: `unknown kit "${String(kit)}" — known: ${kitIds.join(", ")}` }, 400);
5912
- const designFiles = checkDesignFiles(body.designFiles);
6171
+ if (writer && (await store.getSession(writer)))
6172
+ return writer;
6173
+ const sessions = await store.listSessions(project);
6174
+ sessions.sort((a, b) => ownerActiveAt(b).localeCompare(ownerActiveAt(a)));
6175
+ return sessions[0]?.id ?? null;
6176
+ }
6177
+ // The System page: the user sets a palette's status or restores a revision. One
6178
+ // new revision, and one reply through the agent's cursor in the same write.
6179
+ async function systemDecideFlow(name, body, ctx) {
6180
+ if (!ctx.viewer)
6181
+ return viewerOnly();
6182
+ const project = str(name, MAX_TITLE);
6183
+ if (!project)
6184
+ return fail(400, 'provide "project"');
6185
+ if (!isRecord(body))
6186
+ return fail(400, "invalid JSON body");
6187
+ const restoring = body.restore !== undefined;
6188
+ if (restoring === (body.palette !== undefined)) {
6189
+ return fail(400, "send {palette, status} or {restore}");
6190
+ }
6191
+ const status = body.status;
6192
+ if (!restoring && !PALETTE_STATUSES.includes(status)) {
6193
+ return fail(400, `"status" must be one of ${PALETTE_STATUSES.join(", ")}`);
6194
+ }
6195
+ return serialSystem(project, async () => {
6196
+ const current = await systemFor(project);
6197
+ if (!current)
6198
+ return fail(404, `${project} has no system`);
6199
+ let next;
6200
+ let changes;
6201
+ if (restoring) {
6202
+ const rev = Number(body.restore);
6203
+ const old = Number.isInteger(rev) ? await store.getSystemRevision(project, rev) : null;
6204
+ if (!old)
6205
+ return fail(404, `no revision ${String(body.restore)} of ${project}'s system`);
6206
+ next = restoreSystem(current, old).system;
6207
+ if (!systemDiffers(current, next))
6208
+ return ok({ rev: current.rev, changes: [] });
6209
+ const what = describeChange(current, next);
6210
+ changes = [`restored r${rev}: ${what.join("; ")}`];
6211
+ }
6212
+ else {
6213
+ const target = current.color.palettes.find((p) => p.id === body.palette);
6214
+ if (!target)
6215
+ return fail(404, `${project}'s system has no palette "${String(body.palette)}"`);
6216
+ if (target.status === status)
6217
+ return ok({ rev: current.rev, changes: [] });
6218
+ if (target.status === "default") {
6219
+ return fail(400, "make another palette the default first: a project always has one");
6220
+ }
6221
+ const was = defaultPalette(current);
6222
+ next = {
6223
+ ...current,
6224
+ color: {
6225
+ ...current.color,
6226
+ palettes: current.color.palettes.map((p) => p.id === target.id
6227
+ ? { ...p, status }
6228
+ : status === "default" && p.status === "default"
6229
+ ? { ...p, status: "kept" }
6230
+ : p),
6231
+ },
6232
+ };
6233
+ changes = [
6234
+ status === "default"
6235
+ ? `palette "${target.id}" is the default${was ? ` ("${was.id}" kept)` : ""}`
6236
+ : `palette "${target.id}" ${status}`,
6237
+ ];
6238
+ }
6239
+ const rev = current.rev + 1;
6240
+ const sessionId = await systemReplySession(project);
6241
+ const summary = `by the user: ${changes.join("; ")}`;
6242
+ const comment = await store.commitSystemDecision({
6243
+ system: { ...next, rev, updatedAt: new Date().toISOString() },
6244
+ summary,
6245
+ tell: sessionId
6246
+ ? {
6247
+ sessionId,
6248
+ payload: {
6249
+ designId: "",
6250
+ version: 0,
6251
+ answers: {},
6252
+ borrowed: {},
6253
+ tuned: {},
6254
+ comments: [],
6255
+ system: { project, rev, changes },
6256
+ },
6257
+ }
6258
+ : null,
6259
+ });
6260
+ if (comment)
6261
+ announceComment(comment);
6262
+ clearRenderCache();
6263
+ bus.broadcast({ type: "system-changed", project, rev, summary, by: "user" });
6264
+ return ok({ rev, changes, told: sessionId });
6265
+ });
6266
+ }
6267
+ // Knob paths the project's designs declare, so a project knob can't take one.
6268
+ async function designKnobPaths(project) {
6269
+ const out = new Map();
6270
+ for (const d of await store.listDesigns(project)) {
6271
+ for (const path of Object.keys(d.knobs))
6272
+ if (!out.has(path))
6273
+ out.set(path, d.slug);
6274
+ }
6275
+ return out;
6276
+ }
6277
+ // What init read, cut to the sizes a frame and a brief can carry.
6278
+ function checkRepo(raw) {
6279
+ if (raw === null)
6280
+ return null;
6281
+ if (!isRecord(raw))
6282
+ return "repo is what `mockpit init` read";
6283
+ const designFiles = checkDesignFiles(raw.designFiles);
5913
6284
  if (typeof designFiles === "string")
5914
- return c.json({ error: designFiles }, 400);
5915
- const designSystem = {
5916
- detected: body.detected && typeof body.detected === "object"
6285
+ return designFiles;
6286
+ const d = raw.detected;
6287
+ return {
6288
+ detected: isRecord(d)
5917
6289
  ? {
5918
- tailwind: body.detected.tailwind === true,
5919
- shadcn: body.detected.shadcn === true,
5920
- cssVars: Number(body.detected.cssVars) || 0,
5921
- fonts: Array.isArray(body.detected.fonts)
5922
- ? body.detected.fonts.filter((f) => typeof f === "string").slice(0, 20)
6290
+ tailwind: d.tailwind === true,
6291
+ shadcn: d.shadcn === true,
6292
+ cssVars: Number(d.cssVars) || 0,
6293
+ fonts: Array.isArray(d.fonts)
6294
+ ? d.fonts.filter((f) => typeof f === "string").slice(0, 20)
5923
6295
  : [],
5924
6296
  }
5925
6297
  : null,
5926
- palette: body.palette && typeof body.palette === "object" && body.palette.light && body.palette.dark
5927
- ? body.palette
5928
- : null,
5929
- kit,
5930
- cssVars: typeof body.cssVars === "string" ? body.cssVars.slice(0, 64_000) : "",
6298
+ cssVars: typeof raw.cssVars === "string" ? raw.cssVars.slice(0, 64_000) : "",
5931
6299
  // A cut stylesheet would not compile, so an oversized one is dropped
5932
6300
  // whole and the frame falls back to `cssVars`.
5933
- tailwindCss: typeof body.tailwindCss === "string" && body.tailwindCss.length <= 128_000
5934
- ? body.tailwindCss
6301
+ tailwindCss: typeof raw.tailwindCss === "string" && raw.tailwindCss.length <= 128_000
6302
+ ? raw.tailwindCss
5935
6303
  : "",
5936
- strippedImports: Array.isArray(body.strippedImports)
5937
- ? body.strippedImports
6304
+ strippedImports: Array.isArray(raw.strippedImports)
6305
+ ? raw.strippedImports
5938
6306
  .filter((s) => typeof s === "string")
5939
6307
  .map((s) => s.slice(0, 200))
5940
6308
  .slice(0, 50)
5941
6309
  : [],
5942
- iconSets,
5943
- projectKits,
5944
6310
  designFiles,
5945
- updatedAt: new Date().toISOString(),
5946
6311
  };
5947
- return c.json(await saveDesignSystem(project, designSystem));
5948
- });
5949
- async function saveDesignSystem(project, designSystem) {
5950
- await store.setSetting(`design:${project}`, JSON.stringify(designSystem));
5951
- // Every rendered surface bakes the design into its document string, so a
5952
- // design change invalidates them all.
5953
- clearRenderCache();
5954
- bus.broadcast({ type: "theme-changed", mode: await workspaceMode() });
5955
- return designSystem;
5956
- }
5957
- const MAX_PROJECT_KITS = 16;
5958
- function checkProjectKits(raw) {
5959
- if (!Array.isArray(raw))
5960
- return "projectKits must be an array";
5961
- if (raw.length > MAX_PROJECT_KITS)
5962
- return `at most ${MAX_PROJECT_KITS} project kits`;
5963
- const kits = [];
5964
- for (const item of raw) {
5965
- const kit = checkProjectKit(item);
5966
- if (typeof kit === "string")
5967
- return kit;
5968
- if (kits.some((k) => k.id === kit.id))
5969
- return `kit "${kit.id}" is listed twice`;
5970
- kits.push(kit);
5971
- }
5972
- return kits;
5973
- }
5974
- const emptyDesignSystem = () => ({
5975
- detected: null,
5976
- palette: null,
5977
- kit: "none",
5978
- cssVars: "",
5979
- tailwindCss: "",
5980
- strippedImports: [],
5981
- iconSets: [],
5982
- projectKits: [],
5983
- updatedAt: new Date().toISOString(),
5984
- });
5985
- // Add or replace one project kit (`mockpit kit add`). A project that never ran
5986
- // init gets a design holding just the kit.
5987
- app.put("/api/projects/:name/kits/:id", async (c) => {
5988
- const body = await c.req.json().catch(() => null);
5989
- if (!body || typeof body !== "object")
5990
- return c.json({ error: "invalid JSON body" }, 400);
5991
- const project = c.req.param("name");
5992
- const kit = checkProjectKit({ ...body, id: c.req.param("id") });
5993
- if (typeof kit === "string")
5994
- return c.json({ error: kit }, 400);
5995
- const designSystem = (await designSystemFor(project)) ?? emptyDesignSystem();
5996
- const others = designSystem.projectKits.filter((k) => k.id !== kit.id);
5997
- if (others.length >= MAX_PROJECT_KITS)
5998
- return c.json({ error: `at most ${MAX_PROJECT_KITS} project kits` }, 400);
5999
- const saved = await saveDesignSystem(project, {
6000
- ...designSystem,
6001
- projectKits: [...others, kit],
6002
- updatedAt: new Date().toISOString(),
6003
- });
6004
- return c.json(saved);
6005
- });
6006
- // Removing the project's default kit falls back to no kit rather than leaving
6007
- // designSystem.kit naming something that no longer resolves.
6008
- app.delete("/api/projects/:name/kits/:id", async (c) => {
6009
- const project = c.req.param("name");
6010
- const id = c.req.param("id");
6011
- const designSystem = await designSystemFor(project);
6012
- if (!designSystem?.projectKits.some((k) => k.id === id))
6013
- return c.json({ error: `${project} has no kit "${id}"` }, 404);
6014
- const saved = await saveDesignSystem(project, {
6015
- ...designSystem,
6016
- kit: designSystem.kit === id ? "none" : designSystem.kit,
6017
- projectKits: designSystem.projectKits.filter((k) => k.id !== id),
6018
- updatedAt: new Date().toISOString(),
6312
+ }
6313
+ // The user's own system decisions that went to an agent, and whether it has them.
6314
+ async function systemTold(project) {
6315
+ const decided = (await store.listComments({ noDesign: true })).filter((c) => c.kind === "reply" && c.payload?.system?.project === project);
6316
+ return (await withDelivered(decided)).map((c) => ({
6317
+ rev: c.payload.system.rev,
6318
+ delivered: c.delivered,
6319
+ }));
6320
+ }
6321
+ // The system as an agent reads it: everything, with the repo's compiled
6322
+ // Tailwind input said by size, plus its revisions and what fails its checks.
6323
+ async function systemView(project, system, ctx) {
6324
+ const s = system ?? emptySystem(project);
6325
+ return {
6326
+ ...s,
6327
+ ...(s.repo?.tailwindCss
6328
+ ? {
6329
+ repo: {
6330
+ ...s.repo,
6331
+ tailwindCss: `${s.repo.tailwindCss.length} characters, compiled in every frame`,
6332
+ },
6333
+ }
6334
+ : {}),
6335
+ checks: systemChecks(s),
6336
+ revisions: (await store.listSystemRevisions(project)).slice(0, 10),
6337
+ // What the System page draws beside the palettes; agents read the checks.
6338
+ ...(ctx.viewer
6339
+ ? {
6340
+ contrast: pairResults(s, true),
6341
+ components: await componentIndex(project),
6342
+ told: await systemTold(project),
6343
+ }
6344
+ : {}),
6345
+ url: `${ctx.base}/project/${encodeURIComponent(project)}?sec=system`,
6346
+ };
6347
+ }
6348
+ async function systemFlow(projectArg, body, ctx) {
6349
+ const project = str(projectArg, MAX_TITLE);
6350
+ if (!project)
6351
+ return fail(400, 'provide "project"');
6352
+ if (body === undefined)
6353
+ return ok(await systemView(project, await systemFor(project), ctx));
6354
+ if (!isRecord(body))
6355
+ return fail(400, "invalid JSON body");
6356
+ const known = new Set([...SYSTEM_WRITE_KEYS, "restore", "session", "project", "agent"]);
6357
+ const unknown = Object.keys(body).filter((k) => !known.has(k));
6358
+ if (unknown.length) {
6359
+ return fail(400, `unknown ${unknown.length === 1 ? "argument" : "arguments"} ${unknown.map((k) => `"${k}"`).join(", ")}`, {
6360
+ arguments: [
6361
+ ...SYSTEM_WRITE_KEYS.filter((k) => !["repo", "projectKits", "removeKits", "iconSets", "kit"].includes(k)),
6362
+ "restore",
6363
+ ],
6364
+ });
6365
+ }
6366
+ const writing = Object.keys(body).some((k) => k !== "session" && k !== "project" && k !== "agent");
6367
+ if (!writing)
6368
+ return ok(await systemView(project, await systemFor(project), ctx));
6369
+ // The user's changes go through decide, which tells the agent.
6370
+ if (ctx.viewer)
6371
+ return fail(400, "the viewer changes the system through /system/decide");
6372
+ const sessionId = typeof body.session === "string" && (await store.getSession(body.session))
6373
+ ? body.session
6374
+ : null;
6375
+ if (sessionId)
6376
+ await store.touchAgent(sessionId);
6377
+ let write = body;
6378
+ if (body.iconSets !== undefined) {
6379
+ const checked = await checkIconSets(body.iconSets);
6380
+ if (typeof checked === "string")
6381
+ return fail(400, checked);
6382
+ write = { ...write, iconSets: checked };
6383
+ }
6384
+ if (body.repo !== undefined) {
6385
+ const repo = checkRepo(body.repo);
6386
+ if (typeof repo === "string")
6387
+ return fail(400, repo);
6388
+ write = { ...write, repo };
6389
+ }
6390
+ const kitPaths = body.knobs !== undefined ? await designKnobPaths(project) : new Map();
6391
+ return serialSystem(project, async () => {
6392
+ const current = (await systemFor(project)) ?? emptySystem(project);
6393
+ let next;
6394
+ let summary;
6395
+ let changed;
6396
+ // What the user decided, which an agent's restore leaves standing.
6397
+ let keptOnRestore = [];
6398
+ if (body.restore !== undefined) {
6399
+ if (Object.keys(body).some((k) => !["restore", "session", "project", "agent"].includes(k)))
6400
+ return fail(400, "restore stands alone: restore first, then write");
6401
+ const rev = Number(body.restore);
6402
+ const old = Number.isInteger(rev) ? await store.getSystemRevision(project, rev) : null;
6403
+ if (!old) {
6404
+ const kept = (await store.listSystemRevisions(project)).map((r) => r.rev);
6405
+ return fail(404, `no revision ${String(body.restore)} of ${project}'s system`, { kept });
6406
+ }
6407
+ const restored = restoreSystem(current, old, { keepDecided: true });
6408
+ next = restored.system;
6409
+ keptOnRestore = restored.kept;
6410
+ changed = describeChange(current, next);
6411
+ summary = `restored r${rev}${changed.length ? `: ${changed.join("; ")}` : ""}`;
6412
+ }
6413
+ else {
6414
+ const applied = applySystemWrite(current, write, {
6415
+ checkUrl: (url) => {
6416
+ const r = checkCdnUrl(url, "font url");
6417
+ return "error" in r ? r.error : null;
6418
+ },
6419
+ checkKnobs: (raw) => checkKnobs(raw),
6420
+ checkKit: (raw) => checkProjectKit(raw),
6421
+ kitIds: KIT_IDS,
6422
+ designKnobPaths: kitPaths,
6423
+ });
6424
+ if ("error" in applied)
6425
+ return fail(400, applied.error);
6426
+ next = applied.system;
6427
+ changed = describeChange(current, next);
6428
+ summary = changed.join("; ");
6429
+ }
6430
+ const checks = systemChecks(next);
6431
+ // What init derived, said on every rerun, changed or not.
6432
+ const derivedOf = (sys) => body.repo !== undefined
6433
+ ? {
6434
+ derived: {
6435
+ tokens: sys.tokens.filter((t) => t.owner === "repo").length,
6436
+ roles: sys.color.roles.filter((r) => r.owner === "repo").length,
6437
+ palette: sys.color.palettes.some((p) => p.owner === "repo"),
6438
+ untyped: sys.repo?.untyped ?? [],
6439
+ },
6440
+ }
6441
+ : {};
6442
+ if (!systemDiffers(current, next)) {
6443
+ return ok({
6444
+ rev: current.rev,
6445
+ changed: [],
6446
+ ...(keptOnRestore.length ? { kept: keptOnRestore } : {}),
6447
+ checks,
6448
+ ...derivedOf(current),
6449
+ url: `${ctx.base}/project/${encodeURIComponent(project)}?sec=system`,
6450
+ feedback: await feedbackFor(body.session, ctx.base),
6451
+ });
6452
+ }
6453
+ const saved = await store.putSystem({ ...next, rev: current.rev + 1 }, { sessionId, summary });
6454
+ // Every rendered surface of the project wears the system, so none stays.
6455
+ clearRenderCache();
6456
+ bus.broadcast({ type: "system-changed", project, rev: saved.rev, summary, by: "agent" });
6457
+ return ok({
6458
+ rev: saved.rev,
6459
+ changed,
6460
+ ...(keptOnRestore.length ? { kept: keptOnRestore } : {}),
6461
+ checks,
6462
+ ...derivedOf(saved),
6463
+ ...(checks.length ? { nudges: checks } : {}),
6464
+ url: `${ctx.base}/project/${encodeURIComponent(project)}?sec=system`,
6465
+ feedback: await feedbackFor(body.session, ctx.base),
6466
+ });
6019
6467
  });
6020
- return c.json(saved);
6021
- });
6468
+ }
6469
+ // Components by slug, with the first line of their notes and how many screens
6470
+ // embed them: the brief's index of what to reuse.
6471
+ async function componentIndex(project) {
6472
+ const designs = await store.listDesigns(project);
6473
+ const components = designs.filter((d) => d.kind === "component" && !d.archivedAt);
6474
+ if (components.length === 0)
6475
+ return [];
6476
+ const kindOf = new Map(designs.map((d) => [d.id, d]));
6477
+ const screens = new Map();
6478
+ for (const head of await store.listPostHeads(project)) {
6479
+ if (!isLive(head.status))
6480
+ continue;
6481
+ const d = kindOf.get(head.design);
6482
+ if (!d || d.kind !== "screen")
6483
+ continue;
6484
+ for (const e of head.embeds) {
6485
+ let set = screens.get(e.slug);
6486
+ if (!set)
6487
+ screens.set(e.slug, (set = new Set()));
6488
+ set.add(d.slug);
6489
+ }
6490
+ }
6491
+ return components.map((d) => ({
6492
+ slug: d.slug,
6493
+ purpose: (d.notes?.text ?? "").split("\n")[0].trim(),
6494
+ screens: screens.get(d.slug)?.size ?? 0,
6495
+ }));
6496
+ }
6022
6497
  // Every set an html surface of this project can name, installed first.
6023
6498
  app.get("/api/projects/:name/icons", async (c) => {
6024
- const sets = await iconSetsFor(await designSystemFor(c.req.param("name")));
6499
+ const sets = await iconSetsFor(await systemFor(c.req.param("name")));
6025
6500
  return c.json({
6026
6501
  sets: sets.map((s) => ({ prefix: s.prefix, count: iconCount(s.set), source: s.source })),
6027
6502
  });
@@ -6072,6 +6547,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6072
6547
  count: Math.max(0, Math.floor(Number(tokens.count) || 0)),
6073
6548
  cssVars: tokens.cssVars === true,
6074
6549
  values: flat(tokens.values, 400),
6550
+ ...(isRecord(tokens.types) ? { types: flat(tokens.types, 400) } : {}),
6075
6551
  };
6076
6552
  }
6077
6553
  const shadcn = raw.shadcn;
@@ -6159,7 +6635,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6159
6635
  // agent publishing over one origin must never be served to a viewer on another.
6160
6636
  // `knobs` is the canonical (validated, path-sorted) JSON of `?k=`, so two
6161
6637
  // spellings of the same values share an entry.
6162
- const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}:${o.embeds ?? ""}${o.rewrites ? `:w${o.rewrites}` : ""}`;
6638
+ const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}:${o.embeds ?? ""}${o.rewrites ? `:w${o.rewrites}` : ""}:y${o.system ?? 0}:${o.palette ?? ""}`;
6163
6639
  // After a version is rewritten in place: a frame still naming the old `w`
6164
6640
  // must miss the cache and get the version as it is now.
6165
6641
  const dropPostRenders = (postId) => {
@@ -6177,7 +6653,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6177
6653
  // Embedded components are expanded server-side (the caller passes the
6178
6654
  // expansion), so the whole page is still ONE sandboxed document rather
6179
6655
  // than nested frames.
6180
- const html = withAnswerAdditions(args.composed ?? surface.html, args.design?.answerAdditions ?? []);
6656
+ const html = withAnswerAdditions(args.composed ?? surface.html, [
6657
+ ...(args.design?.answerAdditions ?? []),
6658
+ ...(args.system?.additions ?? []),
6659
+ ]);
6660
+ const knobs = { ...projectKnobValues(args.system), ...args.knobs };
6181
6661
  const page = renderHtmlPage({
6182
6662
  title: args.title,
6183
6663
  html,
@@ -6185,12 +6665,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6185
6665
  theme,
6186
6666
  mode,
6187
6667
  kits: surface.kits,
6188
- designSystem: args.designSystem,
6668
+ system: args.system,
6669
+ palette: args.palette,
6670
+ // A version from before contracts stored none: it renders as it was approved.
6671
+ contract: args.contract ?? 1,
6189
6672
  version: args.version,
6190
- knobs: args.knobs,
6191
- resolveIcon: mayHaveIcons(html)
6192
- ? (await iconResolverFor(args.designSystem)).resolve
6193
- : undefined,
6673
+ knobs: Object.keys(knobs).length ? knobs : undefined,
6674
+ resolveIcon: mayHaveIcons(html) ? (await iconResolverFor(args.system)).resolve : undefined,
6194
6675
  });
6195
6676
  return cdnInliner.inline(page, html);
6196
6677
  }
@@ -6254,7 +6735,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6254
6735
  if (!origin)
6255
6736
  return;
6256
6737
  void (async () => {
6257
- const designSystem = await designSystemFor(design.project);
6738
+ const system = await systemFor(design.project);
6739
+ const palette = system ? activePalette(system, undefined)?.id : undefined;
6258
6740
  let warmed = 0;
6259
6741
  for (const [idx, surface] of post.surfaces.entries()) {
6260
6742
  if (warmed >= PREWARM_SURFACE_LIMIT)
@@ -6274,6 +6756,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6274
6756
  origin,
6275
6757
  embeds: composed ? embedsKey(composed) : undefined,
6276
6758
  rewrites: post.rewrites,
6759
+ system: system?.rev,
6277
6760
  });
6278
6761
  if (renderCache.has(key))
6279
6762
  continue;
@@ -6285,7 +6768,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6285
6768
  version: post.version,
6286
6769
  mode,
6287
6770
  origin,
6288
- designSystem,
6771
+ system,
6772
+ palette,
6773
+ contract: post.contract,
6289
6774
  composed: composed?.html,
6290
6775
  }));
6291
6776
  }
@@ -6306,6 +6791,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6306
6791
  const embedParam = c.req.query("embed");
6307
6792
  // `w`: the variant's rewrites the caller saw, since an amend keeps the number.
6308
6793
  const wParam = Number(c.req.query("w") ?? 0) || 0;
6794
+ // `y`: the system revision the caller saw; `sys`: a peek at an older one;
6795
+ // `p`: the palette to wear (absent: the default).
6796
+ const yParam = Number(c.req.query("y") ?? 0) || 0;
6797
+ const sysParam = Number(c.req.query("sys") ?? 0) || 0;
6798
+ const pRaw = c.req.query("p");
6799
+ const pParam = pRaw && /^[a-z][a-z0-9-]{0,47}$/.test(pRaw) ? pRaw : undefined;
6309
6800
  const origin = requestOrigin(c.req.raw);
6310
6801
  // Cache-first. A hit needs nothing from the post row, and the row is the
6311
6802
  // expensive part of this route (surfaces + every retained version). Only a
@@ -6325,6 +6816,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6325
6816
  origin,
6326
6817
  embeds: eParam,
6327
6818
  rewrites: wParam,
6819
+ system: sysParam || yParam,
6820
+ palette: pParam,
6328
6821
  }));
6329
6822
  if (hit !== undefined) {
6330
6823
  surfaceDocHeaders(c, true);
@@ -6338,6 +6831,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6338
6831
  let title = post.title;
6339
6832
  let surfaces = post.surfaces;
6340
6833
  let version = post.version;
6834
+ let contract = post.contract;
6341
6835
  if (ver && Number(ver) !== post.version) {
6342
6836
  const old = post.history.find((h) => h.version === Number(ver));
6343
6837
  if (!old)
@@ -6345,6 +6839,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6345
6839
  title = old.title;
6346
6840
  surfaces = old.surfaces;
6347
6841
  version = old.version;
6842
+ contract = old.contract;
6348
6843
  }
6349
6844
  const idx = Number(surfaceParam ?? 0);
6350
6845
  const surface = surfaces[idx];
@@ -6371,7 +6866,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6371
6866
  catch {
6372
6867
  return bad("k must be URL-encoded JSON of {path: value}");
6373
6868
  }
6374
- const checked = checkKnobValues(raw, design ?? { knobs: {} }, [post], "k");
6869
+ const project = design ? (await systemFor(design.project))?.knobs : undefined;
6870
+ const checked = checkKnobValues(raw, { knobs: { ...project, ...design?.knobs } }, [post], "k");
6375
6871
  if (!checked.ok)
6376
6872
  return bad(checked.error);
6377
6873
  const paths = Object.keys(checked.value).sort();
@@ -6407,6 +6903,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6407
6903
  // Cache the finished document under the same key the pre-warm uses; the
6408
6904
  // resolved `version` and embeds key make it immutable, so a hit is always
6409
6905
  // correct.
6906
+ let system = design ? await systemFor(design.project) : null;
6907
+ const currentRev = system?.rev ?? 0;
6908
+ if (sysParam && design) {
6909
+ system = await store.getSystemRevision(design.project, sysParam);
6910
+ if (!system) {
6911
+ surfaceDocHeaders(c, false);
6912
+ return c.text(`System revision ${sysParam} not available`, 404);
6913
+ }
6914
+ }
6915
+ const palette = system ? activePalette(system, pParam)?.id : undefined;
6410
6916
  const cacheKey = renderKey({
6411
6917
  postId: post.id,
6412
6918
  idx,
@@ -6416,8 +6922,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6416
6922
  knobs: knobsKey,
6417
6923
  embeds: eKey,
6418
6924
  rewrites: post.rewrites,
6925
+ system: system?.rev,
6926
+ palette: pParam,
6419
6927
  });
6420
- const designSystem = design ? await designSystemFor(design.project) : null;
6421
6928
  const { doc, complete } = await cachedRender(cacheKey, () => buildSurfaceDoc({
6422
6929
  post,
6423
6930
  design,
@@ -6426,7 +6933,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6426
6933
  version,
6427
6934
  mode,
6428
6935
  origin,
6429
- designSystem,
6936
+ system,
6937
+ palette,
6938
+ contract,
6430
6939
  knobs,
6431
6940
  composed: composed?.html,
6432
6941
  }));
@@ -6437,7 +6946,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6437
6946
  surfaceDocHeaders(c, complete &&
6438
6947
  ver != null &&
6439
6948
  (eKey === undefined || eKey === eParam) &&
6440
- wParam === (post.rewrites ?? 0));
6949
+ wParam === (post.rewrites ?? 0) &&
6950
+ // The system follows its latest revision unless the URL names one.
6951
+ (sysParam > 0 || !system || yParam === currentRev));
6441
6952
  return c.html(doc);
6442
6953
  };
6443
6954
  app.get("/s/:id", renderPostPage);
@@ -6652,6 +7163,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6652
7163
  const mcpFlows = {
6653
7164
  publish: (body, ctx) => publishFlow(body, ctx),
6654
7165
  list: (query) => listDesignsFlow(query),
7166
+ exportSystem: (project) => exportSystemFlow(project),
6655
7167
  tree: (query) => treeFlow(query),
6656
7168
  decisions: (query) => decisionsFlow(query),
6657
7169
  get: (ref, query) => getDesignFlow(ref, query),
@@ -6662,8 +7174,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {
6662
7174
  feedback: (query, ctx) => feedbackFlow(query, ctx),
6663
7175
  edit: (ref, body, ctx) => editDesignFlow(ref, body, ctx),
6664
7176
  editProject: (name, body, ctx) => editProjectFlow(name, body, ctx),
7177
+ system: (project, body, ctx) => systemFlow(project, body, ctx),
6665
7178
  };
6666
- const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
7179
+ const guide = (project, topic) => topic === "system"
7180
+ ? systemTopicFor(project)
7181
+ : topic
7182
+ ? (topics[topic] ?? "")
7183
+ : briefFor(project);
6667
7184
  // --- run: an agent script over the same flows (server/run.ts) ---
6668
7185
  const runFlow = createRunFlow({
6669
7186
  store,