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.
- package/README.md +13 -8
- package/bin/mockpit.js +116 -16
- package/dist/mcp/server.js +10 -7
- package/dist/server/apiViews.d.ts +118 -17
- package/dist/server/apiViews.js +66 -8
- package/dist/server/app.d.ts +2 -1
- package/dist/server/app.js +917 -144
- package/dist/server/decisions.d.ts +23 -0
- package/dist/server/decisions.js +99 -0
- package/dist/server/designGuide.js +45 -10
- package/dist/server/embeds.d.ts +25 -0
- package/dist/server/embeds.js +118 -0
- package/dist/server/eventLog.js +2 -1
- package/dist/server/feedbackBatch.d.ts +12 -1
- package/dist/server/feedbackBatch.js +20 -3
- package/dist/server/fills.d.ts +41 -0
- package/dist/server/fills.js +328 -0
- package/dist/server/index.js +15 -1
- package/dist/server/knobs.d.ts +6 -2
- package/dist/server/knobs.js +11 -1
- package/dist/server/mcpHttp.d.ts +3 -0
- package/dist/server/mcpHttp.js +3 -0
- package/dist/server/mcpSpec.d.ts +13 -13
- package/dist/server/mcpSpec.js +73 -8
- package/dist/server/postSurfaces.js +3 -12
- package/dist/server/richRender.js +3 -1
- package/dist/server/run.js +4 -0
- package/dist/server/runApi.d.ts +1 -1
- package/dist/server/runApi.js +4 -3
- package/dist/server/sqlStore.d.ts +89 -4
- package/dist/server/sqlStore.js +149 -23
- package/dist/server/surfacePage.d.ts +6 -2
- package/dist/server/surfacePage.js +142 -10
- package/dist/server/themes.d.ts +2 -0
- package/dist/server/themes.js +23 -3
- package/dist/server/types.d.ts +73 -11
- package/dist/server/types.js +42 -2
- package/dist/server/viewerRequest.d.ts +2 -0
- package/dist/server/viewerRequest.js +14 -0
- package/guide/topics/asks.md +50 -3
- package/guide/topics/feedback.md +89 -11
- package/guide/topics/html.md +5 -1
- package/guide/topics/http.md +2 -1
- package/guide/topics/knobs.md +2 -1
- package/guide/topics/surfaces.md +24 -8
- package/package.json +4 -13
- package/skills/launch-video/SKILL.md +1 -1
- package/skills/mockpit/SKILL.md +12 -4
- package/viewer/dist/index.html +8 -8
- package/dist/server/slots.d.ts +0 -14
- 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
|
|
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
|
-
##
|
|
261
|
+
## The design is the UI
|
|
258
262
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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.
|
|
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, "&").replace(/"/g, """);
|
|
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
|
+
}
|
package/dist/server/eventLog.js
CHANGED
|
@@ -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.
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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 {};
|