mockpit 1.3.0 → 3.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 (77) hide show
  1. package/README.md +66 -202
  2. package/bin/demoData.js +5 -5
  3. package/bin/initDesign.js +6 -6
  4. package/bin/mockpit.js +687 -192
  5. package/bin/poster.js +377 -0
  6. package/dist/mcp/server.js +110 -19
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +563 -41
  10. package/dist/server/apiViews.js +223 -74
  11. package/dist/server/app.d.ts +10 -2
  12. package/dist/server/app.js +3284 -782
  13. package/dist/server/appIcon.js +15 -5
  14. package/dist/server/appIconPng.js +3 -3
  15. package/dist/server/askNudges.js +1 -1
  16. package/dist/server/cdnInline.d.ts +8 -0
  17. package/dist/server/cdnInline.js +638 -0
  18. package/dist/server/codeWorker.js +2 -1
  19. package/dist/server/decisionList.d.ts +45 -0
  20. package/dist/server/decisionList.js +302 -0
  21. package/dist/server/decisions.d.ts +8 -8
  22. package/dist/server/decisions.js +18 -17
  23. package/dist/server/designGuide.d.ts +10 -6
  24. package/dist/server/designGuide.js +173 -78
  25. package/dist/server/embeds.d.ts +4 -1
  26. package/dist/server/embeds.js +57 -6
  27. package/dist/server/eventLog.d.ts +2 -2
  28. package/dist/server/eventLog.js +11 -11
  29. package/dist/server/events.d.ts +9 -6
  30. package/dist/server/feedbackBatch.d.ts +38 -16
  31. package/dist/server/feedbackBatch.js +121 -50
  32. package/dist/server/fills.d.ts +4 -0
  33. package/dist/server/fills.js +55 -1
  34. package/dist/server/guidance.d.ts +23 -0
  35. package/dist/server/guidance.js +71 -0
  36. package/dist/server/handoff.d.ts +4 -1
  37. package/dist/server/handoff.js +8 -2
  38. package/dist/server/icons.js +1 -1
  39. package/dist/server/index.js +49 -11
  40. package/dist/server/jump.d.ts +21 -0
  41. package/dist/server/jump.js +175 -0
  42. package/dist/server/knobs.d.ts +3 -3
  43. package/dist/server/knobs.js +6 -6
  44. package/dist/server/mcpHttp.d.ts +8 -1
  45. package/dist/server/mcpHttp.js +44 -7
  46. package/dist/server/mcpSpec.d.ts +2 -1
  47. package/dist/server/mcpSpec.js +172 -50
  48. package/dist/server/parts.js +9 -1
  49. package/dist/server/postSurfaces.js +3 -0
  50. package/dist/server/run.d.ts +1 -1
  51. package/dist/server/run.js +31 -17
  52. package/dist/server/runApi.d.ts +3 -3
  53. package/dist/server/runApi.js +34 -24
  54. package/dist/server/sqlStore.d.ts +117 -94
  55. package/dist/server/sqlStore.js +1089 -141
  56. package/dist/server/standings.d.ts +25 -0
  57. package/dist/server/standings.js +113 -0
  58. package/dist/server/surfacePage.d.ts +6 -9
  59. package/dist/server/surfacePage.js +361 -86
  60. package/dist/server/types.d.ts +252 -69
  61. package/dist/server/types.js +87 -69
  62. package/extensions/mockpit.js +245 -65
  63. package/guide/AGENT_SETUP.md +13 -1
  64. package/guide/packs/design-craft.md +34 -0
  65. package/guide/topics/asks.md +117 -69
  66. package/guide/topics/feedback.md +156 -119
  67. package/guide/topics/html.md +19 -32
  68. package/guide/topics/http.md +43 -40
  69. package/guide/topics/knobs.md +7 -10
  70. package/guide/topics/scripts.md +24 -35
  71. package/guide/topics/surfaces.md +50 -41
  72. package/opencode/mockpit.js +120 -0
  73. package/opencode/package.json +8 -0
  74. package/package.json +4 -6
  75. package/skills/launch-video/SKILL.md +12 -12
  76. package/skills/mockpit/SKILL.md +35 -23
  77. package/viewer/dist/index.html +9 -8
