mockpit 1.2.0 → 1.3.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 (51) hide show
  1. package/README.md +13 -8
  2. package/bin/mockpit.js +116 -16
  3. package/dist/mcp/server.js +10 -7
  4. package/dist/server/apiViews.d.ts +118 -17
  5. package/dist/server/apiViews.js +66 -8
  6. package/dist/server/app.d.ts +2 -1
  7. package/dist/server/app.js +917 -144
  8. package/dist/server/decisions.d.ts +23 -0
  9. package/dist/server/decisions.js +99 -0
  10. package/dist/server/designGuide.js +45 -10
  11. package/dist/server/embeds.d.ts +25 -0
  12. package/dist/server/embeds.js +118 -0
  13. package/dist/server/eventLog.js +2 -1
  14. package/dist/server/feedbackBatch.d.ts +12 -1
  15. package/dist/server/feedbackBatch.js +20 -3
  16. package/dist/server/fills.d.ts +41 -0
  17. package/dist/server/fills.js +328 -0
  18. package/dist/server/index.js +15 -1
  19. package/dist/server/knobs.d.ts +6 -2
  20. package/dist/server/knobs.js +11 -1
  21. package/dist/server/mcpHttp.d.ts +3 -0
  22. package/dist/server/mcpHttp.js +3 -0
  23. package/dist/server/mcpSpec.d.ts +13 -13
  24. package/dist/server/mcpSpec.js +73 -8
  25. package/dist/server/postSurfaces.js +3 -12
  26. package/dist/server/richRender.js +3 -1
  27. package/dist/server/run.js +4 -0
  28. package/dist/server/runApi.d.ts +1 -1
  29. package/dist/server/runApi.js +4 -3
  30. package/dist/server/sqlStore.d.ts +89 -4
  31. package/dist/server/sqlStore.js +149 -23
  32. package/dist/server/surfacePage.d.ts +6 -2
  33. package/dist/server/surfacePage.js +142 -10
  34. package/dist/server/themes.d.ts +2 -0
  35. package/dist/server/themes.js +23 -3
  36. package/dist/server/types.d.ts +73 -11
  37. package/dist/server/types.js +42 -2
  38. package/dist/server/viewerRequest.d.ts +2 -0
  39. package/dist/server/viewerRequest.js +14 -0
  40. package/guide/topics/asks.md +50 -3
  41. package/guide/topics/feedback.md +89 -11
  42. package/guide/topics/html.md +5 -1
  43. package/guide/topics/http.md +2 -1
  44. package/guide/topics/knobs.md +2 -1
  45. package/guide/topics/surfaces.md +24 -8
  46. package/package.json +4 -13
  47. package/skills/launch-video/SKILL.md +1 -1
  48. package/skills/mockpit/SKILL.md +12 -4
  49. package/viewer/dist/index.html +8 -8
  50. package/dist/server/slots.d.ts +0 -14
  51. package/dist/server/slots.js +0 -71
