mockpit 1.3.0 → 2.0.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 (45) hide show
  1. package/README.md +61 -45
  2. package/bin/demoData.js +3 -3
  3. package/bin/mockpit.js +327 -52
  4. package/dist/mcp/server.js +35 -0
  5. package/dist/server/apiViews.d.ts +308 -23
  6. package/dist/server/apiViews.js +60 -19
  7. package/dist/server/app.d.ts +4 -1
  8. package/dist/server/app.js +608 -119
  9. package/dist/server/codeWorker.js +2 -1
  10. package/dist/server/decisions.d.ts +5 -5
  11. package/dist/server/decisions.js +10 -9
  12. package/dist/server/designGuide.d.ts +6 -2
  13. package/dist/server/designGuide.js +77 -43
  14. package/dist/server/embeds.d.ts +1 -0
  15. package/dist/server/embeds.js +30 -4
  16. package/dist/server/feedbackBatch.d.ts +6 -6
  17. package/dist/server/feedbackBatch.js +18 -11
  18. package/dist/server/guidance.d.ts +23 -0
  19. package/dist/server/guidance.js +71 -0
  20. package/dist/server/index.js +10 -1
  21. package/dist/server/mcpHttp.d.ts +3 -0
  22. package/dist/server/mcpHttp.js +32 -0
  23. package/dist/server/mcpSpec.d.ts +2 -1
  24. package/dist/server/mcpSpec.js +74 -24
  25. package/dist/server/postSurfaces.js +3 -0
  26. package/dist/server/run.js +6 -1
  27. package/dist/server/runApi.d.ts +3 -3
  28. package/dist/server/runApi.js +16 -12
  29. package/dist/server/sqlStore.d.ts +10 -3
  30. package/dist/server/sqlStore.js +230 -16
  31. package/dist/server/types.d.ts +46 -17
  32. package/dist/server/types.js +34 -11
  33. package/extensions/mockpit.js +179 -16
  34. package/guide/packs/design-craft.md +35 -0
  35. package/guide/topics/asks.md +60 -56
  36. package/guide/topics/feedback.md +104 -74
  37. package/guide/topics/html.md +5 -11
  38. package/guide/topics/http.md +6 -3
  39. package/guide/topics/knobs.md +2 -2
  40. package/guide/topics/scripts.md +2 -2
  41. package/guide/topics/surfaces.md +28 -10
  42. package/package.json +1 -4
  43. package/skills/launch-video/SKILL.md +1 -1
  44. package/skills/mockpit/SKILL.md +18 -10
  45. package/viewer/dist/index.html +8 -7
@@ -5,11 +5,12 @@ import { streamSSE } from "hono/streaming";
5
5
  import { ICON_SIZES, ICON_SVG, themeColor, webManifest } from "./appIcon.js";
6
6
  import { ICON_PNG_BASE64 } from "./appIconPng.js";
7
7
  import { decodeBase64 } from "./base64.js";
8
- import { mockDetailView, treeNodeView, mockSummaryView, sessionRowView, surfaceRef, } from "./apiViews.js";
8
+ import { mockDetailView, treeNodeView, mockSummaryView, placeOf, sessionRowView, surfaceRef, } from "./apiViews.js";
9
9
  import { askNudges } from "./askNudges.js";
10
10
  import { EventBus } from "./events.js";
11
11
  import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
12
12
  import { buildFeedbackBatches } from "./feedbackBatch.js";
13
+ import { guidanceSections, MAX_RULES_CHARS, parseRulesFile, RULES_SETTING, USER_RULES_SETTING, } from "./guidance.js";
13
14
  import { HANDOFF_NOTE, handoffReady, supersedes } from "./handoff.js";
14
15
  import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
15
16
  import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
@@ -26,7 +27,7 @@ import { logMiddleware, writeLog } from "./eventLog.js";
26
27
  import { RUN_API } from "./runApi.js";
27
28
  import { escapeHtml, renderHtmlPage, renderMermaidPage, knobAttributes, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
28
29
  import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
29
- import { askAnswered, builtinAsk, builtinAskIds, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, inUseVersion, isLive, optionState, newId, openAsks, openCount, OTHER_ID, REACTIONS, projectFromCwd, postAtVersion, versionAt, reservedAgent, sameScreen, slugify, surfacesByteLength, } from "./types.js";
30
+ import { askAnswered, builtinAsk, builtinAskIds, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, inUseVersion, isLive, isMockKind, momentOf, NORMAL_SITUATION, optionState, newId, openAsks, openCount, OTHER_ID, REACTIONS, projectFromCwd, AREA_ORDER_SETTING, stripNul, postAtVersion, versionAt, variantStatus, reservedAgent, sameScreen, situationKey, slugify, surfacesByteLength, } from "./types.js";
30
31
  import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
31
32
  import { isViewerRequest } from "./viewerRequest.js";
32
33
  const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
@@ -169,16 +170,33 @@ const fail = (status, error, extra = {}) => ({
169
170
  body: { error, ...extra },
170
171
  });
171
172
  const MAX_NOTE = 400;
173
+ const MAX_SOURCE = 1024;
172
174
  const MAX_WIDTHS = 4;
173
175
  const MIN_WIDTH = 280;
174
176
  const MAX_WIDTH = 2560;
175
177
  const str = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
176
178
  const MAX_LABEL = 120;
179
+ const MAX_AREAS = 50;
177
180
  const MAX_FLOWS = 50;
178
181
  const MAX_FLOW_STEPS = 100;
179
182
  const MAX_ASKS = 20;
180
183
  const MAX_OPTIONS = 20;
181
184
  const MAX_DRAFT_COMMENTS = 50;
185
+ // A title that names a step of the agent's process instead of what the mock
186
+ // is: it reads as noise on Home, where titles are seen out of context.
187
+ const PROCESS_TITLE = /^\s*(round\s*\d+|flow\s*\d+\s*[·:\-–—]|v\d+\b|prototype\b)/i;
188
+ // Once, on a variant's first version: a canvas page runs live in every
189
+ // overview card, and a browser holds only about 16 WebGL contexts.
190
+ const posterNudges = (surfaces) => surfaces.some((s) => s.kind === "html" && !s.poster && /<canvas[\s>]/i.test(s.html))
191
+ ? [
192
+ "this page draws on a <canvas> and has no poster, so Studio and Home run it live; send a still of it as poster (upload the image, or CLI --poster shot.png) with each version",
193
+ ]
194
+ : [];
195
+ const titleNudges = (title) => title && PROCESS_TITLE.test(title)
196
+ ? [
197
+ `title "${title}" names your process; title the mock by what it is ("Hold menu"), as states are named by what the user sees`,
198
+ ]
199
+ : [];
182
200
  const APP_FILES = new Set([
183
201
  "/manifest.webmanifest",
184
202
  "/icon.svg",
@@ -186,7 +204,7 @@ const APP_FILES = new Set([
186
204
  ...ICON_SIZES.map((s) => `/icon-${s}.png`),
187
205
  ]);
188
206
  const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
189
- export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, renderRev, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
207
+ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {}, userRules, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, renderRev, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
190
208
  const app = new Hono();
191
209
  // The scripts topic carries the run API from runApi.ts, its one source.
192
210
  if (topics.scripts)
@@ -534,13 +552,42 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
534
552
  async function briefFor(project) {
535
553
  try {
536
554
  const name = project ?? (await store.listProjects())[0]?.name ?? DEFAULT_PROJECT;
537
- return renderBriefGuide(await designFor(name));
555
+ return renderBriefGuide(await designFor(name), await guidanceFor(name));
538
556
  }
539
557
  catch (err) {
540
558
  console.warn("[mockpit] project design unavailable for the brief", err);
541
559
  return renderBriefGuide(null);
542
560
  }
543
561
  }
562
+ // The user's file wins over the stored copy when the host can read it: the
563
+ // copy exists for hosts that can't.
564
+ async function userRulesText() {
565
+ const file = userRules ? await userRules().catch(() => null) : null;
566
+ return file ?? (await store.getSetting(USER_RULES_SETTING));
567
+ }
568
+ async function guidanceFor(project) {
569
+ return guidanceSections({
570
+ packs,
571
+ user: await userRulesText(),
572
+ project: await store.getSetting(`${RULES_SETTING}:${project}`),
573
+ });
574
+ }
575
+ // A rules file as sent by `mockpit rules`: stored whole, "" clears it. The
576
+ // answer says what the brief will make of it, so a mistyped pack name shows
577
+ // at once rather than as a quietly missing layer.
578
+ async function saveRules(key, body) {
579
+ if (!body || typeof body !== "object" || typeof body.text !== "string") {
580
+ return fail(400, 'provide "text", the rules file ("" clears it)');
581
+ }
582
+ const text = stripNul(body.text).trim();
583
+ if (text.length > MAX_RULES_CHARS) {
584
+ return fail(400, `a rules file is at most ${MAX_RULES_CHARS} characters`);
585
+ }
586
+ await store.setSetting(key, text);
587
+ const parsed = parseRulesFile(text);
588
+ const unknown = parsed.packs.filter((p) => !(p in packs));
589
+ return ok({ packs: parsed.packs, unknown, rules: parsed.body.length > 0, cleared: !text });
590
+ }
544
591
  // Push + webhooks. Detached on purpose: a dead push endpoint must never fail
545
592
  // (or slow) the write that triggered it.
546
593
  function fireNotify(payload) {
@@ -698,6 +745,45 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
698
745
  code: failure.code,
699
746
  issues: failure.issues,
700
747
  });
748
+ // A poster is a still of the html beside it. Carried over unchanged onto
749
+ // changed html (a parts or fills splice, a fork with edits) it would show an
750
+ // old page, so it goes unless the write brings one; `poster` names the still
751
+ // of the page this write produces.
752
+ async function withPoster(surfaces, before, poster) {
753
+ const given = poster === undefined ? undefined : str(poster, MAX_LABEL);
754
+ if (poster !== undefined && !given) {
755
+ return fail(400, '"poster" must be the asset id of an uploaded image');
756
+ }
757
+ const pagesBefore = (before ?? []).filter((s) => s.kind === "html");
758
+ let ordinal = 0;
759
+ const out = surfaces.map((s) => {
760
+ if (s.kind !== "html")
761
+ return s;
762
+ const old = (s.id !== undefined && pagesBefore.find((b) => b.id === s.id)) || pagesBefore[ordinal];
763
+ let next = s;
764
+ if (s.poster && old?.kind === "html" && old.html !== s.html && old.poster === s.poster) {
765
+ const { poster: _stale, ...rest } = s;
766
+ next = rest;
767
+ }
768
+ if (ordinal === 0 && given)
769
+ next = { ...next, poster: given };
770
+ ordinal++;
771
+ return next;
772
+ });
773
+ if (given && ordinal === 0)
774
+ return fail(400, '"poster" needs an html surface to be the still of');
775
+ for (const s of out) {
776
+ if (s.kind !== "html" || !s.poster)
777
+ continue;
778
+ const asset = await store.getAsset(s.poster);
779
+ if (!asset)
780
+ return fail(404, `poster "${s.poster}" is not an uploaded asset; upload the image first`);
781
+ if (!asset.contentType.startsWith("image/")) {
782
+ return fail(400, `poster "${s.poster}" is ${asset.contentType}, not an image`);
783
+ }
784
+ }
785
+ return out;
786
+ }
701
787
  function checkSurfaces(surfaces) {
702
788
  if (surfaces.length === 0)
703
789
  return fail(400, "a variant needs at least one surface");
@@ -772,6 +858,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
772
858
  }
773
859
  return used;
774
860
  }
861
+ // Being embedded is what makes a mock a component, so a sketch another mock
862
+ // embeds becomes one; a declared page stays a page.
863
+ async function promoteEmbedded(project, embeds) {
864
+ for (const slug of new Set(embeds.map((e) => e.slug))) {
865
+ const m = await store.findMock(project, slug);
866
+ if (m?.kind !== "sketch")
867
+ continue;
868
+ const updated = await store.updateMock(m.id, { kind: "component" });
869
+ if (updated)
870
+ bus.broadcast({ type: "mock-updated", id: updated.id, project });
871
+ }
872
+ }
775
873
  // What the variants of a mock show through their embeds now: per post, the
776
874
  // resolved list (nested included) with each component's versions, and the
777
875
  // key that changes when any of them does.
@@ -862,7 +960,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
862
960
  const states = mock.states.length ? mock.states : [null];
863
961
  const wide = states.every((s) => key(live(s)) === key(names));
864
962
  // An ask that already picks between some of these variants is the question:
865
- // a second, generic "Which one?" would make the user choose the look twice.
963
+ // a second, generic "Which variant?" would make the user choose twice.
866
964
  // A mock-wide one only fits while every state has the same variants; else a
867
965
  // variant only some states have would leave the others no real choice.
868
966
  const existing = mock.asks.find((a) => applies(a) && a.options.some((o) => o.variant) && (a.scope === "state" || wide));
@@ -886,7 +984,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
886
984
  ],