@@ -0,0 +1,638 @@
1
+ // Bakes what an html surface links from the CDN allowlist into its document: fonts as
2
+ // data: URLs, stylesheets and classic scripts inline. Nothing a sandboxed frame fetches
3
+ // is cached or shared in Chrome, while its version-pinned document comes from disk
4
+ // cache, so a byte in the document is paid once and a byte behind a link on every load
5
+ // of every frame.
6
+ //
7
+ // Everything here degrades to the link the agent wrote, which still works: the CSP
8
+ // allows the same hosts. What may not happen is a doc with a passing failure kept for a
9
+ // year, so `complete` says whether every fetch came back; a document that isn't complete
10
+ // isn't stored or served as immutable. Leaving a tag because of what it is (a module, too
11
+ // large, past the cap) gives the same answer every time, so it doesn't count.
12
+ //
13
+ // Runtime-agnostic: runs on the Worker. Works on the document as text, since there is
14
+ // no DOM here, and only reaches what the document itself would have loaded.
15
+ import { CDN_ALLOWLIST, checkCdnUrl } from "./cdn.js";
16
+ // What one document may grow by. The largest common single-file libraries (three, d3,
17
+ // a tailwind build) fit under it with a font or two, while a document stays small enough
18
+ // that a render cache of 32 MB still holds dozens.
19
+ export const MAX_INLINE_BYTES = 1024 * 1024;
20
+ // A held-up CDN must not hold the frame up for long: past the document's deadline the
21
+ // render goes out with links, and the fetch carries on to fill the cache for the next one.
22
+ const FETCH_TIMEOUT_MS = 3000;
23
+ const DOC_DEADLINE_MS = 4000;
24
+ // A CDN that is down is asked again after this, not by every frame on the screen.
25
+ const RETRY_AFTER_MS = 60_000;
26
+ const MAX_REDIRECTS = 3;
27
+ const MAX_IMPORT_DEPTH = 3;
28
+ // Beside the 32 MB render cache, inside the Worker's 128 MB isolate.
29
+ const MAX_CACHE_BYTES = 24 * 1024 * 1024;
30
+ const MAX_FAILED_URLS = 512;
31
+ // What an answer with no bytes to keep (too big, missing) counts as, so a page naming
32
+ // thousands of them still meets the cache's limit.
33
+ const EMPTY_ENTRY_BYTES = 512;
34
+ // Fetches started past a document's deadline only fill the cache for the next render, and
35
+ // a page may link any number of files.
36
+ const MAX_LATE_FETCHES = 8;
37
+ // Google Fonts picks its CSS by User-Agent. Always one modern one: every browser that
38
+ // runs the viewer takes variable woff2 and unicode-range, the answer is the smallest of
39
+ // them, and the document can't vary by who asked, since its cache key doesn't.
40
+ const FONT_CSS_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
41
+ function toBase64(bytes) {
42
+ const parts = [];
43
+ for (let i = 0; i < bytes.length; i += 0x8000) {
44
+ parts.push(String.fromCharCode(...bytes.subarray(i, i + 0x8000)));
45
+ }
46
+ return btoa(parts.join(""));
47
+ }
48
+ async function readCapped(res, max) {
49
+ if (Number(res.headers.get("content-length")) > max) {
50
+ await res.body?.cancel();
51
+ return null;
52
+ }
53
+ if (!res.body)
54
+ return new Uint8Array();
55
+ const reader = res.body.getReader();
56
+ const chunks = [];
57
+ let total = 0;
58
+ for (;;) {
59
+ const { done, value } = await reader.read();
60
+ if (done)
61
+ break;
62
+ total += value.length;
63
+ if (total > max) {
64
+ await reader.cancel();
65
+ return null;
66
+ }
67
+ chunks.push(value);
68
+ }
69
+ const out = new Uint8Array(total);
70
+ let at = 0;
71
+ for (const c of chunks) {
72
+ out.set(c, at);
73
+ at += c.length;
74
+ }
75
+ return out;
76
+ }
77
+ // Each hop is checked against the allowlist before it is followed, so a CDN can't
78
+ // walk the server to a host the frame itself would be refused.
79
+ async function fetchFromCdn(url, signal) {
80
+ let at = url;
81
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
82
+ const checked = checkCdnUrl(at, "url");
83
+ if ("error" in checked)
84
+ return { kind: "failed" };
85
+ const headers = new URL(at).hostname === "fonts.googleapis.com" ? { "user-agent": FONT_CSS_UA } : {};
86
+ const res = await fetch(checked.url, { redirect: "manual", signal, headers });
87
+ if (res.status >= 300 && res.status < 400) {
88
+ await res.body?.cancel();
89
+ const to = res.headers.get("location");
90
+ if (!to)
91
+ return { kind: "failed" };
92
+ at = new URL(to, at).href;
93
+ continue;
94
+ }
95
+ if (!res.ok) {
96
+ await res.body?.cancel();
97
+ const final = res.status >= 400 && res.status < 500 && ![408, 425, 429].includes(res.status);
98
+ return { kind: final ? "missing" : "failed" };
99
+ }
100
+ const bytes = await readCapped(res, MAX_INLINE_BYTES);
101
+ if (!bytes)
102
+ return { kind: "too-big" };
103
+ return {
104
+ kind: "ok",
105
+ url: at,
106
+ contentType: (res.headers.get("content-type") ?? "").toLowerCase(),
107
+ bytes,
108
+ };
109
+ }
110
+ return { kind: "failed" };
111
+ }
112
+ const decodeAttr = (s) => s
113
+ .replace(/&amp;|&#38;|&#x26;/gi, "&")
114
+ .replace(/&quot;|&#34;/gi, '"')
115
+ .replace(/&#39;|&apos;/gi, "'");
116
+ const isSpace = (c) => c === " " || c === "\n" || c === "\t" || c === "\r" || c === "\f";
117
+ function readTag(doc, start) {
118
+ let i = start + 1;
119
+ const nameStart = i;
120
+ while (i < doc.length && !isSpace(doc[i]) && doc[i] !== ">" && doc[i] !== "/")
121
+ i++;
122
+ const name = doc.slice(nameStart, i).toLowerCase();
123
+ const attrs = new Map();
124
+ while (i < doc.length) {
125
+ while (i < doc.length && (isSpace(doc[i]) || doc[i] === "/"))
126
+ i++;
127
+ if (doc[i] === ">")
128
+ return { name, attrs, end: i + 1 };
129
+ const keyStart = i;
130
+ while (i < doc.length && !isSpace(doc[i]) && !"=>/".includes(doc[i]))
131
+ i++;
132
+ const key = doc.slice(keyStart, i).toLowerCase();
133
+ while (i < doc.length && isSpace(doc[i]))
134
+ i++;
135
+ let value = "";
136
+ if (doc[i] === "=") {
137
+ i++;
138
+ while (i < doc.length && isSpace(doc[i]))
139
+ i++;
140
+ const q = doc[i];
141
+ if (q === '"' || q === "'") {
142
+ const close = doc.indexOf(q, i + 1);
143
+ if (close < 0)
144
+ return null;
145
+ value = doc.slice(i + 1, close);
146
+ i = close + 1;
147
+ }
148
+ else {
149
+ const valueStart = i;
150
+ while (i < doc.length && !isSpace(doc[i]) && doc[i] !== ">")
151
+ i++;
152
+ value = doc.slice(valueStart, i);
153
+ }
154
+ }
155
+ if (key)
156
+ attrs.set(key, decodeAttr(value));
157
+ else
158
+ i++;
159
+ }
160
+ return null;
161
+ }
162
+ const closeOf = (doc, name, from) => {
163
+ const re = new RegExp(`</${name}\\s*>`, "gi");
164
+ re.lastIndex = from;
165
+ const m = re.exec(doc);
166
+ return m ? { start: m.index, end: m.index + m[0].length } : null;
167
+ };
168
+ // The elements whose contents the browser doesn't parse as markup, or doesn't load.
169
+ const OPAQUE = new Set([
170
+ "template",
171
+ "textarea",
172
+ "title",
173
+ "noscript",
174
+ "xmp",
175
+ "iframe",
176
+ "noembed",
177
+ "noframes",
178
+ ]);
179
+ // A tag with anything else on it (an id a script looks for, an onload, a title, `disabled`)
180
+ // means something an inline copy wouldn't.
181
+ const STYLESHEET_ATTRS = new Set([
182
+ "rel",
183
+ "href",
184
+ "type",
185
+ "media",
186
+ "crossorigin",
187
+ "integrity",
188
+ "referrerpolicy",
189
+ ]);
190
+ const SCRIPT_ATTRS = new Set([
191
+ "src",
192
+ "type",
193
+ "async",
194
+ "defer",
195
+ "charset",
196
+ "crossorigin",
197
+ "integrity",
198
+ "referrerpolicy",
199
+ ]);
200
+ const onlyThese = (attrs, known) => [...attrs.keys()].every((k) => known.has(k));
201
+ const absolutize = (raw, base) => {
202
+ const url = raw.trim();
203
+ if (!base && !/^(https:)?\/\//i.test(url))
204
+ return null;
205
+ try {
206
+ return new URL(url, base ?? "https://x/").href;
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ };
212
+ // Whether a URL is one the frame would have fetched from the allowlist.
213
+ const onCdn = (raw, base) => {
214
+ const abs = absolutize(raw, base);
215
+ if (!abs)
216
+ return null;
217
+ const checked = checkCdnUrl(abs, "url");
218
+ return "url" in checked ? checked.url : null;
219
+ };
220
+ // --- subresource integrity: an inline copy mustn't lose what the tag promised ---
221
+ const SRI_ALGOS = {
222
+ sha256: "SHA-256",
223
+ sha384: "SHA-384",
224
+ sha512: "SHA-512",
225
+ };
226
+ async function passesIntegrity(integrity, bytes) {
227
+ if (!integrity?.trim())
228
+ return true;
229
+ for (const token of integrity.trim().split(/\s+/)) {
230
+ const [algo, want] = [token.slice(0, token.indexOf("-")), token.slice(token.indexOf("-") + 1)];
231
+ const name = SRI_ALGOS[algo];
232
+ if (!name)
233
+ continue;
234
+ const digest = await crypto.subtle.digest(name, new Uint8Array(bytes));
235
+ if (toBase64(new Uint8Array(digest)) === want.split("?")[0])
236
+ return true;
237
+ }
238
+ return false;
239
+ }
240
+ // --- text that goes back into the document ---
241
+ // `<!--` opens a parser state in which the closing tag may not close, and `\x3c` reads the
242
+ // same in a string, a template or a regex. `<\/` is the usual way past an early close.
243
+ const safeScript = (js) => js
244
+ .replace(/\n?\/\/[#@] sourceMappingURL=[^\n]*\s*$/, "")
245
+ .replace(/<\/script/gi, "<\\/script")
246
+ .replace(/<!--/g, "\\x3c!--");
247
+ const safeStyle = (css) => css.replace(/\/\*# sourceMappingURL=[^*]*\*\/\s*$/, "").replace(/<\/style/gi, "<\\/style");
248
+ // A script that finds its own URL or loads siblings relative to it would look in the wrong
249
+ // place once inline, so it keeps its link.
250
+ const needsItsUrl = (js) => /\bcurrentScript\b|\bimport\s*\(/.test(js);
251
+ // --- which subsets of a font ---
252
+ // The characters a document's own text could need. Numeric entities count; named ones are
253
+ // nearly all Latin, which is always inlined anyway.
254
+ function charsOf(text) {
255
+ const out = new Set();
256
+ for (const ch of text)
257
+ out.add(ch.codePointAt(0));
258
+ for (const m of text.matchAll(/&#(x[0-9a-f]+|\d+);/gi)) {
259
+ const v = m[1];
260
+ out.add(v[0] === "x" || v[0] === "X" ? parseInt(v.slice(1), 16) : parseInt(v, 10));
261
+ }
262
+ return out;
263
+ }
264
+ // One `U+0041-005A, U+0100` list as [from, to] pairs. Wildcards (`U+4??`) count as their span.
265
+ function parseUnicodeRange(raw) {
266
+ const out = [];
267
+ for (const part of raw.split(",")) {
268
+ const m = /^\s*U\+([0-9a-f?]+)(?:-([0-9a-f]+))?\s*$/i.exec(part);
269
+ if (!m)
270
+ continue;
271
+ const from = parseInt(m[1].replace(/\?/g, "0"), 16);
272
+ const to = m[2] ? parseInt(m[2], 16) : parseInt(m[1].replace(/\?/g, "f"), 16);
273
+ out.push([from, to]);
274
+ }
275
+ return out;
276
+ }
277
+ // A family's subsets are inlined when the text needs them, and Latin always, because a page
278
+ // builds text in script that its markup doesn't show: numbers, names, a date. The rest stay
279
+ // in the stylesheet as links. The browser fetches one of those only if a character in its
280
+ // range turns up, so what isn't inlined still renders right and costs nothing until then.
281
+ function subsetWanted(range, chars) {
282
+ if (!range)
283
+ return true;
284
+ const spans = parseUnicodeRange(range);
285
+ const has = (cp) => spans.some(([a, b]) => cp >= a && cp <= b);
286
+ if (has(0x41))
287
+ return true;
288
+ for (const cp of chars)
289
+ if (cp > 0x7f && has(cp))
290
+ return true;
291
+ return false;
292
+ }
293
+ // --- stylesheets ---
294
+ const decl = (face, name) => new RegExp(`(?:^|[;{\\s])${name}\\s*:\\s*([^;}]+)`, "i").exec(face)?.[1]?.trim();
295
+ // In what one src list offers, the one file worth inlining: woff2, else woff.
296
+ function fontFileIn(src, base) {
297
+ const entries = [...src.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)([^,]*)/gi)].map((m) => ({ raw: m[1] ?? m[2] ?? m[3] ?? "", rest: m[4] ?? "" }));
298
+ for (const format of ["woff2", "woff"]) {
299
+ const hit = entries.find((e) => new RegExp(`format\\(\\s*["']?${format}["']?\\s*\\)`, "i").test(e.rest) ||
300
+ new RegExp(`\\.${format}(\\?|#|$)`, "i").test(e.raw));
301
+ const url = hit && onCdn(hit.raw, base);
302
+ if (url)
303
+ return { url, format };
304
+ }
305
+ return null;
306
+ }
307
+ const FONT_MAGIC = { woff2: "wOF2", woff: "wOFF" };
308
+ export function createCdnInliner() {
309
+ const cache = new Map();
310
+ let cacheBytes = 0;
311
+ const inFlight = new Map();
312
+ const failedAt = new Map();
313
+ const weigh = (got) => (got.kind === "ok" ? got.bytes.length : 0) + EMPTY_ENTRY_BYTES;
314
+ const remember = (url, got) => {
315
+ cache.set(url, got);
316
+ cacheBytes += weigh(got);
317
+ while (cacheBytes > MAX_CACHE_BYTES && cache.size > 1) {
318
+ const oldest = cache.keys().next().value;
319
+ const drop = cache.get(oldest);
320
+ cacheBytes -= drop ? weigh(drop) : 0;
321
+ cache.delete(oldest);
322
+ }
323
+ };
324
+ const start = (url) => {
325
+ const running = inFlight.get(url);
326
+ if (running)
327
+ return running;
328
+ const task = fetchFromCdn(url, AbortSignal.timeout(FETCH_TIMEOUT_MS))
329
+ .catch(() => ({ kind: "failed" }))
330
+ .then((got) => {
331
+ inFlight.delete(url);
332
+ if (got.kind === "failed") {
333
+ failedAt.set(url, Date.now());
334
+ if (failedAt.size > MAX_FAILED_URLS) {
335
+ failedAt.delete(failedAt.keys().next().value);
336
+ }
337
+ }
338
+ else
339
+ remember(url, got);
340
+ return got;
341
+ });
342
+ inFlight.set(url, task);
343
+ return task;
344
+ };
345
+ const get = (url, deadline) => {
346
+ const held = cache.get(url);
347
+ if (held) {
348
+ cache.delete(url);
349
+ cache.set(url, held);
350
+ return Promise.resolve(held);
351
+ }
352
+ const failed = failedAt.get(url);
353
+ if (failed !== undefined) {
354
+ if (Date.now() - failed < RETRY_AFTER_MS)
355
+ return Promise.resolve({ kind: "failed" });
356
+ failedAt.delete(url);
357
+ }
358
+ const left = deadline - Date.now();
359
+ if (left <= 0) {
360
+ if (inFlight.size < MAX_LATE_FETCHES)
361
+ void start(url);
362
+ return Promise.resolve({ kind: "failed" });
363
+ }
364
+ let timer;
365
+ return Promise.race([
366
+ start(url).finally(() => clearTimeout(timer)),
367
+ new Promise((resolve) => {
368
+ timer = setTimeout(() => resolve({ kind: "failed" }), left);
369
+ }),
370
+ ]);
371
+ };
372
+ async function inline(doc, text) {
373
+ const deadline = Date.now() + DOC_DEADLINE_MS;
374
+ const chars = charsOf(text);
375
+ let budget = MAX_INLINE_BYTES;
376
+ let complete = true;
377
+ const fetchOr = async (url) => {
378
+ const got = await get(url, deadline);
379
+ if (got.kind === "failed")
380
+ complete = false;
381
+ return got;
382
+ };
383
+ // One stylesheet's text with its imports followed and its fonts baked in. Every
384
+ // relative URL moves to absolute first: inline, it would resolve against the document.
385
+ // An @import that stays has to come before every rule or the browser ignores it, so
386
+ // those go first in the result.
387
+ async function rewriteCss(css, base, depth) {
388
+ const absolute = base
389
+ ? css.replace(/\/\*[\s\S]*?\*\/|url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/gi, (m, a, b, c) => {
390
+ if (m.startsWith("/*"))
391
+ return m;
392
+ const raw = a ?? b ?? c ?? "";
393
+ if (!raw || /^(data:|#|[a-z][a-z0-9+.-]*:)/i.test(raw))
394
+ return m;
395
+ const abs = absolutize(raw, base);
396
+ return abs ? `url("${abs}")` : m;
397
+ })
398
+ : css;
399
+ let imports = "";
400
+ let importedBody = "";
401
+ let rest = "";
402
+ let last = 0;
403
+ const importRe = /\/\*[\s\S]*?\*\/|@import\s+(?:url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)|"([^"]*)"|'([^']*)')\s*([^;]*);/gi;
404
+ for (const m of absolute.matchAll(importRe)) {
405
+ if (m[0].startsWith("/*"))
406
+ continue;
407
+ const at = m.index;
408
+ rest += absolute.slice(last, at);
409
+ last = at + m[0].length;
410
+ const raw = m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? "";
411
+ const url = onCdn(raw, base);
412
+ // A media or layer qualifier would need wrapping, so it keeps its link.
413
+ const qualified = !!m[6]?.trim();
414
+ if (!url || qualified || depth >= MAX_IMPORT_DEPTH) {
415
+ imports += `${m[0]}\n`;
416
+ continue;
417
+ }
418
+ const inlined = await fetchStylesheet(url, undefined, depth + 1);
419
+ if (inlined === null) {
420
+ imports += `${m[0]}\n`;
421
+ continue;
422
+ }
423
+ imports += inlined.imports;
424
+ importedBody += `${inlined.body}\n`;
425
+ }
426
+ rest += absolute.slice(last);
427
+ return { imports, body: `${importedBody}${await rewriteFaces(rest, base)}` };
428
+ }
429
+ // The same file named once per weight is one variable font: Google Fonts answers
430
+ // `wght@400;600` with two faces and one URL. Baked in twice it would cost twice, so
431
+ // they become one face spanning the weights.
432
+ async function rewriteFaces(css, base) {
433
+ const faceRe = /\/\*[\s\S]*?\*\/|@font-face\s*\{[^}]*\}/gi;
434
+ const faces = [...css.matchAll(faceRe)].filter((m) => !m[0].startsWith("/*"));
435
+ if (faces.length === 0)
436
+ return css;
437
+ const groups = new Map();
438
+ for (const m of faces) {
439
+ const src = decl(m[0], "src");
440
+ const file = src && fontFileIn(src, base);
441
+ const weight = Number(decl(m[0], "font-weight"));
442
+ if (!file || !Number.isFinite(weight))
443
+ continue;
444
+ const key = [
445
+ file.url,
446
+ decl(m[0], "font-family"),
447
+ decl(m[0], "font-style"),
448
+ decl(m[0], "font-stretch"),
449
+ decl(m[0], "unicode-range"),
450
+ ].join("|");
451
+ groups.set(key, [...(groups.get(key) ?? []), m.index]);
452
+ }
453
+ const weightsAt = new Map();
454
+ const mergedAway = new Set();
455
+ for (const at of groups.values()) {
456
+ if (at.length < 2)
457
+ continue;
458
+ const weights = at.map((i) => Number(decl(css.slice(i, css.indexOf("}", i)), "font-weight")));
459
+ weightsAt.set(at[0], `${Math.min(...weights)} ${Math.max(...weights)}`);
460
+ for (const i of at.slice(1))
461
+ mergedAway.add(i);
462
+ }
463
+ let out = "";
464
+ let last = 0;
465
+ for (const m of faces) {
466
+ out += css.slice(last, m.index);
467
+ last = m.index + m[0].length;
468
+ if (mergedAway.has(m.index))
469
+ continue;
470
+ let face = m[0];
471
+ const span = weightsAt.get(m.index);
472
+ if (span)
473
+ face = face.replace(/font-weight\s*:\s*[^;}]+/i, `font-weight: ${span}`);
474
+ const src = decl(face, "src");
475
+ const file = src && fontFileIn(src, base);
476
+ if (file && subsetWanted(decl(face, "unicode-range"), chars)) {
477
+ const got = await fetchOr(file.url);
478
+ if (got.kind === "ok") {
479
+ const magic = String.fromCharCode(...got.bytes.subarray(0, 4));
480
+ const size = Math.ceil((got.bytes.length * 4) / 3) + 64;
481
+ if (magic === FONT_MAGIC[file.format] && size <= budget) {
482
+ budget -= size;
483
+ const data = `url(data:font/${file.format};base64,${toBase64(got.bytes)}) format("${file.format}")`;
484
+ // The list may run to several lines; all of it goes, for the one file kept.
485
+ face = face.replace(/(^|[;{\s])src\s*:[^;}]+/i, `$1src: ${data}`);
486
+ }
487
+ }
488
+ }
489
+ out += face;
490
+ }
491
+ return out + css.slice(last);
492
+ }
493
+ async function fetchStylesheet(url, integrity, depth) {
494
+ const got = await fetchOr(url);
495
+ if (got.kind !== "ok" || !got.contentType.includes("text/css"))
496
+ return null;
497
+ if (!(await passesIntegrity(integrity, got.bytes)))
498
+ return null;
499
+ if (got.bytes.length > budget)
500
+ return null;
501
+ budget -= got.bytes.length;
502
+ return rewriteCss(new TextDecoder().decode(got.bytes), got.url, depth);
503
+ }
504
+ let out = "";
505
+ let copied = 0;
506
+ let deferred = "";
507
+ // Deferred and module scripts run in document order once the document has parsed. An
508
+ // inlined one runs at the end of the body, ahead of any that kept its link, so once one
509
+ // of them stays, those after it stay too: a plugin must not run before its library.
510
+ let orderHeld = false;
511
+ const splice = (from, to, withText) => {
512
+ out += doc.slice(copied, from) + withText;
513
+ copied = to;
514
+ };
515
+ let i = 0;
516
+ while (i < doc.length) {
517
+ const lt = doc.indexOf("<", i);
518
+ if (lt < 0)
519
+ break;
520
+ if (doc.startsWith("<!--", lt)) {
521
+ const close = doc.indexOf("-->", lt + 4);
522
+ if (close < 0)
523
+ break;
524
+ i = close + 3;
525
+ continue;
526
+ }
527
+ if (!/[a-z]/i.test(doc[lt + 1] ?? "")) {
528
+ i = lt + 1;
529
+ continue;
530
+ }
531
+ const tag = readTag(doc, lt);
532
+ if (!tag)
533
+ break;
534
+ i = tag.end;
535
+ if (tag.name === "script") {
536
+ const close = closeOf(doc, "script", tag.end);
537
+ if (!close)
538
+ break;
539
+ i = close.end;
540
+ const src = tag.attrs.get("src");
541
+ const url = src ? onCdn(src) : null;
542
+ const type = (tag.attrs.get("type") ?? "").trim().toLowerCase();
543
+ const classic = !type || /^(text|application)\/(java|ecma)script$/.test(type);
544
+ const ordered = classic
545
+ ? !!src && tag.attrs.has("defer") && !tag.attrs.has("async")
546
+ : type === "module" && !tag.attrs.has("async");
547
+ const stays = () => {
548
+ if (ordered)
549
+ orderHeld = true;
550
+ };
551
+ if (!url || !classic || !onlyThese(tag.attrs, SCRIPT_ATTRS)) {
552
+ stays();
553
+ continue;
554
+ }
555
+ if (ordered && orderHeld)
556
+ continue;
557
+ const got = await fetchOr(url);
558
+ const fits = got.kind === "ok" &&
559
+ /javascript|ecmascript/.test(got.contentType) &&
560
+ (await passesIntegrity(tag.attrs.get("integrity"), got.bytes)) &&
561
+ got.bytes.length <= budget;
562
+ const js = got.kind === "ok" && fits ? new TextDecoder().decode(got.bytes) : null;
563
+ if (got.kind !== "ok" || js === null || needsItsUrl(js)) {
564
+ stays();
565
+ continue;
566
+ }
567
+ budget -= got.bytes.length;
568
+ const inlined = `<script>${safeScript(js)}</script>`;
569
+ // An inline script can't wait, so what was deferred goes where deferred scripts
570
+ // run: after the rest of the document has parsed.
571
+ if (tag.attrs.has("defer") || tag.attrs.has("async")) {
572
+ deferred += `${inlined}\n`;
573
+ splice(lt, close.end, "");
574
+ }
575
+ else
576
+ splice(lt, close.end, inlined);
577
+ continue;
578
+ }
579
+ if (tag.name === "style") {
580
+ const close = closeOf(doc, "style", tag.end);
581
+ if (!close)
582
+ break;
583
+ i = close.end;
584
+ const type = (tag.attrs.get("type") ?? "").trim().toLowerCase();
585
+ if (type && type !== "text/css")
586
+ continue;
587
+ const css = doc.slice(tag.end, close.start);
588
+ if (!CDN_ALLOWLIST.some((o) => css.includes(o.slice(6))))
589
+ continue;
590
+ const { imports, body } = await rewriteCss(css, undefined, 0);
591
+ splice(tag.end, close.start, safeStyle(imports + body));
592
+ continue;
593
+ }
594
+ if (tag.name === "link") {
595
+ const rel = (tag.attrs.get("rel") ?? "").trim().toLowerCase();
596
+ const href = tag.attrs.get("href");
597
+ const url = href ? onCdn(href) : null;
598
+ if (!url)
599
+ continue;
600
+ // A connection opened ahead for a font that's now in the document: every frame
601
+ // would still pay for it, and a link left in place loses only a round trip.
602
+ if (rel === "preconnect" || rel === "dns-prefetch") {
603
+ splice(lt, tag.end, "");
604
+ continue;
605
+ }
606
+ if (rel !== "stylesheet" || !onlyThese(tag.attrs, STYLESHEET_ATTRS))
607
+ continue;
608
+ const got = await fetchStylesheet(url, tag.attrs.get("integrity"), 0);
609
+ if (!got)
610
+ continue;
611
+ const media = tag.attrs.get("media");
612
+ const attr = media
613
+ ? ` media="${media.replace(/&/g, "&amp;").replace(/"/g, "&quot;")}"`
614
+ : "";
615
+ splice(lt, tag.end, `<style${attr}>${safeStyle(got.imports + got.body)}</style>`);
616
+ continue;
617
+ }
618
+ if (OPAQUE.has(tag.name)) {
619
+ const close = closeOf(doc, tag.name, tag.end);
620
+ if (!close)
621
+ break;
622
+ i = close.end;
623
+ }
624
+ }
625
+ if (copied === 0 && !deferred)
626
+ return { doc, complete };
627
+ let result = out + doc.slice(copied);
628
+ if (deferred) {
629
+ const bodyEnd = result.lastIndexOf("</body>");
630
+ result =
631
+ bodyEnd < 0
632
+ ? result + deferred
633
+ : `${result.slice(0, bodyEnd)}${deferred}${result.slice(bodyEnd)}`;
634
+ }
635
+ return { doc: result, complete };
636
+ }
637
+ return { inline };
638
+ }
@@ -25,7 +25,8 @@ const PRELUDE = `(function (call, out) {
25
25
  };
26
26
  globalThis.mockpit = Object.freeze({
27
27
  guide: fn("guide"), read: fn("read"), publish: fn("publish"), ask: fn("ask"),
28
- feedback: fn("feedback"), say: fn("say"), export: fn("export"), upload: fn("upload"),
28
+ feedback: fn("feedback"), say: fn("say"), move: fn("move"), edit: fn("edit"), export: fn("export"),
29
+ upload: fn("upload"),
29
30
  });
30
31
  globalThis.print = print;
31
32
  globalThis.console = Object.freeze({ log: print, info: print, warn: print, error: print });
@@ -0,0 +1,45 @@
1
+ import { type DecisionSources, type Design } from "./types.ts";
2
+ export interface DecisionParts {
3
+ asked: string;
4
+ chose?: string;
5
+ kept?: string;
6
+ not?: string;
7
+ words?: string;
8
+ }
9
+ export interface DecisionLine {
10
+ id: string;
11
+ design: string;
12
+ title: string;
13
+ state?: string;
14
+ text: string;
15
+ parts?: DecisionParts;
16
+ edited?: true;
17
+ archived?: true;
18
+ at?: string;
19
+ }
20
+ export type DecisionEdits = Record<string, {
21
+ basis: string;
22
+ text?: string;
23
+ removed?: true;
24
+ }>;
25
+ export declare function parseDecisionEdits(raw: string | null): DecisionEdits;
26
+ export declare function deriveDecisionLines(designs: Design[], sources: DecisionSources): (DecisionLine & {
27
+ basis: string;
28
+ })[];
29
+ export declare function applyDecisionEdits(lines: (DecisionLine & {
30
+ basis: string;
31
+ })[], edits: DecisionEdits): DecisionLine[];
32
+ export interface AgentDecisionLine {
33
+ design: string;
34
+ text: string;
35
+ here?: true;
36
+ edited?: true;
37
+ archived?: true;
38
+ }
39
+ export interface AgentDecisionList {
40
+ total: number;
41
+ lines: AgentDecisionLine[];
42
+ more?: string;
43
+ }
44
+ export declare function agentDecisionList(lines: DecisionLine[], here?: string): AgentDecisionList;
45
+ export declare function fullAgentDecisionList(lines: DecisionLine[]): AgentDecisionList;