@@ -0,0 +1,23 @@
1
+ import { type SlotInfo } from "./fills.ts";
2
+ import { type KnobValue, type Mock, type Post, type Reply } from "./types.ts";
3
+ export interface SlotDecision {
4
+ fill: string;
5
+ label?: string;
6
+ from?: string;
7
+ slots?: Record<string, SlotDecision>;
8
+ }
9
+ export interface StateDecision {
10
+ variant: string;
11
+ slots: Record<string, SlotDecision>;
12
+ }
13
+ export interface Decisions {
14
+ states: Record<string, StateDecision>;
15
+ knobs: Record<string, KnobValue>;
16
+ }
17
+ export declare function replyValues(reply: Pick<Reply, "answers" | "tuned">, mock: Pick<Mock, "asks">): Record<string, KnobValue>;
18
+ export declare function settledValues(replies: Pick<Reply, "answers" | "tuned">[], mock: Pick<Mock, "asks" | "knobs">, posts: (Pick<Post, "knobs"> & {
19
+ surfaces?: Post["surfaces"];
20
+ })[]): Record<string, KnobValue>;
21
+ export declare function chosenFills(slots: SlotInfo[], values: Record<string, KnobValue>): Record<string, string>;
22
+ export declare function postForState(posts: Post[], state: string | null): Post | undefined;
23
+ export declare function decisionsFor(mock: Mock, posts: Post[], reply: Pick<Reply, "answers" | "tuned" | "mix">): Decisions | null;
@@ -0,0 +1,99 @@
1
+ // What a reply decided, as the screen is built: per state, the look and each
2
+ // slot's chosen fill, with the slots inside a fill listed under it only when
3
+ // that fill was chosen; plus every other knob value the reply set. The agent
4
+ // reads "entry → skeleton → rows → shimmer" instead of reassembling it from
5
+ // option ids, and `export` composes the same choice into one screen.
6
+ // Runtime-agnostic (no node imports).
7
+ import { slotsInSurfaces } from "./fills.js";
8
+ import { checkKnobValue, knobFor } from "./knobs.js";
9
+ import { inUseVersion, postAtVersion, } from "./types.js";
10
+ // The knob values a reply carries: each chosen option's `set`, then tuning,
11
+ // which wins, as in the viewer's resolveKnobs.
12
+ export function replyValues(reply, mock) {
13
+ const out = {};
14
+ for (const ask of mock.asks) {
15
+ const answer = reply.answers[ask.id];
16
+ const ids = answer === undefined ? [] : Array.isArray(answer) ? answer : [answer];
17
+ for (const id of ids)
18
+ Object.assign(out, ask.options.find((o) => o.id === id)?.set ?? {});
19
+ }
20
+ return { ...out, ...reply.tuned };
21
+ }
22
+ // Every value the user has sent, oldest reply first so a later answer or tune
23
+ // replaces an earlier one. Values the knobs declared now can't hold drop out:
24
+ // the agent may have renamed or narrowed a knob since.
25
+ export function settledValues(replies, mock, posts) {
26
+ const all = {};
27
+ for (const r of replies)
28
+ Object.assign(all, replyValues(r, mock));
29
+ const out = {};
30
+ for (const [path, value] of Object.entries(all)) {
31
+ const config = knobFor(path, mock, posts);
32
+ const checked = config === undefined ? null : checkKnobValue(path, config, value);
33
+ if (checked?.ok)
34
+ out[path] = checked.value;
35
+ }
36
+ return out;
37
+ }
38
+ // The fill each slot shows under these values: a value naming one of its
39
+ // fills, else its default.
40
+ export function chosenFills(slots, values) {
41
+ const out = {};
42
+ for (const s of slots) {
43
+ const v = values[s.part];
44
+ out[s.part] = typeof v === "string" && s.fills.some((f) => f.name === v) ? v : s.default;
45
+ }
46
+ return out;
47
+ }
48
+ // The post a state is decided by: the accepted look at its version in use,
49
+ // else the open one.
50
+ export function postForState(posts, state) {
51
+ const inState = posts.filter((p) => p.state === state);
52
+ const post = inState.find((p) => p.status === "accepted") ??
53
+ inState.find((p) => p.status === "open") ??
54
+ inState[0];
55
+ return post && (postAtVersion(post, inUseVersion(post)) ?? post);
56
+ }
57
+ function tree(slots, chosen, parent, mix) {
58
+ const out = {};
59
+ for (const s of slots) {
60
+ const same = parent
61
+ ? s.parent?.part === parent.part && s.parent.fill === parent.fill
62
+ : s.parent === null;
63
+ if (!same)
64
+ continue;
65
+ const fill = chosen[s.part];
66
+ const label = s.fills.find((f) => f.name === fill)?.label;
67
+ const inner = tree(slots, chosen, { part: s.part, fill }, mix);
68
+ out[s.part] = {
69
+ fill,
70
+ ...(label ? { label } : {}),
71
+ ...(mix[s.part] ? { from: mix[s.part] } : {}),
72
+ ...(Object.keys(inner).length ? { slots: inner } : {}),
73
+ };
74
+ }
75
+ return out;
76
+ }
77
+ // Null when the mock has no slots and the reply set no knobs: then the
78
+ // answered asks already say everything.
79
+ export function decisionsFor(mock, posts, reply) {
80
+ const values = replyValues(reply, mock);
81
+ const states = {};
82
+ const slotPaths = new Set();
83
+ for (const state of mock.states.length ? mock.states : [null]) {
84
+ const post = postForState(posts, state);
85
+ if (!post)
86
+ continue;
87
+ const slots = slotsInSurfaces(post.surfaces);
88
+ for (const s of slots)
89
+ slotPaths.add(s.part);
90
+ states[state ?? ""] = {
91
+ variant: post.variant,
92
+ slots: tree(slots, chosenFills(slots, values), null, reply.mix ?? {}),
93
+ };
94
+ }
95
+ const knobs = Object.fromEntries(Object.entries(values).filter(([p]) => !slotPaths.has(p)));
96
+ if (!slotPaths.size && !Object.keys(knobs).length)
97
+ return null;
98
+ return { states, knobs };
99
+ }
@@ -29,7 +29,7 @@ export function iconsLine(design) {
29
29
  }