887
985
  };
888
986
  return {
889
- nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing ask "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
987
+ nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing question "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
890
988
  ask,
891
989
  };
892
990
  }
@@ -894,15 +992,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
894
992
  // Not the built-in's reserved id: a stored ask under it would collide with the
895
993
  // built-in the viewer synthesizes once a new variant goes unbound. Nor any id
896
994
  // already taken, which sending this would silently replace.
897
- const base = wide ? "look" : `look-${slugify(state ?? "")}`;
995
+ const base = wide ? "choice" : `choice-${slugify(state ?? "")}`;
898
996
  let id = base;
899
997
  for (let n = 2; mock.asks.some((a) => a.id === id); n++)
900
998
  id = `${base}-${n}`;
901
999
  const ask = wide
902
- ? { id, text: "Which one?", scope: "mock", options }
903
- : { id, text: "Which one?", scope: "state", state, options };
1000
+ ? { id, text: "Which variant?", scope: "mock", options }
1001
+ : { id, text: "Which variant?", scope: "state", state, options };
904
1002
  return {
905
- nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk. Never ask in chat.`,
1003
+ nudge: `${where} has ${names.length} variants and no question binds them: call ask with suggestedAsk. Never ask in chat.`,
906
1004
  ask,
907
1005
  };
908
1006
  }
@@ -913,7 +1011,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
913
1011
  const posts = await store.listPosts({ mockId: mock.id });
914
1012
  const detail = mockDetailView(mock, posts);
915
1013
  const unbound = unboundVariants(mock, posts, post.state);
916
- // A slot no ask sets is a decision nobody was asked; hand over the ask.
1014
+ // A part with fills that no ask sets is a decision nobody was asked; hand over the ask.
917
1015
  const slots = slotsInSurfaces(post.surfaces);
918
1016
  const setPaths = new Set(mock.asks.flatMap((a) => a.options.flatMap((o) => Object.keys(o.set ?? {}))));
919
1017
  const slotAsks = slots
@@ -930,7 +1028,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
930
1028
  ...(unbound ? [unbound.nudge] : []),
931
1029
  ...(slotAsks.length
932
1030
  ? [
933
- `${slotAsks.map((a) => `slot "${a.part}"`).join(", ")} ${slotAsks.length === 1 ? "has" : "have"} fills and no ask sets ${slotAsks.length === 1 ? "it" : "them"}: send suggestedSlotAsks with ask (reword the text in the user's terms)`,
1031
+ `${slotAsks.map((a) => `part "${a.part}"`).join(", ")} ${slotAsks.length === 1 ? "has" : "have"} fills and no question sets ${slotAsks.length === 1 ? "it" : "them"}: send suggestedFillAsks with ask (reword the text in the user's terms)`,
934
1032
  ]
935
1033
  : []),
936
1034
  ];
@@ -947,6 +1045,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
947
1045
  slug: mock.slug,
948
1046
  title: mock.title,
949
1047
  kind: mock.kind,
1048
+ ...placeOf(mock),
950
1049
  states: mock.states,
951
1050
  },
952
1051
  post: {
@@ -954,20 +1053,20 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
954
1053
  state: post.state,
955
1054
  variant: post.variant,
956
1055
  version: post.version,
957
- status: post.status,
1056
+ status: variantStatus(post.status),
958
1057
  surfaces: post.surfaces.map(surfaceRef),
959
1058
  },
960
1059
  sessionId: post.sessionId,
961
1060
  url: mockUrl(ctx.base, mock, post),
962
1061
  parts: detail.parts,
963
- ...(slots.length ? { slots } : {}),
1062
+ ...(slots.length ? { fills: slots } : {}),
964
1063
  ...(extra.applied ? { applied: extra.applied } : {}),
965
1064
  ...(partChanges && (partChanges.vanished.length || partChanges.renamed.length)
966
1065
  ? { partChanges }
967
1066
  : {}),
968
1067
  ...(nudges.length ? { nudges } : {}),
969
1068
  ...(unbound ? { suggestedAsk: unbound.ask } : {}),
970
- ...(slotAsks.length ? { suggestedSlotAsks: slotAsks } : {}),
1069
+ ...(slotAsks.length ? { suggestedFillAsks: slotAsks } : {}),
971
1070
  ...(warnings.length ? { warnings } : {}),
972
1071
  ...(extra.flow ? { flow: extra.flow } : {}),
973
1072
  feedback,
@@ -1031,7 +1130,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1031
1130
  return fail(400, variantKnobs.error);
1032
1131
  const stateIn = stateArg(body.state);
1033
1132
  const variantIn = str(body.variant, MAX_LABEL);
1034
- const kind = body.kind === "page" || body.kind === "component" ? body.kind : undefined;
1133
+ const kind = isMockKind(body.kind) ? body.kind : undefined;
1035
1134
  const title = str(body.title, MAX_TITLE);
1036
1135
  const fromN = Number(body.from);
1037
1136
  const from = Number.isInteger(fromN) && fromN > 0 ? fromN : undefined;
