mockpit 1.1.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 (62) hide show
  1. package/README.md +13 -6
  2. package/bin/mockpit.js +139 -18
  3. package/dist/mcp/server.js +30 -10
  4. package/dist/server/apiViews.d.ts +129 -25
  5. package/dist/server/apiViews.js +86 -23
  6. package/dist/server/app.d.ts +2 -1
  7. package/dist/server/app.js +1258 -200
  8. package/dist/server/appIcon.d.ts +22 -0
  9. package/dist/server/appIcon.js +43 -0
  10. package/dist/server/appIconPng.d.ts +1 -0
  11. package/dist/server/appIconPng.js +6 -0
  12. package/dist/server/askNudges.d.ts +3 -0
  13. package/dist/server/askNudges.js +40 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +8 -0
  16. package/dist/server/decisions.d.ts +23 -0
  17. package/dist/server/decisions.js +99 -0
  18. package/dist/server/designGuide.js +53 -16
  19. package/dist/server/embeds.d.ts +25 -0
  20. package/dist/server/embeds.js +118 -0
  21. package/dist/server/eventLog.js +2 -1
  22. package/dist/server/feedbackBatch.d.ts +28 -5
  23. package/dist/server/feedbackBatch.js +78 -11
  24. package/dist/server/fills.d.ts +41 -0
  25. package/dist/server/fills.js +328 -0
  26. package/dist/server/handoff.d.ts +6 -0
  27. package/dist/server/handoff.js +38 -0
  28. package/dist/server/index.js +15 -1
  29. package/dist/server/knobs.d.ts +6 -2
  30. package/dist/server/knobs.js +13 -4
  31. package/dist/server/mcpHttp.d.ts +5 -2
  32. package/dist/server/mcpHttp.js +14 -5
  33. package/dist/server/mcpSpec.d.ts +21 -13
  34. package/dist/server/mcpSpec.js +99 -9
  35. package/dist/server/postSurfaces.js +3 -12
  36. package/dist/server/richRender.js +3 -1
  37. package/dist/server/run.js +6 -2
  38. package/dist/server/runApi.d.ts +1 -1
  39. package/dist/server/runApi.js +5 -4
  40. package/dist/server/sqlStore.d.ts +92 -5
  41. package/dist/server/sqlStore.js +188 -30
  42. package/dist/server/surfacePage.d.ts +6 -2
  43. package/dist/server/surfacePage.js +142 -10
  44. package/dist/server/themes.d.ts +2 -0
  45. package/dist/server/themes.js +23 -3
  46. package/dist/server/types.d.ts +95 -10
  47. package/dist/server/types.js +89 -6
  48. package/dist/server/viewerRequest.d.ts +2 -0
  49. package/dist/server/viewerRequest.js +14 -0
  50. package/extensions/mockpit.js +1 -0
  51. package/guide/topics/asks.md +69 -10
  52. package/guide/topics/feedback.md +104 -16
  53. package/guide/topics/html.md +5 -1
  54. package/guide/topics/http.md +2 -1
  55. package/guide/topics/knobs.md +4 -3
  56. package/guide/topics/surfaces.md +32 -16
  57. package/package.json +4 -13
  58. package/skills/launch-video/SKILL.md +1 -1
  59. package/skills/mockpit/SKILL.md +14 -4
  60. package/viewer/dist/index.html +9 -9
  61. package/dist/server/slots.d.ts +0 -14
  62. package/dist/server/slots.js +0 -71