30
30
  const TOPIC_SUMMARY = {
31
31
  knobs: "every knob shape, how values reach html",
32
- asks: "the ask JSON, scopes, the Look ask, multi",
32
+ asks: "the ask JSON, scopes, slots (data-fill), the Look ask, multi",
33
33
  surfaces: "markdown, diff, mermaid, code, terminal, json, image; uploads",
34
34
  html: "the contract, finish rules, tokens, kits, Tailwind, icons",
35
35
  feedback: "the feedback JSON, pending, delivery, export",
@@ -228,6 +228,9 @@ page or component, by slug; a state is one moment of it in the user's words
228
228
  ("Writing"), omitted for a single-state mock; a variant is a parallel design of
229
229
  a state; versions are its history. A part is an element marked \`data-part\`.
230
230
  The user answers asks, tunes knobs, presses Send once; you get one reply.
231
+ Name a state by what the user sees on that screen ("Home, idle", "Proposal
232
+ shown"), never by where it is in your process ("Prototype", "v2", "Round 2"):
233
+ the viewer lists states as the screens of the mock.
231
234
 
232
235
  ## The loop
233
236
 
@@ -241,7 +244,8 @@ mockpit publish --mock writer --state "Writing" --variant dark --html dark.html
241
244
  mockpit ask --mock writer "Which look?" --option Quiet=quiet --option Dark=dark
242
245
  mockpit feedback # after the user says they answered
243
246
  mockpit publish --mock writer --state "Writing" --variant dark --parts body=b.html # v2, one part
244
- mockpit export --mock writer # the accepted html per state
247
+ mockpit publish --mock writer --state "Writing" --variant dark --fills bar#tabs=t.html
248
+ mockpit export --mock writer # the chosen html per state
245
249
  \`\`\`
246
250
 
247
251
  Server: \`$MOCKPIT_URL\` (default http://localhost:8228; \`mockpit serve\`).
@@ -254,12 +258,37 @@ feedback on. The same name in two states is the same part. \`data-part-key\`
254
258
  tells list items apart and keeps comments anchored across a rename. Never
255
259
  declare geometry; the viewer measures it.
256
260
 
257
- ## Ask or knob
261
+ ## The design is the UI
258
262
 
259
- A choice is several variants plus one ask that binds them. Ask it first. A
260
- variant decides everything it differs on; don't re-ask it. Visual options
261
- carry a \`variant\` or knob \`set\`. One render plus a control: a knob,
262
- \`--knobs '{...}'\`.
263
+ Publish the screen alone, laid out for the width it ships at: no captions,
264
+ side notes, labels or rationale beside it (they shrink the design on a phone).
265
+ Say what a variant is in \`--note "System font, thin bars; feels like a tool"\`;
266
+ the viewer shows it on the variant and its option. Name the widths the user
267
+ ships to once, \`--widths 390,1280\`, and the design is shown at each, side by
268
+ side; without them the viewer picks one for the screen it is on.
269
+ Screens the user moves between, even across mocks, are a flow: publish each
270
+ with \`--flow Morning --step 2\` (\`--step 0\` takes it out); the viewer walks it.
271
+
272
+ ## Build from components
273
+
274
+ A screen made of reusable pieces (a button, a card, a header, a day strip) is
275
+ built from them: publish each piece as its own mock (\`--kind component\`), and
276
+ the screen (\`--kind page\`) embeds it with \`<mockpit-embed slug="card">\`.
277
+ Vary a piece in its own mock, not by copying it into every page: the user
278
+ sees every page it is in, a decision on it reaches all of them, and Home's
279
+ Components tab counts the screens it is in. Topic \`surfaces\` has the details.
280
+
281
+ ## Ask, slot or knob
282
+
283
+ Answers drive the screen. A choice is several variants plus one ask that binds
284
+ them (ask it first), or a slot: one region's alternatives in one html, sharing
285
+ \`data-part\`, each its own \`data-fill\` (\`<nav data-part="bar" data-fill="thin">\`).
286
+ The viewer shows the chosen fill. A fill may hold slots, asked only while it is
287
+ chosen. Send publish's \`suggestedSlotAsks\` (\`set: {"bar": "tabs"}\`). A variant
288
+ decides all it differs on; don't re-ask it. Visual options carry a \`variant\`
289
+ or knob \`set\`. One render plus a control: a knob,
290
+ \`--knobs '{...}'\`. Declare knobs for what is worth fine-tuning (size,
291
+ spacing, colour, density) even unasked; they wait in Tune.
263
292
 
264
293
  | knob | control |
265
294
  | --- | --- |
@@ -278,16 +307,22 @@ mix, comments, reactions, decision, text}, accepted, archived}\`. In order: answ
278
307
  decide structure (a variant answer already accepted it); tuned values become
279
308
  defaults; mix names a part to take from another variant (if it conflicts with an
280
309
  answer, the answer wins); address each comment and reaction on its part; decision
281
- (accept, revise, drop) replaces answers when there were no asks. Each Send arrives exactly once: in a write's
310
+ (accept, revise, drop) replaces answers when there were no asks; \`decisions\` is
311
+ the screen as chosen, per state: variant, then each slot's fill, nested. Each Send arrives exactly once: in a write's
282
312
  \`feedback\`, \`mockpit feedback\` or a \`mockpit watch\` line.
283
313
  A \`pending\` draft means the user is still answering. Send a nudge's
284
- \`suggestedAsk\` with \`ask\`.
314
+ \`suggestedAsk\` with \`ask\`. A batch with \`resolved: true\` (no reply) means
315
+ the user closed the mock unanswered: its \`dismissed\` asks were not worth
316
+ answering. Don't ask them again, under any id; \`ask\` skips a dismissed id.
317
+ The viewer shows each state its own asks and only open ones; a sent answer is
318
+ not shown again.
285
319
 
286
320
  ## HTML
287
321
 
288
322
  Send a body fragment: no doctype, html, head or body. Keep content in normal
289
323
  flow: no \`position: fixed\`, no absolute layers over a fixed height; grid-stack
290
- (\`grid-area: 1/1\`) to overlap. Never hardcode a color; use the tokens below so
324
+ (\`grid-area: 1/1\`) to overlap. A web font is decoration: the page must read
325
+ without it. Never hardcode a color; use the tokens below so
291
326
  dark and light both work. External loads only from cdnjs.cloudflare.com, esm.sh,
292
327
  cdn.jsdelivr.net, unpkg.com and Google Fonts.
293
328
 
@@ -0,0 +1,25 @@
1
+ import type { Embed } from "./types.ts";
2
+ export interface EmbedTag {
3
+ slug: string;
4
+ variant: string | null;
5
+ version: number | null;
6
+ }
7
+ export interface ResolvedEmbed {
8
+ variant: string;
9
+ version: number;
10
+ html: string;
11
+ attrs?: string;
12
+ stamp?: string;
13
+ }
14
+ export type EmbedResolver = (tag: EmbedTag) => Promise<ResolvedEmbed | null>;
15
+ export declare const MAX_EMBED_DEPTH = 6;
16
+ export declare function parseEmbedTags(html: string): EmbedTag[];
17
+ export declare const hasEmbedTags: (html: string) => boolean;
18
+ export interface Expanded {
19
+ html: string;
20
+ used: Embed[];
21
+ stamps: string[];
22
+ cycle: string[] | null;
23
+ }
24
+ export declare function expandHtml(html: string, resolve: EmbedResolver, stack: string[]): Promise<Expanded>;
25
+ export declare function embedsKey({ used, stamps }: Pick<Expanded, "used" | "stamps">): string;
@@ -0,0 +1,118 @@
1
+ // Composition: `<mockpit-embed slug="…" variant="…" version="…">` tags in any
2
+ // mock's html are expanded server-side by inlining the referenced variant
3
+ // version's first html surface body, recursively, so a component can be built
4
+ // from components. A tag without `version` follows the version in use when the
5
+ // variant it resolves to is accepted, else its latest; one without `variant`
6
+ // follows the component's accepted variant.
7
+ // Publish records the versions it saw on the post (`Post.embeds`), so what a
8
+ // version rendered with stays answerable.
9
+ //
10
+ // Runtime-agnostic (no node imports) and string-only: the result is handed to
11
+ // renderHtmlPage, which serves it from /s/:id under the sandbox CSP header, so
12
+ // an inlined component body is exactly as contained as the page's own markup.
13
+ // `<mockpit-slot>`, the tag's first name, is still read: "slot" now means a
14
+ // part with alternative fills (fills.ts).
15
+ // Deep enough for page › section › component › control; past it a chain is
16
+ // almost certainly a mistake and costs a render each time.
17
+ export const MAX_EMBED_DEPTH = 6;
18
+ const EMBED_TAG = /<mockpit-(?:embed|slot)\b([^>]*?)(?:\/>|>\s*<\/mockpit-(?:embed|slot)\s*>|>)/gi;
19
+ const ATTR = /([a-z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi;
20
+ function attributes(raw) {
21
+ const out = {};
22
+ ATTR.lastIndex = 0;
23
+ let m;
24
+ while ((m = ATTR.exec(raw)))
25
+ out[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
26
+ return out;
27
+ }
28
+ function tagOf(attrRaw) {
29
+ const attrs = attributes(attrRaw);
30
+ const slug = attrs.slug ?? "";
31
+ if (!slug)
32
+ return null;
33
+ const version = Number(attrs.version);
34
+ return {
35
+ slug,
36
+ variant: attrs.variant || null,
37
+ version: Number.isInteger(version) && version > 0 ? version : null,
38
+ };
39
+ }
40
+ export function parseEmbedTags(html) {
41
+ const tags = [];
42
+ EMBED_TAG.lastIndex = 0;
43
+ let m;
44
+ while ((m = EMBED_TAG.exec(html))) {
45
+ const tag = tagOf(m[1] ?? "");
46
+ if (tag)
47
+ tags.push(tag);
48
+ }
49
+ return tags;
50
+ }
51
+ export const hasEmbedTags = (html) => /<mockpit-(?:embed|slot)\b/i.test(html);
52
+ const escapeAttr = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
53
+ // Replace each tag with its resolved body, expanding that body's own tags in
54
+ // turn. `stack` is the chain of slugs being expanded (the root mock's first),
55
+ // so a component that reaches back up renders a marked placeholder instead of
56
+ // recursing. An unresolvable embed renders as an empty marked-up placeholder
57
+ // rather than vanishing, so a broken reference is visible in review instead of
58
+ // silently changing the layout.
59
+ export async function expandHtml(html, resolve, stack) {
60
+ const used = [];
61
+ const stamps = [];
62
+ let cycle = null;
63
+ const matches = [];
64
+ EMBED_TAG.lastIndex = 0;
65
+ let m;
66
+ while ((m = EMBED_TAG.exec(html)))
67
+ matches.push({ tag: tagOf(m[1] ?? "") });
68
+ const bodies = [];
69
+ for (const { tag } of matches) {
70
+ if (!tag) {
71
+ bodies.push(null);
72
+ continue;
73
+ }
74
+ const label = (variant, version) => `data-mockpit-embed="${escapeAttr(tag.slug)}"${variant ? ` data-mockpit-variant="${escapeAttr(variant)}"` : ""}${version == null ? "" : ` data-mockpit-version="${version}"`}`;
75
+ if (stack.includes(tag.slug)) {
76
+ cycle ??= [...stack.slice(stack.indexOf(tag.slug)), tag.slug];
77
+ bodies.push(`<div ${label(tag.variant, tag.version)} data-mockpit-cycle="1"></div>`);
78
+ continue;
79
+ }
80
+ if (stack.length >= MAX_EMBED_DEPTH) {
81
+ bodies.push(`<div ${label(tag.variant, tag.version)} data-mockpit-too-deep="1"></div>`);
82
+ continue;
83
+ }
84
+ const resolved = await resolve(tag);
85
+ if (!resolved) {
86
+ bodies.push(`<div ${label(tag.variant, tag.version)} data-mockpit-missing="1"></div>`);
87
+ continue;
88
+ }
89
+ used.push({
90
+ slug: tag.slug,
91
+ variant: resolved.variant,
92
+ version: resolved.version,
93
+ ...(tag.version != null ? { pinned: true } : {}),
94
+ });
95
+ stamps.push(resolved.stamp ?? "");
96
+ const inner = await expandHtml(resolved.html, resolve, [...stack, tag.slug]);
97
+ used.push(...inner.used);
98
+ stamps.push(...inner.stamps);
99
+ cycle ??= inner.cycle;
100
+ bodies.push(`<div ${label(resolved.variant, resolved.version)}${resolved.attrs ?? ""}>${inner.html}</div>`);
101
+ }
102
+ let i = 0;
103
+ EMBED_TAG.lastIndex = 0;
104
+ const out = html.replace(EMBED_TAG, (raw) => bodies[i++] ?? raw);
105
+ return { html: out, used, stamps, cycle };
106
+ }
107
+ // A short, stable key for what a document's embeds resolved to: part of the
108
+ // render cache key and of the frame URL, so a component's new version reloads
109
+ // every page that shows it and a stale document is never served as current.
110
+ export function embedsKey({ used, stamps }) {
111
+ let h = 0x811c9dc5;
112
+ const s = used.map((e, i) => `${e.slug}:${e.variant}@${e.version}:${stamps[i] ?? ""}`).join("|");
113
+ for (let i = 0; i < s.length; i++) {
114
+ h ^= s.charCodeAt(i);
115
+ h = Math.imul(h, 0x01000193);
116
+ }
117
+ return (h >>> 0).toString(36);
118
+ }
@@ -4,6 +4,7 @@
4
4
  // the agent was told was pending. Shapes and ids only, never html or comment
5
5
  // text: the store already holds the content under the same ids, and a log that
6
6
  // copied it would be a second, unbounded store. `scripts/inspect.ts` reads it.
7
+ import { isViewerRequest } from "./viewerRequest.js";
7
8
  const MAX_ERROR = 300;
8
9
  const CLIENT = /^[\w.@/-]{1,64}$/;
9
10
  export function clientOf(header, viewer, mcp) {
@@ -112,7 +113,7 @@ export function logMiddleware(sink) {
112
113
  return async (c, next) => {
113
114
  const start = Date.now();
114
115
  await next();
115
- const viewer = c.req.header("sec-fetch-site") === "same-origin";
116
+ const viewer = isViewerRequest(c.req.raw);
116
117
  if (!loggable(c, viewer))
117
118
  return;
118
119
  try {
@@ -1,8 +1,12 @@
1
+ import { type Decisions } from "./decisions.ts";
1
2
  import { type AskOption, type Comment, type CommentAnchor, type Mock, OTHER_ID, type PartComment, type Post, type Reaction, type Reply, type Store } from "./types.ts";
2
3
  export interface VariantRef {
3
4
  state: string | null;
4
5
  variant: string;
5
6
  }
7
+ export interface InUseRef extends VariantRef {
8
+ version: number;
9
+ }
6
10
  export interface FeedbackComment {
7
11
  seq: number;
8
12
  text: string;
@@ -35,6 +39,7 @@ export interface FeedbackReply extends Omit<Reply, "others" | "notes" | "otherIm
35
39
  at: string;
36
40
  asks: AnsweredAsk[];
37
41
  reactions?: string[];
42
+ decisions?: Decisions;
38
43
  }
39
44
  export interface FeedbackBatch {
40
45
  mockId: string | null;
@@ -43,9 +48,15 @@ export interface FeedbackBatch {
43
48
  title: string | null;
44
49
  reply: FeedbackReply | null;
45
50
  comments: FeedbackComment[];
46
- accepted: VariantRef[];
51
+ accepted: InUseRef[];
47
52
  archived: VariantRef[];
53
+ dropped: VariantRef[];
48
54
  handoff?: string;
55
+ resolved?: true;
56
+ dismissed?: {
57
+ ask: string;
58
+ text: string;
59
+ }[];
49
60
  }
50
61
  export declare function reactionLine(c: PartComment & {
51
62
  reaction: Reaction;
@@ -2,7 +2,8 @@
2
2
  // The `feedback` verb, the watch long-poll and the write piggyback all return this,
3
3
  // so an agent reads one grouped object instead of re-deriving which mock a flat
4
4
  // comment list belongs to. Runtime-agnostic (no node imports).
5
- import { builtinAsk, DEFAULT_VARIANT, OTHER_ID, } from "./types.js";
5
+ import { decisionsFor } from "./decisions.js";
6
+ import { builtinAsk, inUseVersion, DEFAULT_VARIANT, OTHER_ID, } from "./types.js";
6
7
  const ref = (p) => ({ state: p.state, variant: p.variant });
7
8
  const REACTION_LEAD = {
8
9
  up: "👍",
@@ -74,7 +75,7 @@ export function groupFeedback(comments, mocks, posts, base = "") {
74
75
  const mock = c.mockId ? mocks.get(c.mockId) : undefined;
75
76
  const mockPosts = (c.mockId && posts.get(c.mockId)) || [];
76
77
  let batch = open.get(key);
77
- if (!batch || (c.kind === "reply" && batch.reply)) {
78
+ if (!batch || (c.kind === "reply" && (batch.reply || batch.resolved))) {
78
79
  batch = {
79
80
  mockId: c.mockId,
80
81
  project: mock?.project ?? null,
@@ -84,10 +85,20 @@ export function groupFeedback(comments, mocks, posts, base = "") {
84
85
  comments: [],
85
86
  accepted: [],
86
87
  archived: [],
88
+ dropped: [],
87
89
  };
88
90
  open.set(key, batch);
89
91
  batches.push(batch);
90
92
  }
93
+ if (c.kind === "reply" && c.payload?.resolved) {
94
+ batch.resolved = true;
95
+ batch.dismissed = c.payload.resolved.dismissed.map((id) => ({
96
+ ask: id,
97
+ text: (mock?.asks.find((a) => a.id === id) ?? (mock && builtinAsk(mock, mockPosts, id)))
98
+ ?.text ?? "",
99
+ }));
100
+ continue;
101
+ }
91
102
  if (c.kind === "reply" && c.payload) {
92
103
  const { others: _others, notes: _notes, otherImages: _otherImages, noteImages: _noteImages, ...payload } = c.payload;
93
104
  const reactions = (payload.comments ?? []).filter(isReaction).map(reactionLine);
@@ -99,8 +110,14 @@ export function groupFeedback(comments, mocks, posts, base = "") {
99
110
  asks: answered(c.payload, mock, mockPosts, base),
100
111
  ...(reactions.length ? { reactions } : {}),
101
112
  };
102
- batch.accepted = mockPosts.filter((p) => p.status === "accepted").map(ref);
113
+ const decisions = mock ? decisionsFor(mock, mockPosts, c.payload) : null;
114
+ if (decisions)
115
+ batch.reply.decisions = decisions;
116
+ batch.accepted = mockPosts
117
+ .filter((p) => p.status === "accepted")
118
+ .map((p) => ({ ...ref(p), version: inUseVersion(p) }));
103
119
  batch.archived = mockPosts.filter((p) => p.status === "archived").map(ref);
120
+ batch.dropped = mockPosts.filter((p) => p.status === "dropped").map(ref);
104
121
  continue;
105
122
  }
106
123
  const post = c.postId ? mockPosts.find((p) => p.id === c.postId) : undefined;
@@ -0,0 +1,41 @@
1
+ import type { Knobs, Surface } from "./types.ts";
2
+ export interface Fill {
3
+ name: string;
4
+ label?: string;
5
+ }
6
+ export interface SlotInfo {
7
+ part: string;
8
+ label?: string;
9
+ fills: Fill[];
10
+ default: string;
11
+ parent: {
12
+ part: string;
13
+ fill: string;
14
+ } | null;
15
+ }
16
+ interface Range {
17
+ part: string;
18
+ fill: string;
19
+ start: number;
20
+ end: number;
21
+ tagEnd: number;
22
+ }
23
+ export interface FillScan {
24
+ slots: SlotInfo[];
25
+ ranges: Range[];
26
+ }
27
+ export declare function scanFills(html: string): FillScan;
28
+ export declare function slotKnobs(slots: SlotInfo[]): Knobs;
29
+ export declare function slotCss(slots: SlotInfo[]): string;
30
+ export declare function composeFills(html: string, chosen: Record<string, string>): string;
31
+ export declare function slotsInSurfaces(surfaces: Surface[]): SlotInfo[];
32
+ export type FillSplice = {
33
+ ok: true;
34
+ surfaces: Surface[];
35
+ applied: string[];
36
+ } | {
37
+ ok: false;
38
+ error: string;
39
+ };
40
+ export declare function spliceFills(surfaces: Surface[], edits: Record<string, string>): FillSplice;
41
+ export {};