@@ -1041,6 +1140,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1041
1140
  const widths = widthsArg(body.widths);
1042
1141
  if (isResult(widths))
1043
1142
  return widths;
1143
+ const area = labelArg(body, "area", MAX_LABEL);
1144
+ if (isResult(area))
1145
+ return area;
1146
+ const source = labelArg(body, "source", MAX_SOURCE);
1147
+ if (isResult(source))
1148
+ return source;
1044
1149
  // An existing mock is addressable by id before a session exists.
1045
1150
  let mock = null;
1046
1151
  if (typeof body.mock === "string")
@@ -1072,6 +1177,32 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1072
1177
  }
1073
1178
  state = stateIn;
1074
1179
  }
1180
+ const situationIn = str(body.situation, MAX_LABEL);
1181
+ // A situation is another render of one state ("Couldn't save"); "Normal"
1182
+ // names the state's own render.
1183
+ let situation;
1184
+ if (situationIn && situationIn.toLowerCase() !== NORMAL_SITUATION.toLowerCase()) {
1185
+ if (state === null) {
1186
+ return fail(400, '"situation" needs "state": the state it is another render of');
1187
+ }
1188
+ if (!mock || !mock.states.includes(state)) {
1189
+ return fail(404, `${slug} has no state "${state}" yet: publish its normal render first, then its situations`, mock ? { states: mock.states } : {});
1190
+ }
1191
+ const moment = momentOf(mock.situations, state);
1192
+ // The key feedback names it by, passed back with its own situation.
1193
+ if (moment !== state && moment !== null && situationKey(moment, situationIn) === state) {
1194
+ state = moment;
1195
+ }
1196
+ else if (moment !== state) {
1197
+ return fail(400, `"${state}" is a situation; give "situation" the state it belongs to`);
1198
+ }
1199
+ const key = situationKey(state, situationIn);
1200
+ if (mock.states.includes(key) && !(mock.situations[state] ?? []).includes(situationIn)) {
1201
+ return fail(409, `${slug} already has a state named "${key}"; name the situation otherwise`);
1202
+ }
1203
+ situation = { of: state, name: situationIn };
1204
+ state = key;
1205
+ }
1075
1206
  const inState = posts.filter((p) => p.state === state);
1076
1207
  let variant = variantIn;