@@ -0,0 +1,22 @@
1
+ import { type Mode } from "./themes.ts";
2
+ export declare const ICON_SVG: string;
3
+ export declare const ICON_SIZES: readonly [180, 192, 512];
4
+ export type IconSize = (typeof ICON_SIZES)[number];
5
+ export declare const themeColor: (mode: Mode) => string;
6
+ export declare function webManifest(mode: Mode, startUrl?: string): {
7
+ name: string;
8
+ short_name: string;
9
+ description: string;
10
+ id: string;
11
+ start_url: string;
12
+ scope: string;
13
+ display: string;
14
+ background_color: string;
15
+ theme_color: string;
16
+ icons: {
17
+ src: string;
18
+ sizes: string;
19
+ type: string;
20
+ purpose: string;
21
+ }[];
22
+ };
@@ -0,0 +1,43 @@
1
+ // The installable app: its web app manifest and its icon. Runtime-agnostic (no
2
+ // `node:` imports) so the Worker serves the same bytes. The PNG renders of the
3
+ // icon are generated from ICON_SVG by scripts/app-icons.ts into appIconPng.ts,
4
+ // because iOS takes no SVG for a home-screen icon.
5
+ import { DIALKIT } from "./themes.js";
6
+ const BG = DIALKIT.dark.bg;
7
+ const ACCENT = DIALKIT.dark.info.text;
8
+ // A stage frame with a comment pin on its corner. Full-bleed background and the
9
+ // mark inside the central 80% circle, so the same art works as `maskable`.
10
+ export const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
11
+ <rect width="512" height="512" fill="${BG}"/>
12
+ <rect x="126" y="142" width="260" height="196" rx="34" fill="none" stroke="${ACCENT}" stroke-width="28"/>
13
+ <path d="M170 206h120M170 262h76" stroke="${ACCENT}" stroke-width="24" stroke-linecap="round" opacity="0.55"/>
14
+ <circle cx="366" cy="338" r="58" fill="${ACCENT}" stroke="${BG}" stroke-width="22"/>
15
+ </svg>`;
16
+ export const ICON_SIZES = [180, 192, 512];
17
+ export const themeColor = (mode) => DIALKIT[mode].bg;
18
+ // Relative URLs resolve against the manifest's own URL, so the app works under
19
+ // any base path. `startUrl` carries `?key=` on a tokened workspace: a
20
+ // home-screen app may not share Safari's cookie jar, and the key is how the
21
+ // viewer gets its cookie back on first launch.
22
+ export function webManifest(mode, startUrl = "./") {
23
+ return {
24
+ name: "mockpit",
25
+ short_name: "mockpit",
26
+ description: "Answer your agent's design questions.",
27
+ id: "./",
28
+ start_url: startUrl,
29
+ scope: "./",
30
+ display: "standalone",
31
+ background_color: themeColor(mode),
32
+ theme_color: themeColor(mode),
33
+ icons: [
34
+ { src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
35
+ ...ICON_SIZES.filter((s) => s !== 180).map((s) => ({
36
+ src: `icon-${s}.png`,
37
+ sizes: `${s}x${s}`,
38
+ type: "image/png",
39
+ purpose: "any maskable",
40
+ })),
41
+ ],
42
+ };
43
+ }
@@ -0,0 +1 @@
1
+ export declare const ICON_PNG_BASE64: Record<number, string>;
@@ -0,0 +1,6 @@
1
+ // Generated by scripts/app-icons.ts from ICON_SVG; do not edit.
2
+ export const ICON_PNG_BASE64 = {
3
+ 180: "iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAAKnklEQVR4nOzdfXAU9R3H8W9yyT0k95BcEhJCHgiJEXkIjTRqRAYUpDyNQBGKteq0xZbOtDqdtuPUGTt22k7/czr9wz+EtsoMijwjT/IgIGIxahRJCCQBQsjzcy6Xu9xDHlzMwDDmvktCbve3e/m8Jn/dLoTk3uz+9rd7uzEOh4MAQokmAAbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiAFYMaVV+zvL7p62clDTDbEq0mJ1mk40ihc/v7vN1+vxdLe0XK68drL5+hDQpSmtPTbDFT55f9Mqs+9cbY+NoYggEveWVO858/s9ebwtpiYbiMBntCx95rajgRZqoSs6/KSXiD7hJG7QSh92a8eyqfUmJeTSxtXVceveDtW5PI2mAwWw2k2hpKXNeWPuhw5ZBE158XMqs/Kdr6j/2eFtJNPFHK4mOac+tORBnSSL4jjU+9fkfH3Im5JJoguMwmxzS3kQabRDcQfqF/PSpPcZYwQdoIuOIjo7ZsHJngj2LYIQEe/b6Fe9GRYl8g0SOOYoKfl048wUChtSHx9vW1PoVCSJyEqy48OVRrtnT29jlqhkaGiL9i4qKSnTk2K3po1l53g//UFq+hQQRFscDuats1jT5dXz+nsOnf3+19rg/0EORRRps5WY9uWzhG2aT3HjLbp0szRRX1RwmEYTt0qbnrZJfobntwlvvPVpRvTvyyqCb3bsuVu+SfsDWjovyaz6Qt5oEERNHdHRsfs4ymRXcnuZ3di/t6a2niCb9gG/vWur2NMmsk5+zVPp1kQhi4piW+URsjEVmhX3HNgb7vTQBBILufcd+JbOCdFibk7mQRBATR2rKbJmlja1f1zacpQmjtuGTxpbzMiukJReQCGLiMMvOetU1nqMJpr65RGap0ShmNkxMHPJTonVNn9EEI/8jW0xiTo6KOZQ1GIwyS6VZDZpgXO46maUGg4lEwGWCwEIcwEIcwEIcwEIcwNLuRxNGMsbastIftcanGgRNJw8LBD2u3vobE2CaTjdx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",
4
+ 192: "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",
5
+ 512: "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",
6
+ };
@@ -0,0 +1,3 @@
1
+ import type { Ask } from "./types.ts";
2
+ export declare const looksVisual: (label: string) => boolean;
3
+ export declare function askNudges(added: Ask[], all: Ask[]): string[];
@@ -0,0 +1,40 @@
1
+ // Ask discipline on `ask`: the two habits that make the user type or re-answer
2
+ // what they could have seen. Pure, so the rules are tested on their own.
3
+ // Words that name how something looks, matched on option labels only: a
4
+ // question's text names the topic, not the options. Words with a common
5
+ // non-visual sense ("size", "background", "compact", "white") are left out, so
6
+ // a pill about file size or a background job is not flagged.
7
+ const VISUAL = /\b(colou?rs?|borders?|radius|rounded|corners?|spacing|padding|fonts?|typeface|serif|shadows?|tint|hue|opacity|red|orange|yellow|green|olive|teal|blue|navy|purple|violet|pink|gr[ae]y|\d+\s?(px|rem|em|pt))\b|#[0-9a-f]{3,8}\b/i;
8
+ const plain = (o) => !o.variant && !(o.set && Object.keys(o.set).length);
9
+ export const looksVisual = (label) => VISUAL.test(label);
10
+ // A Look-style ask: one whose options choose between variants.
11
+ const bindsVariants = (a) => a.options.some((o) => o.variant);
12
+ const quoted = (opts) => opts.map((o) => `"${o.label}"`).join(", ");
13
+ export function askNudges(added, all) {
14
+ const out = [];
15
+ for (const ask of added) {
16
+ const pills = ask.options.filter(plain);
17
+ if (!pills.length)
18
+ continue;
19
+ const visual = pills.filter((o) => looksVisual(o.label));
20
+ const look = ask.scope === "part" || bindsVariants(ask)
21
+ ? undefined
22
+ : all.find((a) => a.id !== ask.id &&
23
+ a.scope !== "part" &&
24
+ bindsVariants(a) &&
25
+ (a.scope === "mock" || ask.scope === "mock" || a.state === ask.state));
26
+ // Only a pill that names a look or reads visual overlaps the variants; a
27
+ // plain question on another axis ("Drawer or Margin?") stands beside them.
28
+ const names = new Set(look?.options.flatMap((o) => [o.label, o.variant ?? ""].map((n) => n.toLowerCase())));
29
+ const reask = look
30
+ ? pills.filter((o) => names.has(o.label.toLowerCase()) || looksVisual(o.label))
31
+ : [];
32
+ if (look && reask.length) {
33
+ out.push(`ask "${ask.id}": ${quoted(reask)} may re-ask what "${look.id}" decides. Drop it or use knob sets.`);
34
+ }
35
+ else if (visual.length) {
36
+ out.push(`ask "${ask.id}": ${quoted(visual)} look visual. Give each a knob set or variant.`);
37
+ }
38
+ }
39
+ return out;
40
+ }
@@ -1 +1,2 @@
1
1
  export declare function decodeBase64(b64: string): Uint8Array;
2
+ export declare function encodeBase64(bytes: Uint8Array): string;
@@ -14,3 +14,11 @@ export function decodeBase64(b64) {
14
14
  bytes[i] = bin.charCodeAt(i);
15
15
  return bytes;
16
16
  }
17
+ // bytes -> base64, in chunks so a multi-MB image never spreads into one call.
18
+ export function encodeBase64(bytes) {
19
+ let bin = "";
20
+ for (let i = 0; i < bytes.length; i += 0x8000) {
21
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
22
+ }
23
+ return btoa(bin);
24
+ }
@@ -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, Look, Which one?, 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",
@@ -227,7 +227,10 @@ project › mock › state › variant › version. A project is the repo; a moc
227
227
  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
- Asks are questions, knobs are values; the user presses Send once, you get one reply.
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,11 +244,12 @@ 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\`).
248
- MCP tools have the verb names (\`publish\`, \`feedback\`, …).
252
+ MCP tools share the verb names.
249
253
 
250
254
  ## Parts
251
255
 
@@ -254,10 +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. Look question
260
- first. One render plus a control: declare a knob with \`--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.
261
292
 
262
293
  | knob | control |
263
294
  | --- | --- |
@@ -267,25 +298,31 @@ first. One render plus a control: declare a knob with \`--knobs '{...}'\`.
267
298
  | \`"trim.position": {"type": "select", "options": ["top", "bottom"]}\` | select |
268
299
 
269
300
  Values reach the html unitless: \`calc(var(--k-body-size, 17) * 1px)\`, and as
270
- \`data-k-trim-position\` on \`<html>\`. Topics \`knobs\` and \`asks\` have the rest.
301
+ \`data-k-trim-position\` on \`<html>\`.
271
302
 
272
303
  ## Feedback
273
304
 
274
305
  \`feedback\` returns \`{feedback, pending}\`. A batch is \`{mock, reply: {asks, tuned,
275
- mix, comments, decision, text}, accepted, archived}\`. In order: answers decide
276
- structure (a variant answer already accepted it and archived its siblings);
277
- tuned values become new defaults; mix names a part to take from another variant;
278
- address each comment on its part; decision (accept, revise, drop) stands in for
279
- answers when there were no asks. Each Send arrives exactly once: in a write's
306
+ mix, comments, reactions, decision, text}, accepted, archived}\`. In order: answers
307
+ decide structure (a variant answer already accepted it); tuned values become
308
+ defaults; mix names a part to take from another variant (if it conflicts with an
309
+ answer, the answer wins); address each comment and reaction on its part; decision
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
280
312
  \`feedback\`, \`mockpit feedback\` or a \`mockpit watch\` line.
281
- A \`pending\` draft means the user is still answering. Variants no ask binds
282
- earn a \`suggestedAsk\` on publish: send it with \`ask\`.
313
+ A \`pending\` draft means the user is still answering. Send a nudge's
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.
283
319
 
284
320
  ## HTML
285
321
 
286
322
  Send a body fragment: no doctype, html, head or body. Keep content in normal
287
323
  flow: no \`position: fixed\`, no absolute layers over a fixed height; grid-stack
288
- (\`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
289
326
  dark and light both work. External loads only from cdnjs.cloudflare.com, esm.sh,
290
327
  cdn.jsdelivr.net, unpkg.com and Google Fonts.
291
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 {