1077
1208
  if (!variant) {
@@ -1094,7 +1225,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1094
1225
  const missing = () => fail(404, `${slug} has no variant "${variant}"${state === null ? "" : ` in "${state}"`}`, {
1095
1226
  variants: posts.map(variantLabel),
1096
1227
  });
1097
- const screen = { mock: slug, state };
1228
+ // A flow steps through states; a situation's step is its state's.
1229
+ const screen = { mock: slug, state: situation ? situation.of : state };
1098
1230
  if (flowIn) {
1099
1231
  const full = await flowFull(project, flowIn.name, screen, flowIn.step);
1100
1232
  if (full)
@@ -1171,13 +1303,23 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1171
1303
  return parsed;
1172
1304
  surfaces = parsed;
1173
1305
  }
1306
+ const before = base();
1307
+ const postered = await withPoster(surfaces, isResult(before) ? null : before, body.poster);
1308
+ if (isResult(postered))
1309
+ return postered;
1310
+ surfaces = postered;
1174
1311
  const bad = checkSurfaces(surfaces);
1175
1312
  if (bad)
1176
1313
  return bad;
1177
- const nudges = knobNudges({ ...knobs.value, ...variantKnobs.value });
1314
+ const nudges = [
1315
+ ...knobNudges({ ...knobs.value, ...variantKnobs.value }),
1316
+ ...titleNudges(title),
1317
+ ...(target ? [] : posterNudges(surfaces)),
1318
+ ];
1178
1319
  const embeds = await publishEmbeds(project, slug, surfaces);
1179
1320
  if (isResult(embeds))
1180
1321
  return embeds;
1322
+ await promoteEmbedded(project, embeds);
1181
1323
  if (!target) {
1182
1324
  // A new variant: carried by the session the agent is writing from.
1183
1325
  if (!session) {
@@ -1194,7 +1336,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1194
1336
  project,
1195
1337
  slug,
1196
1338
  title: title ?? titleFromSlug(slug),
1197
- kind: kind ?? "component",
1339
+ kind: kind ?? "sketch",
1340
+ ...(area === undefined ? {} : { area }),
1341
+ ...(source === undefined ? {} : { source }),
1198
1342
  states: state === null ? [] : [state],
1199
1343
  knobs: knobs.value,
1200
1344
  ...(widths ? { widths } : {}),
@@ -1225,10 +1369,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1225
1369
  return fail(404, "session not found");
1226
1370
  mock = await updateMockAfterWrite(mock, {
1227
1371
  state,
1372
+ situation,
1228
1373
  kind,
1229
1374
  title,
1230
1375
  knobs: knobs.value,
1231
1376
  widths,
1377
+ area,
1378
+ source,
1232
1379
  session,
1233
1380
  });
1234
1381
  const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
@@ -1255,7 +1402,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1255
1402
  if (!updated)
1256
1403
  return fail(404, "variant not found");
1257
1404
  const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
1258
- mock = await updateMockAfterWrite(mock, { state, kind, knobs: knobs.value, widths, session });
1405
+ mock = await updateMockAfterWrite(mock, {
1406
+ state,
1407
+ situation,
1408
+ kind,
1409
+ knobs: knobs.value,
1410
+ widths,
1411
+ area,
1412
+ source,
1413
+ session,
1414
+ });
1259
1415
  announcePost(mock, updated, false);
1260
1416
  if (prompt && ctx.request)
1261
1417
  notifyPublish(mock, updated, prompt, ctx);
@@ -1342,6 +1498,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1342
1498
  return fail(400, bad);
1343
1499
  return version === undefined ? { variant } : { variant, version };
1344
1500
  }
1501
+ // An optional free-text field of the mock (`area`, `source`): absent keeps
1502
+ // it, "" or null clears it.
1503
+ function labelArg(body, key, max) {
1504
+ const v = body?.[key];
1505
+ if (v === undefined)
1506
+ return undefined;
1507
+ if (v === null)
1508
+ return null;
1509
+ if (typeof v !== "string")
1510
+ return fail(400, `"${key}" must be a string; "" clears it`);
1511
+ return str(v, max) ?? null;
1512
+ }
1345
1513
  // `widths`: the device widths the mock is judged at, in the agent's order;
1346
1514
  // [] hands the choice back to the viewer.
1347
1515
  function widthsArg(v) {
@@ -1383,14 +1551,34 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1383
1551
  // A write may add a state, retitle the mock, declare knobs, and makes its
1384
1552
  // session the one a reply is delivered to.
1385
1553
  async function updateMockAfterWrite(mock, w) {
1386
- const states = w.state !== null && !mock.states.includes(w.state) ? [...mock.states, w.state] : undefined;
1554
+ let states;
1555
+ let situations;
1556
+ if (w.state !== null && !mock.states.includes(w.state)) {
1557
+ if (w.situation) {
1558
+ // After its state and that state's other situations, so the order of
1559
+ // states reads moment by moment.
1560
+ const { of, name } = w.situation;
1561
+ const own = mock.situations[of] ?? [];
1562
+ const last = [of, ...own.map((n) => situationKey(of, n))]
1563
+ .map((st) => mock.states.indexOf(st))
1564
+ .reduce((a, b) => Math.max(a, b), -1);
1565
+ states = [...mock.states.slice(0, last + 1), w.state, ...mock.states.slice(last + 1)];
1566
+ situations = { ...mock.situations, [of]: [...own, name] };
1567
+ }
1568
+ else {
1569
+ states = [...mock.states, w.state];
1570
+ }
1571
+ }
1387
1572
  const knobs = Object.keys(w.knobs).length ? { ...mock.knobs, ...w.knobs } : undefined;
1388
1573
  const updated = await store.updateMock(mock.id, {
1389
1574
  ...(states ? { states } : {}),
1575
+ ...(situations ? { situations } : {}),
1390
1576
  ...(w.kind ? { kind: w.kind } : {}),
1391
1577
  ...(w.title ? { title: w.title } : {}),
1392
1578
  ...(knobs ? { knobs } : {}),
1393
1579
  ...(w.widths ? { widths: w.widths } : {}),
1580
+ ...(w.area === undefined ? {} : { area: w.area }),
1581
+ ...(w.source === undefined ? {} : { source: w.source }),
1394
1582
  ...(w.session ? { sessionId: w.session.id } : {}),
1395
1583
  });
1396
1584
  if (!updated)
@@ -1441,35 +1629,41 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1441
1629
  const pending = [];
1442
1630
  for (const m of mocks) {
1443
1631
  const posts = await store.listPosts({ mockId: m.id });
1444
- const embeds = await embedViews(m, posts);
1445
- const uses = new Set();
1632
+ const views = await embedViews(m, posts);
1633
+ const embeds = new Set();
1446
1634
  for (const p of posts) {
1447
1635
  if (!isLive(p.status))
1448
1636
  continue;
1449
1637
  for (const x of p.surfaces) {
1450
1638
  if (x.kind === "html")
1451
1639
  for (const t of parseEmbedTags(x.html))
1452
- uses.add(t.slug);
1640
+ embeds.add(t.slug);
1453
1641
  }
1454
1642
  }
1455
1643
  nodes.push(treeNodeView(m, posts, {
1456
1644
  settled: settledValues(await replyPayloads(m.id), m, posts),
1457
- uses: [...uses],
1458
- embeds,
1645
+ embeds: [...embeds],
1646
+ embedViews: views,
1459
1647
  }));
1460
1648
  pending.push(pendingOf(m, posts));
1461
1649
  }
1462
1650
  const bySlug = new Map(nodes.map((n) => [n.slug, n]));
1463
1651
  for (const n of nodes) {
1464
- n.uses = n.uses.filter((s) => bySlug.has(s));
1465
- for (const s of n.uses)
1466
- bySlug.get(s).usedIn.push(n.slug);
1652
+ n.embeds = n.embeds.filter((s) => bySlug.has(s));
1653
+ for (const s of n.embeds)
1654
+ bySlug.get(s).embeddedIn.push(n.slug);
1467
1655
  }
1468
- return ok({ project, tree: nodes, flows: await flowsView(project, mocks), pending });
1656
+ return ok({
1657
+ project,
1658
+ tree: nodes,
1659
+ flows: await flowsView(project, mocks),
1660
+ areaOrder: await areaOrderOf(project),
1661
+ pending,
1662
+ });
1469
1663
  }
1470
1664
  async function replyPayloads(mockId) {
1471
1665
  return (await store.listComments({ mockId }))
1472
- .filter((c) => c.kind === "reply" && c.payload)
1666
+ .filter((c) => c.kind === "reply" && c.payload && !c.payload.edited)
1473
1667
  .map((c) => c.payload);
1474
1668
  }
1475
1669
  async function getMockFlow(ref, query) {
@@ -1513,9 +1707,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1513
1707
  continue;
1514
1708
  const post = postAtVersion(found, inUseVersion(found)) ?? found;
1515
1709
  const html = post.surfaces.find((s) => s.kind === "html");
1516
- // With slots, the screen as decided: only the chosen fills, by every
1710
+ // With fills, the state as decided: only the chosen fills, by every
1517
1711
  // value sent so far (defaults before any reply). The authored markup with
1518
- // every fill stays available as `source`.
1712
+ // every fill stays available as `allFills`.
1519
1713
  const slots = slotsInSurfaces(post.surfaces);
1520
1714
  const raw = html && html.kind === "html" ? html.html : "";
1521
1715
  const composed = slots.length ? composeFills(raw, chosenFills(slots, settled)) : raw;
@@ -1526,9 +1720,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1526
1720
  version: post.version,
1527
1721
  // The agent's newer versions wait until the user puts one in use.
1528
1722
  ...(found.version !== post.version ? { latest: found.version } : {}),
1529
- status: post.status,
1723
+ status: variantStatus(post.status),
1530
1724
  html: composed,
1531
- ...(slots.length ? { source: raw } : {}),
1725
+ ...(slots.length ? { allFills: raw } : {}),
1532
1726
  markdown: postToMarkdown(post, {
1533
1727
  postUrl: `${ctx.base}/s/${post.id}`,
1534
1728
  assetBase: ctx.base,
@@ -1601,6 +1795,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1601
1795
  const options = [];
1602
1796
  // Seeded with the viewer's write-in, so a label "Other" slugs to "other-2".
1603
1797
  const optionIds = new Set([OTHER_ID]);
1798
+ // The agent's own pick, shown as a quiet mark on its option: one per ask.
1799
+ const picks = raw.options.filter((o) => o && typeof o === "object" && o.recommended === true).length;
1800
+ if (picks > 1)
1801
+ return `ask "${text}" recommends ${picks} options; recommend one`;
1604
1802
  for (const o of raw.options) {
1605
1803
  const opt = typeof o === "string" ? { label: o } : o;
1606
1804
  const label = str(opt?.label, 200);
@@ -1624,14 +1822,21 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1624
1822
  return checked.error;
1625
1823
  set = checked.value;
1626
1824
  }
1825
+ const pick = opt.recommended === true ? { recommended: true } : {};
1627
1826
  if (opt.version !== undefined) {
1628
1827
  const pinned = versionOption(opt.version, label, variant, { scope, state }, mock, posts);
1629
1828
  if (typeof pinned === "string")
1630
1829
  return pinned;
1631
- options.push({ id, label, ...pinned, ...(set ? { set } : {}) });
1830
+ options.push({ id, label, ...pinned, ...(set ? { set } : {}), ...pick });
1632
1831
  continue;
1633
1832
  }
1634
- options.push({ id, label, ...(variant ? { variant } : {}), ...(set ? { set } : {}) });
1833
+ options.push({
1834
+ id,
1835
+ label,
1836
+ ...(variant ? { variant } : {}),
1837
+ ...(set ? { set } : {}),
1838
+ ...pick,
1839
+ });
1635
1840
  }
1636
1841
  let id = slugify(str(raw.id, 64) ?? text).slice(0, 40);
1637
1842
  if (!str(raw.id, 64))
@@ -1719,7 +1924,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1719
1924
  return fail(400, ask);
1720
1925
  const at = asks.findIndex((a) => a.id === ask.id);
1721
1926
  // The user closed this one as not worth answering; asking it again is
1722
- // exactly the noise they resolved away. Only their Reopen revives it.
1927
+ // exactly the noise they closed away. Only their Reopen revives it.
1723
1928
  if (at >= 0 && asks[at].dismissed) {
1724
1929
  skipped.push(ask.id);
1725
1930
  continue;
@@ -1731,7 +1936,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1731
1936
  taken.add(ask.id);
1732
1937
  added.push(ask);
1733
1938
  }
1734
- const skippedNudges = skipped.map((id) => `ask "${id}": the user dismissed it when they resolved ${mock.slug}; not asked again. Don't re-ask it.`);
1939
+ const skippedNudges = skipped.map((id) => `ask "${id}": the user dismissed it when they closed ${mock.slug}; not asked again. Don't re-ask it.`);
1735
1940
  if (!added.length) {
1736
1941
  return ok({
1737
1942
  mock: mock.slug,
@@ -1749,12 +1954,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1749
1954
  if (typeof body.session === "string" && (await store.getSession(body.session))) {
1750
1955
  sessionId = body.session;
1751
1956
  }
1752
- // A new question on a resolved mock brings it back to the open list, or it
1957
+ // A new question on a closed mock brings it back to the open list, or it
1753
1958
  // would wait where the user no longer looks.
1754
1959
  const updated = await store.updateMock(mock.id, {
1755
1960
  asks,
1756
1961
  sessionId,
1757
- ...(mock.resolvedAt ? { resolvedAt: null } : {}),
1962
+ ...(mock.closedAt ? { closedAt: null } : {}),
1758
1963
  });
1759
1964
  if (!updated)
1760
1965
  return fail(404, "mock not found");
@@ -1810,15 +2015,33 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1810
2015
  if (reaction !== undefined && !REACTIONS.includes(reaction))
1811
2016
  return `reaction must be one of ${REACTIONS.join(", ")}`;
1812
2017
  const text = str(c?.text, MAX_COMMENT_TEXT);
1813
- if (!text && !reaction)
2018
+ const borrowIn = c?.borrow;
2019
+ if (!text && !reaction && !borrowIn)
1814
2020
  return "every comment needs text";
1815
2021
  const state = stateArg(c.state) ?? null;
1816
2022
  if (state !== null && !mock.states.includes(state))
1817
2023
  return `${mock.slug} has no state "${state}"`;
1818
2024
  const part = str(c.part, 200) ?? null;
1819
2025
  const variant = str(c.variant, 200);
1820
- if (variant && !posts.some((p) => p.variant === variant && p.state === state))
2026
+ const post = variant ? posts.find((p) => p.variant === variant && p.state === state) : null;
2027
+ if (variant && !post)
1821
2028
  return `${mock.slug} has no variant "${variant}"${state ? ` in "${state}"` : ""}`;
2029
+ const version = c.version;
2030
+ if (version !== undefined &&
2031
+ (!post || !Number.isInteger(version) || version < 1 || version > post.version))
2032
+ return `${mock.slug} has no version ${String(version)}${variant ? ` of "${variant}"` : ""}`;
2033
+ let borrow;
2034
+ if (borrowIn !== undefined) {
2035
+ const from = str(borrowIn?.from, 240);
2036
+ const at = from ? /^(.+)@(\d+)$/.exec(from) : null;
2037
+ const source = at && posts.find((p) => p.variant === at[1] && p.state === state);
2038
+ if (!at || !source || Number(at[2]) < 1 || Number(at[2]) > source.version)
2039
+ return `borrow.from must be variant@version of "${mock.slug}"${state ? ` in "${state}"` : ""}`;
2040
+ if (!post || version === undefined)
2041
+ return "a borrow names the version it goes into";
2042
+ const borrowed = str(borrowIn?.part, 200);
2043
+ borrow = { from: at[0], ...(borrowed ? { part: borrowed } : {}) };
2044
+ }
1822
2045
  const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
1823
2046
  const anchor = a ? sanitizePartAnchor(a) : undefined;
1824
2047
  const next = {
@@ -1828,13 +2051,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1828
2051
  ...(anchor ? { anchor } : {}),
1829
2052
  ...(reaction ? { reaction: reaction } : {}),
1830
2053
  ...(variant ? { variant } : {}),
2054
+ ...(version !== undefined ? { version: version } : {}),
2055
+ ...(borrow ? { borrow } : {}),
1831
2056
  };
1832
2057
  // One reaction per target: a later tap on the same part replaces the earlier one.
1833
2058
  if (reaction) {
1834
2059
  const same = out.findIndex((o) => o.reaction &&
1835
2060
  o.part === part &&
1836
2061
  o.state === state &&
1837
- (o.variant ?? null) === (variant ?? null));
2062
+ (o.variant ?? null) === (variant ?? null) &&
2063
+ (o.version ?? null) === (version ?? null));
1838
2064
  if (same >= 0)
1839
2065
  out.splice(same, 1);
1840
2066
  }
@@ -1941,18 +2167,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1941
2167
  }
1942
2168
  return out;
1943
2169
  }
1944
- function sanitizeMix(raw, posts) {
2170
+ function sanitizeBorrowed(raw, posts) {
1945
2171
  if (raw === undefined || raw === null)
1946
2172
  return {};
1947
2173
  if (typeof raw !== "object" || Array.isArray(raw))
1948
- return '"mix" must be an object';
2174
+ return '"borrowed" must be an object';
1949
2175
  const variants = new Set(posts.map((p) => p.variant));
1950
2176
  const out = {};
1951
2177
  for (const [part, variant] of Object.entries(raw)) {
1952
2178
  if (!part || part.length > 200)
1953
- return "mix keys are part names";
2179
+ return "borrowed keys are part names";
1954
2180
  if (typeof variant !== "string" || !variants.has(variant)) {
1955
- return `mix "${part}" names a variant that does not exist`;
2181
+ return `borrowed "${part}" names a variant that does not exist`;
1956
2182
  }
1957
2183
  out[part] = variant;
1958
2184
  }
@@ -1965,9 +2191,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1965
2191
  const answers = sanitizeAnswers(pick("answers"), mock, posts);
1966
2192
  if (typeof answers === "string")
1967
2193
  return answers;
1968
- const mix = sanitizeMix(pick("mix"), posts);
1969
- if (typeof mix === "string")
1970
- return mix;
2194
+ const borrowed = sanitizeBorrowed(pick("borrowed"), posts);
2195
+ if (typeof borrowed === "string")
2196
+ return borrowed;
1971
2197
  const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
1972
2198
  if (!tuned.ok)
1973
2199
  return tuned.error;
@@ -2002,7 +2228,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2002
2228
  return {
2003
2229
  version,
2004
2230
  answers,
2005
- mix,
2231
+ borrowed,
2006
2232
  tuned: tuned.value,
2007
2233
  comments,
2008
2234
  others,
@@ -2047,28 +2273,26 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2047
2273
  bus.broadcast({ type: "draft-updated", mockId: mock.id });
2048
2274
  return ok({ draft: null });
2049
2275
  }
2050
- function sanitizeDecision(raw, mock, posts) {
2276
+ function sanitizeVerdict(raw, mock, posts) {
2051
2277
  if (raw === undefined || raw === null)
2052
2278
  return undefined;
2053
2279
  const kind = raw.kind;
2054
- if (kind !== "accept" && kind !== "revise" && kind !== "drop") {
2055
- return 'decision.kind must be "accept", "revise" or "drop"';
2056
- }
2280
+ if (kind !== "use" && kind !== "revise")
2281
+ return 'verdict.kind must be "use" or "revise"';
2057
2282
  const chosen = chooseVariant(mock, posts, stateArg(raw.state), str(raw.variant, MAX_LABEL));
2058
2283
  if (isResult(chosen))
2059
2284
  return chosen.body.error;
2060
2285
  return { kind, state: chosen.state, variant: chosen.variant };
2061
2286
  }
2062
- // Which variants a reply accepts and archives. A variant-bound answer accepts
2063
- // the chosen variant(s) in every state the ask covers and archives their
2064
- // siblings there; a part-scoped pick is a mix, not a verdict. The built-in
2065
- // variant asks flip the same way, per state. Each accepted variant is put in
2066
- // use at the version the reply was made on (`version`, a mock version), not
2287
+ // Which variants a reply puts in use and archives. A variant-bound answer puts
2288
+ // the chosen variant(s) in use in every state the ask covers and archives
2289
+ // their siblings there; a part-scoped pick is a borrow, not a verdict. The
2290
+ // built-in variant questions flip the same way, per state. Each variant is put
2291
+ // in use at the version the reply was made on (`version`, a mock version), not
2067
2292
  // whatever the agent published since — or, for a version option, at its version.
2068
- function replyFlips(mock, posts, answers, decision, version) {
2293
+ function replyFlips(mock, posts, answers, verdict, version) {
2069
2294
  const accept = new Map();
2070
2295
  const archive = new Set();
2071
- const drop = new Set();
2072
2296
  const settle = (inState, chosen, pins) => {
2073
2297
  if (!inState.some((p) => chosen.has(p.variant)))
2074
2298
  return;
@@ -2080,7 +2304,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2080
2304
  }
2081
2305
  const pin = pins?.get(p.variant);
2082
2306
  // A version option outranks a looser pick of the same variant (the
2083
- // built-in Which one?, a Look) in the same reply.
2307
+ // built-in Which variant?, or the agent's own variant question) in the
2308
+ // same reply.
2084
2309
  if (pin === undefined && accept.has(p.id))
2085
2310
  continue;
2086
2311
  // A pinned version history retention has since dropped falls back to
@@ -2109,21 +2334,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2109
2334
  for (const state of states)
2110
2335
  settle(posts.filter((p) => p.state === state), chosen);
2111
2336
  }
2112
- if (decision?.kind === "accept") {
2113
- settle(posts.filter((p) => p.state === decision.state), new Set([decision.variant]));
2114
- }
2115
- else if (decision?.kind === "drop") {
2116
- const post = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
2117
- if (post)
2118
- drop.add(post.id);
2119
- }
2120
- for (const id of accept.keys()) {
2121
- archive.delete(id);
2122
- drop.delete(id);
2337
+ if (verdict?.kind === "use") {
2338
+ settle(posts.filter((p) => p.state === verdict.state), new Set([verdict.variant]));
2123
2339
  }
2124
- for (const id of drop)
2340
+ for (const id of accept.keys())
2125
2341
  archive.delete(id);
2126
- return { accept, archive, drop };
2342
+ return { accept, archive };
2127
2343
  }
2128
2344
  // An ask as a reply leaves it. A pick or a write-in replaces whatever was
2129
2345
  // answered before; a note alone keeps that answer and adds to it.
@@ -2170,13 +2386,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2170
2386
  if (typeof draft === "string")
2171
2387
  return fail(400, draft);
2172
2388
  const text = str(body?.text, MAX_COMMENT_TEXT);
2173
- const decision = sanitizeDecision(body?.decision, mock, posts);
2174
- if (typeof decision === "string")
2175
- return fail(400, decision);
2389
+ const verdict = sanitizeVerdict(body?.verdict, mock, posts);
2390
+ if (typeof verdict === "string")
2391
+ return fail(400, verdict);
2176
2392
  const empty = !text &&
2177
- !decision &&
2393
+ !verdict &&
2178
2394
  !Object.keys(draft.answers).length &&
2179
- !Object.keys(draft.mix).length &&
2395
+ !Object.keys(draft.borrowed).length &&
2180
2396
  !Object.keys(draft.tuned).length &&
2181
2397
  !draft.comments.length &&
2182
2398
  !Object.keys(draft.others ?? {}).length &&
@@ -2191,7 +2407,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2191
2407
  mockId: mock.id,
2192
2408
  version: draft.version,
2193
2409
  answers: draft.answers,
2194
- mix: draft.mix,
2410
+ borrowed: draft.borrowed,
2195
2411
  tuned: draft.tuned,
2196
2412
  comments: draft.comments,
2197
2413
  ...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
@@ -2201,11 +2417,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2201
2417
  ...(text ? { text } : {}),
2202
2418
  };
2203
2419
  const asks = mock.asks.map((a) => sentAsk(a, draft));
2204
- const flips = replyFlips(mock, posts, draft.answers, decision, draft.version);
2205
- if (decision) {
2206
- const target = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
2207
- const pinned = decision.kind === "accept" && target ? flips.accept.get(target.id) : undefined;
2208
- payload.decision = pinned === undefined ? decision : { ...decision, version: pinned };
2420
+ const flips = replyFlips(mock, posts, draft.answers, verdict, draft.version);
2421
+ if (verdict) {
2422
+ const target = posts.find((p) => p.state === verdict.state && p.variant === verdict.variant);
2423
+ const pinned = verdict.kind === "use" && target ? flips.accept.get(target.id) : undefined;
2424
+ payload.verdict = pinned === undefined ? verdict : { ...verdict, version: pinned };
2209
2425
  }
2210
2426
  const comment = await store.commitReply({
2211
2427
  mockId: mock.id,
@@ -2215,7 +2431,6 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2215
2431
  asks,
2216
2432
  accept: [...flips.accept].map(([id, version]) => ({ id, version })),
2217
2433
  archive: [...flips.archive],
2218
- drop: [...flips.drop],
2219
2434
  });
2220
2435
  if (!comment)
2221
2436
  return fail(409, `${mock.slug} has no agent session to reply to`);
@@ -2225,7 +2440,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2225
2440
  void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
2226
2441
  const after = await store.listPosts({ mockId: mock.id });
2227
2442
  for (const p of after) {
2228
- if (flips.accept.has(p.id) || flips.archive.has(p.id) || flips.drop.has(p.id)) {
2443
+ if (flips.accept.has(p.id) || flips.archive.has(p.id)) {
2229
2444
  bus.broadcast({
2230
2445
  type: "post-updated",
2231
2446
  id: p.id,
@@ -2240,29 +2455,26 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2240
2455
  event: "decision",
2241
2456
  project: mock.project,
2242
2457
  slug: mock.slug,
2243
- variant: decision?.variant ?? "",
2458
+ variant: verdict?.variant ?? "",
2244
2459
  version: draft.version,
2245
- text: text || decision?.kind || "reply",
2460
+ text: text || verdict?.kind || "reply",
2246
2461
  url: mockUrl(ctx.base, mock),
2247
2462
  });
2248
2463
  const [delivered] = await withDelivered([comment]);
2249
2464
  return ok({
2250
2465
  reply: delivered,
2251
- accepted: after
2466
+ inUse: after
2252
2467
  .filter((p) => flips.accept.has(p.id))
2253
2468
  .map((p) => ({ state: p.state, variant: p.variant, version: inUseVersion(p) })),
2254
2469
  archived: after
2255
2470
  .filter((p) => flips.archive.has(p.id))
2256
2471
  .map((p) => ({ state: p.state, variant: p.variant })),
2257
- dropped: after
2258
- .filter((p) => flips.drop.has(p.id))
2259
- .map((p) => ({ state: p.state, variant: p.variant })),
2260
2472
  }, 201);
2261
2473
  }
2262
- // "Use this": the user puts one exact version of a variant in use. It is an
2263
- // accept, so it archives the variant's live siblings in that state and
2264
- // reaches the agent once, as a reply carrying the decision, through the same
2265
- // cursor as a Send; the user's unsent draft is left for their next Send.
2474
+ // "Use this": the user puts one exact version of a variant in use. It
2475
+ // archives the variant's live siblings in that state and reaches the agent
2476
+ // once, as a reply carrying the verdict, through the same cursor as a Send;
2477
+ // the user's unsent draft is left for their next Send.
2266
2478
  async function useFlow(ref, body, ctx) {
2267
2479
  if (!ctx.viewer)
2268
2480
  return viewerOnly();
@@ -2299,10 +2511,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2299
2511
  mockId: mock.id,
2300
2512
  version,
2301
2513
  answers: {},
2302
- mix: {},
2514
+ borrowed: {},
2303
2515
  tuned: {},
2304
2516
  comments: [],
2305
- decision: { kind: "accept", ...inUse },
2517
+ verdict: { kind: "use", ...inUse },
2306
2518
  },
2307
2519
  asks: mock.asks,
2308
2520
  accept: [{ id: chosen.id, version }],
@@ -2330,7 +2542,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2330
2542
  slug: mock.slug,
2331
2543
  variant: chosen.variant,
2332
2544
  version,
2333
- text: "accept",
2545
+ text: "use",
2334
2546
  url: mockUrl(ctx.base, mock),
2335
2547
  });
2336
2548
  const [delivered] = await withDelivered([comment]);
@@ -2394,15 +2606,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2394
2606
  }
2395
2607
  // The user closes a mock from the project list: its open asks are dismissed
2396
2608
  // unanswered, an unsent draft goes with them, and the agent hears it once,
2397
- // as a resolution through the same cursor as a Send.
2398
- async function resolveFlow(ref, ctx) {
2609
+ // as a close through the same cursor as a Send.
2610
+ async function closeFlow(ref, ctx) {
2399
2611
  if (!ctx.viewer)
2400
2612
  return viewerOnly();
2401
2613
  const mock = await resolveMock(ref);
2402
2614
  if (isResult(mock))
2403
2615
  return mock;
2404
2616
  const posts = await store.listPosts({ mockId: mock.id });
2405
- if (mock.resolvedAt)
2617
+ if (mock.closedAt)
2406
2618
  return ok({ mock: mockSummaryView(mock, posts), dismissed: [] });
2407
2619
  const dismissed = [...openAsks(mock).map((a) => a.id), ...builtinAskIds(mock, posts, mock.id)];
2408
2620
  const asks = mock.asks.map((a) => (askAnswered(a) ? a : { ...a, dismissed: true }));
@@ -2419,22 +2631,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2419
2631
  mockId: mock.id,
2420
2632
  version: Math.max(1, ...posts.map((p) => p.version)),
2421
2633
  answers: {},
2422
- mix: {},
2634
+ borrowed: {},
2423
2635
  tuned: {},
2424
2636
  comments: [],
2425
- resolved: { dismissed },
2637
+ closed: { dismissed },
2426
2638
  },
2427
2639
  asks,
2428
2640
  accept: [],
2429
2641
  archive: [],
2430
- resolvedAt: at,
2642
+ closedAt: at,
2431
2643
  })
2432
2644
  : null;
2433
2645
  if (comment)
2434
2646
  announceComment(comment);
2435
2647
  else {
2436
2648
  // No agent to tell: the mock still leaves the open list.
2437
- await store.updateMock(mock.id, { asks, resolvedAt: at });
2649
+ await store.updateMock(mock.id, { asks, closedAt: at });
2438
2650
  await store.putDraft(mock.id, null);
2439
2651
  }
2440
2652
  bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
@@ -2442,7 +2654,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2442
2654
  const after = (await store.getMock(mock.id)) ?? mock;
2443
2655
  return ok({ mock: mockSummaryView(after, posts), dismissed });
2444
2656
  }
2445
- // Back from Resolved: the asks it dismissed are open again. The agent was
2657
+ // Back from Closed: the asks it dismissed are open again. The agent was
2446
2658
  // told they were dismissed; answers given now reach it as a normal Send.
2447
2659
  async function reopenFlow(ref, ctx) {
2448
2660
  if (!ctx.viewer)
@@ -2451,10 +2663,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2451
2663
  if (isResult(mock))
2452
2664
  return mock;
2453
2665
  const posts = await store.listPosts({ mockId: mock.id });
2454
- if (!mock.resolvedAt)
2666
+ if (!mock.closedAt)
2455
2667
  return ok({ mock: mockSummaryView(mock, posts) });
2456
2668
  const asks = mock.asks.map(({ dismissed: _, ...a }) => a);
2457
- const updated = await store.updateMock(mock.id, { asks, resolvedAt: null });
2669
+ const updated = await store.updateMock(mock.id, { asks, closedAt: null });
2458
2670
  if (!updated)
2459
2671
  return fail(404, "mock not found");
2460
2672
  bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
@@ -2479,7 +2691,44 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2479
2691
  version: updated.version,
2480
2692
  by: "user",
2481
2693
  });
2482
- return ok({ state: updated.state, variant: updated.variant, status: updated.status });
2694
+ return ok({
2695
+ state: updated.state,
2696
+ variant: updated.variant,
2697
+ status: variantStatus(updated.status),
2698
+ });
2699
+ }
2700
+ // The user archives a variant they're done with, short of choosing another:
2701
+ // Studio's × on its last version. Restore is the way back. The variant in
2702
+ // use stays, because archiving it would leave the state with nothing chosen
2703
+ // and no reply saying why.
2704
+ async function archiveFlow(ref, body, ctx) {
2705
+ if (!ctx.viewer)
2706
+ return viewerOnly();
2707
+ const mock = await resolveMock(ref);
2708
+ if (isResult(mock))
2709
+ return mock;
2710
+ const posts = await store.listPosts({ mockId: mock.id });
2711
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
2712
+ if (isResult(chosen))
2713
+ return chosen;
2714
+ if (variantStatus(chosen.status) === "inUse")
2715
+ return fail(409, `${chosen.variant} is in use`);
2716
+ const updated = await store.setPostStatus(chosen.id, "archived");
2717
+ if (!updated)
2718
+ return fail(404, "variant not found");
2719
+ bus.broadcast({
2720
+ type: "post-updated",
2721
+ id: updated.id,
2722
+ mockId: mock.id,
2723
+ sessionId: updated.sessionId,
2724
+ version: updated.version,
2725
+ by: "user",
2726
+ });
2727
+ return ok({
2728
+ state: updated.state,
2729
+ variant: updated.variant,
2730
+ status: variantStatus(updated.status),
2731
+ });
2483
2732
  }
2484
2733
  // The user stars (or unstars) a variant: a favourite to come back to, which
2485
2734
  // decides nothing. Viewer-only; the agent reads it as `starred`.
@@ -2557,6 +2806,216 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2557
2806
  bus.broadcast({ type: "mock-deleted", id: mock.id, project: mock.project });
2558
2807
  return ok({ ok: true });
2559
2808
  }
2809
+ // A write's feedback for the session that made it, when it named one: an
2810
+ // edit needs no session, so without one there is no cursor to read from.
2811
+ const feedbackFor = async (session, base) => typeof session === "string" && session ? collectFeedback(session, base) : [];
2812
+ // `edit`: a mock's own fields, without a new version. A new slug rewrites
2813
+ // every reference to the old one in the project (store.renameMock), so pages
2814
+ // keep showing it and flows keep walking it.
2815
+ async function editMockFlow(ref, body, ctx) {
2816
+ if (!body || typeof body !== "object")
2817
+ return fail(400, "invalid JSON body");
2818
+ const given = ["slug", "title", "kind", "area", "source"].filter((k) => body[k] !== undefined);
2819
+ if (given.length === 0) {
2820
+ return fail(400, 'provide one of "slug", "title", "kind", "area" or "source"');
2821
+ }
2822
+ const mock = await resolveMock(ref, body.project, body.session);
2823
+ if (isResult(mock))
2824
+ return mock;
2825
+ const slugIn = body.slug === undefined ? undefined : str(body.slug, MAX_TITLE);
2826
+ if (body.slug !== undefined && !slugIn)
2827
+ return fail(400, '"slug" must name the mock, e.g. "onni"');
2828
+ const slug = slugIn === undefined ? undefined : slugify(slugIn);
2829
+ const title = str(body.title, MAX_TITLE);
2830
+ if (body.title !== undefined && !title)
2831
+ return fail(400, '"title" must be non-empty text');
2832
+ if (body.kind !== undefined && !isMockKind(body.kind)) {
2833
+ return fail(400, '"kind" must be page, component or sketch');
2834
+ }
2835
+ const kind = body.kind;
2836
+ const area = labelArg(body, "area", MAX_LABEL);
2837
+ if (isResult(area))
2838
+ return area;
2839
+ const source = labelArg(body, "source", MAX_SOURCE);
2840
+ if (isResult(source))
2841
+ return source;
2842
+ const renaming = slug !== undefined && slug !== mock.slug;
2843
+ if (renaming && (await store.findMock(mock.project, slug))) {
2844
+ return fail(409, `${mock.project} already has a mock "${slug}"; pick another slug`);
2845
+ }
2846
+ // Being embedded is what makes a mock a component: a sketch that is still
2847
+ // embedded would be promoted back on its embedder's next publish.
2848
+ if (kind === "sketch" && mock.kind !== "sketch") {
2849
+ const embedders = await embeddersOf(mock.project, mock.slug);
2850
+ if (embedders.length) {
2851
+ return fail(409, `${mock.slug} is embedded in ${embedders.map((m) => m.slug).join(", ")}; being embedded makes it a component. Take the embeds out first`, { embeddedIn: embedders.map((m) => m.slug) });
2852
+ }
2853
+ }
2854
+ let next = mock;
2855
+ // The rename goes first: it is the part that can still lose a race for the slug.
2856
+ if (renaming) {
2857
+ const renamed = await store.renameMock(mock.id, slug);
2858
+ if (!renamed)
2859
+ return fail(409, `${mock.project} already has a mock "${slug}"`);
2860
+ next = renamed;
2861
+ clearRenderCache();
2862
+ }
2863
+ if (title !== undefined || kind !== undefined || area !== undefined || source !== undefined) {
2864
+ next =
2865
+ (await store.updateMock(mock.id, {
2866
+ ...(title === undefined ? {} : { title }),
2867
+ ...(kind === undefined ? {} : { kind }),
2868
+ ...(area === undefined ? {} : { area }),
2869
+ ...(source === undefined ? {} : { source }),
2870
+ })) ?? next;
2871
+ }
2872
+ bus.broadcast({ type: "mock-updated", id: next.id, project: next.project });
2873
+ if (renaming)
2874
+ await announceEmbedders(next);
2875
+ if (ctx.viewer)
2876
+ await tellAgentEdited(mock, next);
2877
+ // Flows are declared apart from kind, so a page that stops being one stays
2878
+ // in them until the agent takes it out.
2879
+ const nudges = [...titleNudges(title)];
2880
+ if (kind !== undefined && kind !== "page" && mock.kind === "page") {
2881
+ for (const f of await store.listFlows(next.project)) {
2882
+ if (!f.steps.some((x) => x.mock === next.slug))
2883
+ continue;
2884
+ nudges.push(`${next.slug} is still a step of flow "${f.name}"; publish it with flow "${f.name}" and step 0 to take it out`);
2885
+ }
2886
+ }
2887
+ return ok({
2888
+ mock: {
2889
+ id: next.id,
2890
+ project: next.project,
2891
+ slug: next.slug,
2892
+ title: next.title,
2893
+ kind: next.kind,
2894
+ ...placeOf(next),
2895
+ states: next.states,
2896
+ },
2897
+ ...(renaming ? { renamed: { from: mock.slug, to: next.slug } } : {}),
2898
+ url: mockUrl(ctx.base, next),
2899
+ ...(nudges.length ? { nudges } : {}),
2900
+ feedback: await feedbackFor(body.session, ctx.base),
2901
+ });
2902
+ }
2903
+ // The user moved a mock from Home (another kind or area): the agent hears it
2904
+ // once, through the same cursor as a Send, on the session driving the mock.
2905
+ // Nothing to tell when no agent has written it.
2906
+ async function tellAgentEdited(before, after) {
2907
+ const edited = {
2908
+ ...(after.kind !== before.kind ? { kind: after.kind } : {}),
2909
+ ...(after.area !== before.area ? { area: after.area } : {}),
2910
+ };
2911
+ if (Object.keys(edited).length === 0)
2912
+ return;
2913
+ const posts = await store.listPosts({ mockId: after.id });
2914
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2915
+ const sessionId = after.sessionId ?? latest?.sessionId;
2916
+ if (!sessionId)
2917
+ return;
2918
+ const comment = await store.commitReply({
2919
+ mockId: after.id,
2920
+ sessionId,
2921
+ text: "",
2922
+ payload: {
2923
+ mockId: after.id,
2924
+ version: Math.max(1, ...posts.map((p) => p.version)),
2925
+ answers: {},
2926
+ borrowed: {},
2927
+ tuned: {},
2928
+ comments: [],
2929
+ edited,
2930
+ },
2931
+ asks: after.asks,
2932
+ accept: [],
2933
+ archive: [],
2934
+ keepDraft: true,
2935
+ });
2936
+ if (comment)
2937
+ announceComment(comment);
2938
+ }
2939
+ const areaOrderOf = async (project) => {
2940
+ const raw = await store.getSetting(`${AREA_ORDER_SETTING}:${project}`);
2941
+ if (!raw)
2942
+ return [];
2943
+ try {
2944
+ const v = JSON.parse(raw);
2945
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
2946
+ }
2947
+ catch {
2948
+ return [];
2949
+ }
2950
+ };
2951
+ // `areaOrder`: the areas in the order Home lists them; [] (or "") hands the
2952
+ // order back to the alphabet.
2953
+ function areaOrderArg(v) {
2954
+ const list = typeof v === "string" ? v.split(",") : v;
2955
+ if (!Array.isArray(list) || list.some((x) => typeof x !== "string")) {
2956
+ return fail(400, '"areaOrder" must be a list of area names, e.g. ["Shell", "Food"]');
2957
+ }
2958
+ const names = [...new Set(list.map((x) => stripNul(x).trim()).filter(Boolean))];
2959
+ if (names.length > MAX_AREAS)
2960
+ return fail(400, `"areaOrder" takes at most ${MAX_AREAS} areas`);
2961
+ if (names.some((x) => x.length > MAX_LABEL)) {
2962
+ return fail(400, `an area name is at most ${MAX_LABEL} characters`);
2963
+ }
2964
+ return names;
2965
+ }
2966
+ // `edit` on a project: a new name for it, carrying its mocks, sessions,
2967
+ // flows and settings (refused onto a project that already has mocks), and
2968
+ // the order Home lists its areas in.
2969
+ async function editProjectFlow(name, body, ctx) {
2970
+ if (!body || typeof body !== "object")
2971
+ return fail(400, "invalid JSON body");
2972
+ const from = str(name, MAX_TITLE);
2973
+ if (!from)
2974
+ return fail(400, "provide the project to edit");
2975
+ if (body.name === undefined && body.areaOrder === undefined) {
2976
+ return fail(400, 'provide "name" (the project\'s new name) or "areaOrder"');
2977
+ }
2978
+ const to = body.name === undefined ? undefined : str(body.name, MAX_TITLE);
2979
+ if (body.name !== undefined && !to)
2980
+ return fail(400, '"name" must be the project\'s new name');
2981
+ if (to === from)
2982
+ return fail(400, `${from} already has that name`);
2983
+ const order = body.areaOrder === undefined ? undefined : areaOrderArg(body.areaOrder);
2984
+ if (isResult(order))
2985
+ return order;
2986
+ const mocks = await store.listMocks(from);
2987
+ if (mocks.length === 0)
2988
+ return fail(404, `no project "${from}" with mocks`);
2989
+ if (to !== undefined) {
2990
+ if (!(await store.renameProject(from, to))) {
2991
+ return fail(409, `${to} already has mocks; pick another name`);
2992
+ }
2993
+ clearRenderCache();
2994
+ }
2995
+ const project = to ?? from;
2996
+ if (order !== undefined) {
2997
+ await store.setSetting(`${AREA_ORDER_SETTING}:${project}`, JSON.stringify(order));
2998
+ }
2999
+ for (const m of mocks)
3000
+ bus.broadcast({ type: "mock-updated", id: m.id, project });
3001
+ const named = new Set(mocks.flatMap((m) => (m.area ? [m.area] : [])));
3002
+ const unnamed = (order ?? []).filter((a) => !named.has(a));
3003
+ return ok({
3004
+ project,
3005
+ ...(to === undefined ? {} : { renamed: { from, to } }),
3006
+ ...(order === undefined ? {} : { areaOrder: order }),
3007
+ mocks: mocks.length,
3008
+ url: `${ctx.base}/project/${encodeURIComponent(project)}`,
3009
+ ...(unnamed.length
3010
+ ? {
3011
+ nudges: [
3012
+ `no mock in ${project} has area ${unnamed.map((a) => `"${a}"`).join(", ")} yet; Home lists it once one does`,
3013
+ ],
3014
+ }
3015
+ : {}),
3016
+ feedback: await feedbackFor(body.session, ctx.base),
3017
+ });
3018
+ }
2560
3019
  // --- comments ---
2561
3020
  function numberInRange(value, min, max) {
2562
3021
  const n = Number(value);
@@ -2883,7 +3342,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2883
3342
  const pendingOf = (mock, posts) => ({
2884
3343
  mock: mock.slug,
2885
3344
  // Closed by the user: nothing there is waiting on them, or on you.
2886
- ...(mock.resolvedAt ? { resolved: true } : {}),
3345
+ ...(mock.closedAt ? { closed: true } : {}),
2887
3346
  viewerOpen: viewing.has(mock.id),
2888
3347
  draft: mock.draft
2889
3348
  ? {
@@ -3195,6 +3654,17 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3195
3654
  const flag = (c, key) => c.req.query(key) === "1";
3196
3655
  // --- sessions ---
3197
3656
  app.get("/api/sessions", async (c) => {
3657
+ // A watcher started with every conversation finds that conversation's
3658
+ // session without creating one, and never mistakes a sibling's in the same
3659
+ // repo for its own.
3660
+ const key = c.req.query("key");
3661
+ if (key !== undefined) {
3662
+ const session = key ? await store.getSessionByKey(key) : null;
3663
+ if (!session)
3664
+ return c.json([]);
3665
+ const posts = await store.listPosts({ sessionId: session.id });
3666
+ return c.json([sessionRowView(session, posts.length)]);
3667
+ }
3198
3668
  const [sessions, counts] = await Promise.all([
3199
3669
  store.listSessions(),
3200
3670
  store.countPostsBySession(),
@@ -3280,6 +3750,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3280
3750
  variant: c.req.query("variant"),
3281
3751
  }, flowCtx(c)));
3282
3752
  });
3753
+ app.patch("/api/mocks/:id", async (c) => {
3754
+ const body = await jsonBody(c);
3755
+ // A slug names its project in the body or, as on the reads, in the query.
3756
+ const project = c.req.query("project");
3757
+ const withProject = body && typeof body === "object" && body.project === undefined && project
3758
+ ? { ...body, project }
3759
+ : body;
3760
+ return send(c, await editMockFlow(c.req.param("id"), withProject, flowCtx(c)));
3761
+ });
3283
3762
  app.delete("/api/mocks/:id", async (c) => send(c, await removeMockFlow(c.req.param("id"), { project: c.req.query("project") })));
3284
3763
  app.post("/api/mocks/:id/asks", async (c) => send(c, await askFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3285
3764
  app.get("/api/mocks/:id/draft", async (c) => send(c, await getDraftFlow(c.req.param("id"), flowCtx(c))));
@@ -3288,9 +3767,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3288
3767
  app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3289
3768
  app.post("/api/mocks/:id/use", async (c) => send(c, await useFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3290
3769
  app.post("/api/mocks/:id/hide", async (c) => send(c, await hideFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3291
- app.post("/api/mocks/:id/resolve", async (c) => send(c, await resolveFlow(c.req.param("id"), flowCtx(c))));
3770
+ app.post("/api/mocks/:id/close", async (c) => send(c, await closeFlow(c.req.param("id"), flowCtx(c))));
3292
3771
  app.post("/api/mocks/:id/reopen", async (c) => send(c, await reopenFlow(c.req.param("id"), flowCtx(c))));
3293
3772
  app.post("/api/mocks/:id/star", async (c) => send(c, await starFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3773
+ app.post("/api/mocks/:id/archive", async (c) => send(c, await archiveFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3294
3774
  app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
3295
3775
  app.post("/api/mocks/:id/variants/:postId/restore", async (c) => send(c, await restoreVersionFlow(c.req.param("id"), c.req.param("postId"), await jsonBody(c), flowCtx(c))));
3296
3776
  app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
@@ -3421,7 +3901,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3421
3901
  // The project's design system, imported by `mockpit init` and injected into
3422
3902
  // every html surface of the project (see renderHtmlPage).
3423
3903
  app.get("/api/projects/:name/tree", async (c) => send(c, await treeFlow({ project: c.req.param("name") })));
3904
+ app.patch("/api/projects/:name", async (c) => send(c, await editProjectFlow(c.req.param("name"), await jsonBody(c), flowCtx(c))));
3424
3905
  app.get("/api/projects/:name/design", async (c) => c.json(await designFor(c.req.param("name"))));
3906
+ // A project's rules file (.mockpit/rules.md, sent by `mockpit rules` and
3907
+ // `mockpit init`) and the user's own (`mockpit rules --mine`).
3908
+ app.get("/api/projects/:name/rules", async (c) => c.json({ text: (await store.getSetting(`${RULES_SETTING}:${c.req.param("name")}`)) ?? "" }));
3909
+ app.put("/api/projects/:name/rules", async (c) => send(c, await saveRules(`${RULES_SETTING}:${c.req.param("name")}`, await jsonBody(c))));
3910
+ app.get("/api/rules", async (c) => c.json({ text: (await userRulesText()) ?? "" }));
3911
+ app.put("/api/rules", async (c) => send(c, await saveRules(USER_RULES_SETTING, await jsonBody(c))));
3425
3912
  app.put("/api/projects/:name/design", async (c) => {
3426
3913
  const body = await c.req.json().catch(() => null);
3427
3914
  if (!body || typeof body !== "object")
@@ -3737,9 +4224,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3737
4224
  // The runtime's module cache makes every later call cheap, so there's no memo
3738
4225
  // here to keep in sync. On the Worker the module is already inside the
3739
4226
  // deployed bundle — the import defers evaluating it, not fetching it — so this
3740
- // needs no network at runtime. test/workerIntegration covers that on real
3741
- // workerd, because a dynamic import resolving differently there is exactly the
3742
- // way this optimization could break in production and nowhere else.
4227
+ // needs no network at runtime. Check it on real workerd (`npm run dev:cloud`):
4228
+ // a dynamic import resolving differently there is exactly the way this
4229
+ // optimization could break in production and nowhere else.
3743
4230
  const { renderCode, renderDiff, renderMarkdown, renderTerminal } = await import("./richRender.js");
3744
4231
  const rendered = surface.kind === "markdown"
3745
4232
  ? await renderMarkdown(surface, { theme: themeId, mode })
@@ -4160,6 +4647,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
4160
4647
  exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
4161
4648
  say: (ref, body, ctx) => sayFlow(ref, body, ctx),
4162
4649
  feedback: (query, ctx) => feedbackFlow(query, ctx),
4650
+ edit: (ref, body, ctx) => editMockFlow(ref, body, ctx),
4651
+ editProject: (name, body, ctx) => editProjectFlow(name, body, ctx),
4163
4652
  };
4164
4653
  const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
4165
4654
  // --- run: an agent script over the same flows (server/run.ts) ---