mockpit 0.0.0-stage → 1.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 (82) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +232 -2
  3. package/bin/demoData.js +132 -0
  4. package/bin/initDesign.js +846 -0
  5. package/bin/mockpit.js +1830 -0
  6. package/bin/serveUrl.d.ts +1 -0
  7. package/bin/serveUrl.js +7 -0
  8. package/dist/mcp/server.d.ts +2 -0
  9. package/dist/mcp/server.js +230 -0
  10. package/dist/server/apiViews.d.ts +329 -0
  11. package/dist/server/apiViews.js +135 -0
  12. package/dist/server/app.d.ts +53 -0
  13. package/dist/server/app.js +3112 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +16 -0
  16. package/dist/server/cdn.d.ts +6 -0
  17. package/dist/server/cdn.js +33 -0
  18. package/dist/server/codeRunner.d.ts +5 -0
  19. package/dist/server/codeRunner.js +89 -0
  20. package/dist/server/codeWorker.d.ts +1 -0
  21. package/dist/server/codeWorker.js +196 -0
  22. package/dist/server/designGuide.d.ts +16 -0
  23. package/dist/server/designGuide.js +311 -0
  24. package/dist/server/events.d.ts +53 -0
  25. package/dist/server/events.js +14 -0
  26. package/dist/server/feedbackBatch.d.ts +43 -0
  27. package/dist/server/feedbackBatch.js +98 -0
  28. package/dist/server/icons.d.ts +50 -0
  29. package/dist/server/icons.js +225 -0
  30. package/dist/server/index.d.ts +1 -0
  31. package/dist/server/index.js +64 -0
  32. package/dist/server/kits.d.ts +36 -0
  33. package/dist/server/kits.js +279 -0
  34. package/dist/server/knobs.d.ts +15 -0
  35. package/dist/server/knobs.js +333 -0
  36. package/dist/server/mcpHttp.d.ts +53 -0
  37. package/dist/server/mcpHttp.js +143 -0
  38. package/dist/server/mcpSpec.d.ts +191 -0
  39. package/dist/server/mcpSpec.js +402 -0
  40. package/dist/server/parts.d.ts +31 -0
  41. package/dist/server/parts.js +338 -0
  42. package/dist/server/postMarkdown.d.ts +10 -0
  43. package/dist/server/postMarkdown.js +271 -0
  44. package/dist/server/postSurfaces.d.ts +40 -0
  45. package/dist/server/postSurfaces.js +407 -0
  46. package/dist/server/public.d.ts +5 -0
  47. package/dist/server/public.js +5 -0
  48. package/dist/server/push.d.ts +45 -0
  49. package/dist/server/push.js +234 -0
  50. package/dist/server/richRender.d.ts +15 -0
  51. package/dist/server/richRender.js +392 -0
  52. package/dist/server/run.d.ts +82 -0
  53. package/dist/server/run.js +290 -0
  54. package/dist/server/runApi.d.ts +6 -0
  55. package/dist/server/runApi.js +82 -0
  56. package/dist/server/slots.d.ts +14 -0
  57. package/dist/server/slots.js +71 -0
  58. package/dist/server/sqlStore.d.ts +119 -0
  59. package/dist/server/sqlStore.js +954 -0
  60. package/dist/server/sqliteStorage.d.ts +2 -0
  61. package/dist/server/sqliteStorage.js +90 -0
  62. package/dist/server/surfacePage.d.ts +48 -0
  63. package/dist/server/surfacePage.js +1099 -0
  64. package/dist/server/themes.d.ts +48 -0
  65. package/dist/server/themes.js +305 -0
  66. package/dist/server/types.d.ts +599 -0
  67. package/dist/server/types.js +299 -0
  68. package/dist/workers/public.d.ts +1 -0
  69. package/dist/workers/public.js +3 -0
  70. package/extensions/mockpit.js +703 -0
  71. package/guide/AGENT_SETUP.md +23 -0
  72. package/guide/topics/asks.md +87 -0
  73. package/guide/topics/feedback.md +127 -0
  74. package/guide/topics/html.md +175 -0
  75. package/guide/topics/http.md +76 -0
  76. package/guide/topics/knobs.md +72 -0
  77. package/guide/topics/scripts.md +129 -0
  78. package/guide/topics/surfaces.md +100 -0
  79. package/package.json +165 -4
  80. package/skills/launch-video/SKILL.md +159 -0
  81. package/skills/mockpit/SKILL.md +40 -0
  82. package/viewer/dist/index.html +1922 -0
@@ -0,0 +1,1099 @@
1
+ import { CDN_ALLOWLIST, checkCdnUrl } from "./cdn.js";
2
+ import { CORE_CSS, KITS, kitAssets } from "./kits.js";
3
+ import { expandIcons } from "./icons.js";
4
+ import { schemeCss, themeById, tokenThemeCss, viewerThemeCss, } from "./themes.js";
5
+ // The kit's two custom SVG accent ramps (teal, coral) aren't in the theme
6
+ // palette, so they carry their own light/dark values. Like the theme tokens
7
+ // they pin to a forced mode (no media query) when one is given, else flip with
8
+ // the OS — kept in sync via the shared schemeCss. Dark overrides only bg/text;
9
+ // the line color is shared, so it's repeated in both maps.
10
+ const KIT_ACCENTS_LIGHT = {
11
+ "c-teal-bg": "#e1f4f1",
12
+ "c-teal-line": "#1fa996",
13
+ "c-teal-text": "#0c6e62",
14
+ "c-coral-bg": "#fdece5",
15
+ "c-coral-line": "#e8835e",
16
+ "c-coral-text": "#a44f28",
17
+ };
18
+ const KIT_ACCENTS_DARK = {
19
+ ...KIT_ACCENTS_LIGHT,
20
+ "c-teal-bg": "rgba(31, 169, 150, 0.18)",
21
+ "c-teal-text": "#6fd0c2",
22
+ "c-coral-bg": "rgba(232, 131, 94, 0.18)",
23
+ "c-coral-text": "#f0a987",
24
+ };
25
+ const kitAccentCss = (mode) => schemeCss(KIT_ACCENTS_LIGHT, KIT_ACCENTS_DARK, mode);
26
+ // Force the document's used color-scheme so the UA-painted canvas, scrollbars,
27
+ // and native form controls follow the same scheme as the theme vars (the vars
28
+ // alone don't drive those). Pinned frames get a single scheme; unpinned/direct
29
+ // loads opt into both schemes so the browser can resolve the user's system mode.
30
+ const colorSchemeCss = (mode) => `:root{color-scheme:${mode ?? "light dark"}}`;
31
+ const cdns = CDN_ALLOWLIST.join(" ");
32
+ // `origin` is the server's own origin, added to img/media so uploaded assets
33
+ // (served at <origin>/a/:id) embed by URL. It is needed because the iframe runs
34
+ // at an opaque origin (sandbox without allow-same-origin), so `'self'` matches
35
+ // nothing, and a local http origin isn't covered by the `https:` source.
36
+ // `${origin}/asset/` is the fixed, content-hashed path the bridge script and the
37
+ // static stylesheets are served from (see registerAsset). It is deliberately a
38
+ // single directory of server-authored, workspace-data-free files: nothing an
39
+ // agent or a user can write is ever reachable under it, so widening script-src
40
+ // to it grants the sandbox no capability beyond running our own bridge.
41
+ function buildCsp(origin) {
42
+ const assets = `${origin}${STATIC_ASSET_PREFIX}`;
43
+ return [
44
+ `default-src 'none'`,
45
+ `script-src 'unsafe-inline' ${assets} ${cdns}`,
46
+ `style-src 'unsafe-inline' ${assets} ${cdns}`,
47
+ `font-src ${cdns} data:`,
48
+ `img-src https: data: blob: ${origin}`,
49
+ `connect-src ${cdns}`,
50
+ `media-src https: data: blob: ${origin}`,
51
+ ].join("; ");
52
+ }
53
+ // Static design tokens exposed to html surfaces — fonts and radii. The COLOR tokens
54
+ // (--color-*) are theme-dependent and injected separately by renderHtmlPage via
55
+ // tokenThemeCss(theme); names match Claude's widget surface either way so agents
56
+ // reuse the same muscle memory.
57
+ const TOKENS_CSS = `
58
+ :root {
59
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
60
+ --font-serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
61
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
62
+ --border-radius-md: 8px;
63
+ --border-radius-lg: 12px;
64
+ --border-radius-xl: 16px;
65
+ }
66
+ html { box-sizing: border-box; scrollbar-width: none; }
67
+ html::-webkit-scrollbar, body::-webkit-scrollbar { display: none; }
68
+ *, *::before, *::after { box-sizing: inherit; }
69
+ body {
70
+ margin: 0;
71
+ padding: 16px;
72
+ background: var(--color-background-primary);
73
+ color: var(--color-text-primary);
74
+ font: 16px/1.6 var(--font-sans);
75
+ }
76
+ `;
77
+ // Surface kit: element defaults and SVG utility classes baked into every
78
+ // html-surface doc so agents publish compact markup instead of hand-writing inline
79
+ // CSS. Documented as a reference table in guide/topics/html.md — keep the
80
+ // two in sync. Note: CSS rules override SVG presentation attributes, so bare
81
+ // element selectors here must never set properties surfaces commonly set via
82
+ // attributes (fill/font-size on text, etc.) — that's why text styling is
83
+ // opt-in via classes.
84
+ const KIT_CSS = `
85
+ :root { color-scheme: light dark; }
86
+ button {
87
+ font: 500 14px/1.4 var(--font-sans);
88
+ color: var(--color-text-primary);
89
+ background: none;
90
+ border: 0.5px solid var(--color-border-secondary);
91
+ border-radius: var(--border-radius-md);
92
+ padding: 6px 14px;
93
+ cursor: pointer;
94
+ }
95
+ button:hover { background: var(--color-background-secondary); }
96
+ input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea {
97
+ font: 14px/1.4 var(--font-sans);
98
+ color: var(--color-text-primary);
99
+ background: var(--color-background-primary);
100
+ border: 0.5px solid var(--color-border-secondary);
101
+ border-radius: var(--border-radius-md);
102
+ padding: 6px 10px;
103
+ outline: none;
104
+ }
105
+ input:focus, select:focus, textarea:focus { border-color: var(--color-border-info); }
106
+ input::placeholder, textarea::placeholder { color: var(--color-text-tertiary); }
107
+ textarea { resize: vertical; }
108
+ input[type=checkbox], input[type=radio], input[type=range], progress {
109
+ accent-color: var(--color-border-info);
110
+ }
111
+ svg { font-family: var(--font-sans); fill: var(--color-text-primary); }
112
+ .t { font-size: 14px; }
113
+ .ts { font-size: 12px; fill: var(--color-text-secondary); }
114
+ .th { font-size: 14px; font-weight: 500; }
115
+ .box { fill: var(--color-background-secondary); stroke: var(--color-border-tertiary); rx: 8px; }
116
+ .arr { stroke: var(--color-text-secondary); stroke-width: 1.2; fill: none; }
117
+ .leader { stroke: var(--color-border-secondary); stroke-width: 1; stroke-dasharray: 3 4; fill: none; }
118
+ .node { cursor: pointer; }
119
+ .node:hover { opacity: 0.75; }
120
+ .c-blue, .c-blue .box { fill: var(--color-background-info); stroke: var(--color-border-info); }
121
+ .c-blue text, text.c-blue { fill: var(--color-text-info); stroke: none; }
122
+ .c-teal, .c-teal .box { fill: var(--c-teal-bg); stroke: var(--c-teal-line); }
123
+ .c-teal text, text.c-teal { fill: var(--c-teal-text); stroke: none; }
124
+ .c-amber, .c-amber .box { fill: var(--color-background-warning); stroke: var(--color-border-warning); }
125
+ .c-amber text, text.c-amber { fill: var(--color-text-warning); stroke: none; }
126
+ .c-coral, .c-coral .box { fill: var(--c-coral-bg); stroke: var(--c-coral-line); }
127
+ .c-coral text, text.c-coral { fill: var(--c-coral-text); stroke: none; }
128
+ .c-green, .c-green .box { fill: var(--color-background-success); stroke: var(--color-border-success); }
129
+ .c-green text, text.c-green { fill: var(--color-text-success); stroke: none; }
130
+ .c-red, .c-red .box { fill: var(--color-background-danger); stroke: var(--color-border-danger); }
131
+ .c-red text, text.c-red { fill: var(--color-text-danger); stroke: none; }
132
+ .c-gray, .c-gray .box { fill: var(--color-background-secondary); stroke: var(--color-border-secondary); }
133
+ .c-gray text, text.c-gray { fill: var(--color-text-secondary); stroke: none; }
134
+ `;
135
+ // Shared SVG defs injected into every html-surface doc. Inline SVGs anywhere in
136
+ // the document can reference these by id; the arrowhead inherits the
137
+ // referencing line's stroke color via context-stroke.
138
+ const SVG_DEFS = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="context-stroke"/></marker></defs></svg>`;
139
+ // Bridge to the host viewer: sendPrompt/openLink/copyToClipboard mirror
140
+ // Claude's widget globals, and a ResizeObserver reports content height so the
141
+ // parent can size the sandboxed (opaque-origin) iframe. copyToClipboard posts
142
+ // to the parent (trusted origin) which has clipboard API access; the sandbox
143
+ // itself is opaque-origin so navigator.clipboard is unavailable there.
144
+ // Exported so the resize-guard regression test can run the exact shipped script
145
+ // in a vm, instead of scraping it back out of rendered HTML.
146
+ export const BRIDGE_JS = `
147
+ window.sendPrompt = function (text) {
148
+ parent.postMessage({ __mockpit: true, type: 'send-prompt', text: String(text) }, '*');
149
+ };
150
+ window.openLink = function (url) {
151
+ parent.postMessage({ __mockpit: true, type: 'open-link', url: String(url) }, '*');
152
+ };
153
+ window.copyToClipboard = function (text) {
154
+ parent.postMessage({ __mockpit: true, type: 'copy', text: String(text) }, '*');
155
+ };
156
+ document.addEventListener('click', function (e) {
157
+ var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
158
+ if (a && /^https?:/.test(a.href)) { e.preventDefault(); window.openLink(a.href); }
159
+ });
160
+ // Cmd+Option+Up/Down switches sessions in the sidebar, but keydowns fire in
161
+ // whichever document holds focus — once the user clicks into a surface, this
162
+ // sandboxed iframe swallows them. Forward just that combo to the host.
163
+ document.addEventListener('keydown', function (e) {
164
+ if (!e.metaKey || !e.altKey || e.ctrlKey || e.shiftKey) return;
165
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
166
+ e.preventDefault();
167
+ parent.postMessage({ __mockpit: true, type: 'switch-session', key: e.key }, '*');
168
+ });
169
+ // Report content height to the parent so it can size this iframe, while
170
+ // breaking a feedback loop that can peg a CPU core.
171
+ //
172
+ // The loop: the parent sets the iframe's height to whatever we report, but some
173
+ // content's height *inverts* with the frame's height — a scrollbar that appears
174
+ // at height A reflows the content to height B, then disappears at B and reflows
175
+ // back to A (or any 100vh / percentage-derived layout). The ResizeObserver then
176
+ // fires on every flip, so reported heights alternate A, B, A, B... forever. With
177
+ // a cheap surface that's a brief blip; with a heavy one (a big syntax-highlighted
178
+ // diff/markdown surface) each relayout is expensive and the tab sits at 100% CPU
179
+ // until the surface unmounts.
180
+ //
181
+ // A plain h !== __lastH guard can't stop this: in a 2-cycle every value differs
182
+ // from the one immediately before it. So we remember the previous height too and
183
+ // defer a return to it *if it recurs faster than a human could* (< 250ms) — that's
184
+ // the runaway. The deferred pass keeps one trailing re-measure and reports the
185
+ // taller height in the pair, so an ordinary font/image reflow can't leave the
186
+ // frame permanently clipped.
187
+ var __lastH = 0;
188
+ var __prevH = 0;
189
+ var __lastT = 0;
190
+ var __seenH = 0;
191
+ var __trailTimer = 0;
192
+ var __trailH = 0;
193
+ var __FLIP_MS = 250;
194
+ var __TRAIL_MS = 350;
195
+ function __now() {
196
+ return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
197
+ }
198
+ function __measureHeight() {
199
+ return document.body
200
+ ? document.body.scrollHeight
201
+ : document.documentElement.scrollHeight;
202
+ }
203
+ function __postHeight(h, t) {
204
+ __prevH = __lastH;
205
+ __lastH = h;
206
+ __lastT = t;
207
+ parent.postMessage({ __mockpit: true, type: 'resize', height: h }, '*');
208
+ }
209
+ function __clearTrailing() {
210
+ if (__trailTimer && typeof clearTimeout !== 'undefined') clearTimeout(__trailTimer);
211
+ __trailTimer = 0;
212
+ __trailH = 0;
213
+ }
214
+ function __flushTrailing() {
215
+ __trailTimer = 0;
216
+ var measured = __measureHeight();
217
+ if (measured > 0) __seenH = measured;
218
+ var target = Math.max(__trailH || 0, measured || 0);
219
+ __trailH = 0;
220
+ if (target <= 0 || target === __lastH) return;
221
+ __postHeight(target, __now());
222
+ }
223
+ function __scheduleTrailing(h, reset) {
224
+ __trailH = Math.max(__trailH || 0, h || 0, __lastH || 0);
225
+ if (__trailTimer) {
226
+ if (!reset) return;
227
+ if (typeof clearTimeout !== 'undefined') clearTimeout(__trailTimer);
228
+ }
229
+ __trailTimer = setTimeout(__flushTrailing, __TRAIL_MS);
230
+ }
231
+ function __report() {
232
+ var h = __measureHeight();
233
+ if (h <= 0) return; // no content yet
234
+ var changed = h !== __seenH;
235
+ __seenH = h;
236
+ if (h === __lastH) return; // unchanged
237
+ var t = __now();
238
+ if (h === __prevH && (__trailTimer || t - __lastT < __FLIP_MS)) {
239
+ __scheduleTrailing(h, true); // rapid A<->B flip: defer one settled report
240
+ return;
241
+ }
242
+ __clearTrailing();
243
+ __postHeight(h, t);
244
+ }
245
+ if (document.readyState === 'complete') __report();
246
+ else window.addEventListener('load', function () { requestAnimationFrame(__report); });
247
+ setTimeout(__report, 60);
248
+ setTimeout(__report, 350);
249
+ setTimeout(__report, 1500);
250
+ setTimeout(__report, 3000);
251
+ setTimeout(__report, 6000);
252
+ setTimeout(__report, 10000);
253
+ if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
254
+ document.fonts.ready.then(function () { __report(); });
255
+ }
256
+ if (window.ResizeObserver) {
257
+ window.__ssRO = new ResizeObserver(__report);
258
+ window.__ssRO.observe(document.documentElement);
259
+ if (document.body) window.__ssRO.observe(document.body);
260
+ }
261
+ `;
262
+ // Hit test: the viewer's marker overlay lives in the trusted origin ABOVE the
263
+ // sandboxed frame, so when the operator drops a pin it knows the normalized
264
+ // point but nothing about what sits under it. This answers that question over
265
+ // one narrow message — the viewer asks, we reply with data (a css path, a text
266
+ // snippet, a normalized rect) that it renders as text nodes only.
267
+ //
268
+ // Narrow-channel rules, both directions:
269
+ // - we only answer `parent`, so a nested frame or a popup can't harvest the
270
+ // document by spamming hit tests;
271
+ // - the reply is DATA, never markup, and `text` is clamped so a surface can't
272
+ // use a pin as a megaphone into the viewer's chrome.
273
+ //
274
+ // Coordinates are normalized against the DOCUMENT box (clientWidth ×
275
+ // body.scrollHeight), i.e. the same box the resize bridge reports and the
276
+ // viewer sizes the iframe to — so an anchor drawn over the frame maps 1:1
277
+ // whatever the viewport preset scales it to.
278
+ //
279
+ // Kept separate from BRIDGE_JS: the resize half is a load-bearing, WebKit-
280
+ // quirked script with its own regression test that runs it verbatim in a vm.
281
+ export const HIT_TEST_JS = `
282
+ (function () {
283
+ var MAX_DEPTH = 6;
284
+ var MAX_TEXT = 60;
285
+ function docBox() {
286
+ var w = document.documentElement.clientWidth || 1;
287
+ var h = (document.body && document.body.scrollHeight) || document.documentElement.scrollHeight || 1;
288
+ return { w: w, h: h };
289
+ }
290
+ function ident(v) { return typeof v === 'string' && /^[A-Za-z][\\w-]*$/.test(v); }
291
+ function cssPath(el) {
292
+ var parts = [];
293
+ while (el && el.nodeType === 1 && el !== document.body && el !== document.documentElement) {
294
+ var tag = el.tagName.toLowerCase();
295
+ if (ident(el.id)) { parts.unshift(tag + '#' + el.id); break; }
296
+ var sel = tag;
297
+ var cls = (el.getAttribute('class') || '').split(/\\s+/).filter(ident).slice(0, 2);
298
+ if (cls.length) sel += '.' + cls.join('.');
299
+ var p = el.parentElement;
300
+ if (p) {
301
+ var sibs = [];
302
+ for (var i = 0; i < p.children.length; i++) {
303
+ if (p.children[i].tagName === el.tagName) sibs.push(p.children[i]);
304
+ }
305
+ if (sibs.length > 1) sel += ':nth-of-type(' + (sibs.indexOf(el) + 1) + ')';
306
+ }
307
+ parts.unshift(sel);
308
+ if (parts.length >= MAX_DEPTH) break;
309
+ el = p;
310
+ }
311
+ return parts.join(' > ');
312
+ }
313
+ function firstLine(el) {
314
+ var t = (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim();
315
+ var line = t.split('\\n')[0].trim();
316
+ return line.length > MAX_TEXT ? line.slice(0, MAX_TEXT - 1) + '\\u2026' : line;
317
+ }
318
+ window.addEventListener('message', function (e) {
319
+ if (e.source !== parent || parent === window) return;
320
+ var d = e.data;
321
+ if (!d || d.__mockpit !== true || d.type !== 'hit-test') return;
322
+ var box = docBox();
323
+ var px = Math.max(0, Math.min(1, Number(d.x) || 0)) * box.w;
324
+ var py = Math.max(0, Math.min(1, Number(d.y) || 0)) * box.h;
325
+ var el = document.elementFromPoint(px - (window.scrollX || 0), py - (window.scrollY || 0));
326
+ var reply = { __mockpit: true, type: 'hit-test-result', ref: d.ref, path: '', text: '', rect: [0, 0, 0, 0] };
327
+ if (el && el.nodeType === 1) {
328
+ var r = el.getBoundingClientRect();
329
+ reply.path = cssPath(el);
330
+ reply.text = firstLine(el);
331
+ reply.rect = [
332
+ (r.left + (window.scrollX || 0)) / box.w,
333
+ (r.top + (window.scrollY || 0)) / box.h,
334
+ r.width / box.w,
335
+ r.height / box.h,
336
+ ];
337
+ }
338
+ parent.postMessage(reply, '*');
339
+ });
340
+ })();
341
+ `;
342
+ // ---------------------------------------------------------------------------
343
+ // Stage bridge: parts and knobs (html surfaces only)
344
+ // ---------------------------------------------------------------------------
345
+ //
346
+ // The message protocol between a surface frame and the trusted host. Every
347
+ // message, in both directions, is a plain object carrying `__mockpit: true` and
348
+ // a `type`. The frame only acts on messages whose source is its parent; the host
349
+ // must only accept messages whose source is a frame it created, and must treat
350
+ // every field as untrusted data (text nodes and numbers, never markup).
351
+ //
352
+ // frame → host
353
+ // resize {height} body.scrollHeight (BRIDGE_JS)
354
+ // send-prompt {text} window.sendPrompt (BRIDGE_JS)
355
+ // open-link {url} http(s) link click / openLink (BRIDGE_JS)
356
+ // copy {text} window.copyToClipboard (BRIDGE_JS)
357
+ // switch-session {key} Cmd+Opt+ArrowUp/Down (BRIDGE_JS)
358
+ // hit-test-result {ref, path, text, rect} reply to hit-test (HIT_TEST_JS)
359
+ // parts {version, parts, height, scroll:{x,y}, viewport:{w,h}} (PARTS_JS)
360
+ // parts[i] = {name, label, key, box:{x,y,w,h}, visible, fixed, depth, parent, order}
361
+ // box is in DOCUMENT px (viewport rect + scroll), so the host draws
362
+ // (box − scroll) × scale, which is also right for fixed and sticky parts.
363
+ // label falls back to name; key is null unless data-part-key is set;
364
+ // parent is the nearest enclosing part's name (null at top level), depth
365
+ // counts enclosing parts, order is document order. Invisible parts
366
+ // (display:none, visibility:hidden, zero size) are listed with
367
+ // visible:false. `version` is the post version the document was rendered
368
+ // for: a reloading frame keeps its contentWindow, so the host must drop
369
+ // reports whose version is not the one it loaded. Sent on load, on
370
+ // 50/150/400/1000ms timers, and on any layout-affecting change; never
371
+ // twice in a row with identical content.
372
+ // hit {ref, part, key} reply to hit; part = name|null
373
+ //
374
+ // host → frame
375
+ // hit-test {ref, x, y} x,y normalized 0..1 of the document box (Mark tool)
376
+ // hit {ref, x, y} x,y in document px; answered with the deepest
377
+ // [data-part] under the point by the page's own
378
+ // stacking (elementFromPoint), not overlay order
379
+ // highlight {parts:[name]} outline these parts, replacing the previous set;
380
+ // never triggers a parts report
381
+ // clear {} remove every highlight
382
+ // scroll {dx, dy} scrollBy in document px (a wheel over the host's
383
+ // overlay, forwarded)
384
+ // knobs {values} {path: value}, merged into the current values
385
+ // and applied exactly like the `?k=` preamble
386
+ //
387
+ // Knobs (`/s/:id?k=`, validated by the route against the mock's declared knobs,
388
+ // and the `knobs` command): for each path, dots become "-" in names, and
389
+ // --k-<path> on <html style>: numbers as-is, booleans 1/0, strings
390
+ // raw when they are plain CSS tokens, else a quoted CSS
391
+ // string; an object value ({x, y}) spreads one level
392
+ // into --k-<path>-<field>
393
+ // data-k-<path> on <html>: numbers, "true"/"false", strings; objects
394
+ // get none
395
+ // [data-k-bind="<path>"] textContent = the value (objects as JSON)
396
+ // window `mockpit:knobs` CustomEvent, detail {values} = every current value,
397
+ // fired at DOMContentLoaded and after each command.
398
+ // Structural options are pre-rendered by the agent and switched with CSS on
399
+ // `html[data-k-<path>="…"]`.
400
+ // Shared by the server-side preamble and the in-frame script so a baked value and
401
+ // a live one land identically; the unit test runs this source to prove it.
402
+ const KNOB_VALUE_JS = `
403
+ var __kPathRe = /^[A-Za-z_][\\w-]{0,63}(\\.[A-Za-z_][\\w-]{0,63}){0,3}$/;
404
+ var __kTokenRe = /^[#\\w(),.%/+-][#\\w\\s(),.%/+-]*$/;
405
+ function __kName(path) { return path.replace(/\\./g, '-'); }
406
+ function __kCssString(s) {
407
+ return '"' + s.replace(/[\\\\"]/g, function (c) { return '\\\\' + c; })
408
+ .replace(/[\\u0000-\\u001f\\u007f]/g, function (c) { return '\\\\' + c.charCodeAt(0).toString(16) + ' '; }) + '"';
409
+ }
410
+ function __kCss(v) {
411
+ if (typeof v === 'number') return isFinite(v) ? String(v) : null;
412
+ if (typeof v === 'boolean') return v ? '1' : '0';
413
+ if (typeof v === 'string') return __kTokenRe.test(v) ? v : __kCssString(v);
414
+ return null;
415
+ }
416
+ function __kVars(path, v) {
417
+ var name = __kName(path);
418
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
419
+ var out = [];
420
+ for (var f in v) {
421
+ if (!Object.prototype.hasOwnProperty.call(v, f) || !/^[A-Za-z_][\\w-]{0,63}$/.test(f)) continue;
422
+ var c = __kCss(v[f]);
423
+ if (c !== null) out.push([name + '-' + f, c]);
424
+ }
425
+ return out;
426
+ }
427
+ var css = __kCss(v);
428
+ return css === null ? [] : [[name, css]];
429
+ }
430
+ function __kAttr(v) {
431
+ if (typeof v === 'number') return isFinite(v) ? String(v) : null;
432
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
433
+ if (typeof v === 'string') return v;
434
+ return null;
435
+ }
436
+ `;
437
+ // Parts: measure every element the agent marked with data-part and report it.
438
+ // Kept apart from BRIDGE_JS for the same reason HIT_TEST_JS is (that script runs
439
+ // verbatim in a vm regression test), and html-only because only agent markup
440
+ // carries parts.
441
+ export const PARTS_JS = `
442
+ (function () {
443
+ if (window.parent === window) return;
444
+ var HL = 'mockpit-part-hl';
445
+ var MAX_PARTS = 200;
446
+ var MAX_TEXT = 200;
447
+ var doc = window.__mockpitDoc || {};
448
+ var version = typeof doc.version === 'number' ? doc.version : null;
449
+ function text(v) { return v == null ? null : String(v).slice(0, MAX_TEXT); }
450
+ function isFixed(el) {
451
+ for (var n = el; n && n.nodeType === 1; n = n.parentElement) {
452
+ if (getComputedStyle(n).position === 'fixed') return true;
453
+ }
454
+ return false;
455
+ }
456
+ function collect() {
457
+ var sx = window.scrollX || 0, sy = window.scrollY || 0;
458
+ var els = document.querySelectorAll('[data-part]');
459
+ var parts = [];
460
+ for (var i = 0; i < els.length && parts.length < MAX_PARTS; i++) {
461
+ var el = els[i];
462
+ var name = text(el.getAttribute('data-part'));
463
+ if (!name) continue;
464
+ var r = el.getBoundingClientRect();
465
+ var depth = 0;
466
+ for (var p = el.parentElement; p; p = p.parentElement) if (p.hasAttribute('data-part')) depth++;
467
+ var outer = el.parentElement && el.parentElement.closest('[data-part]');
468
+ parts.push({
469
+ name: name,
470
+ label: text(el.getAttribute('data-part-label')) || name,
471
+ key: text(el.getAttribute('data-part-key')),
472
+ box: { x: r.left + sx, y: r.top + sy, w: r.width, h: r.height },
473
+ visible: (r.width > 0 || r.height > 0) && getComputedStyle(el).visibility !== 'hidden',
474
+ fixed: isFixed(el),
475
+ depth: depth,
476
+ parent: outer ? text(outer.getAttribute('data-part')) : null,
477
+ order: i,
478
+ });
479
+ }
480
+ return {
481
+ version: version,
482
+ parts: parts,
483
+ height: document.body ? document.body.scrollHeight : document.documentElement.scrollHeight,
484
+ scroll: { x: sx, y: sy },
485
+ viewport: { w: innerWidth, h: innerHeight },
486
+ };
487
+ }
488
+ var last = '';
489
+ var timer = 0;
490
+ // setTimeout, not rAF: a hidden cross-origin frame may have rendering (and
491
+ // with it rAF and ResizeObserver) throttled, but timers still run.
492
+ function schedule() {
493
+ if (timer) return;
494
+ timer = setTimeout(function () {
495
+ timer = 0;
496
+ var m = collect();
497
+ var key = JSON.stringify(m);
498
+ if (key === last) return;
499
+ last = key;
500
+ m.__mockpit = true;
501
+ m.type = 'parts';
502
+ parent.postMessage(m, '*');
503
+ }, 0);
504
+ }
505
+
506
+ var ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
507
+ var observed = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
508
+ function observeAll() {
509
+ if (!ro || !observed) return;
510
+ // html/body too: a non-part sibling growing moves parts without resizing them.
511
+ var els = [document.documentElement, document.body].concat([].slice.call(document.querySelectorAll('[data-part]')));
512
+ for (var i = 0; i < els.length; i++) {
513
+ var el = els[i];
514
+ if (el && !observed.has(el)) { observed.add(el); ro.observe(el); }
515
+ }
516
+ }
517
+ var moTimer = 0;
518
+ var mo = new MutationObserver(function () {
519
+ clearTimeout(moTimer);
520
+ moTimer = setTimeout(function () { observeAll(); schedule(); }, 40);
521
+ });
522
+ mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
523
+
524
+ // Transforms and transitions move boxes without resizing or mutating anything.
525
+ document.addEventListener('animationend', schedule, true);
526
+ document.addEventListener('transitionend', schedule, true);
527
+ window.addEventListener('scroll', schedule, { passive: true });
528
+ window.addEventListener('resize', schedule);
529
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(schedule);
530
+ observeAll();
531
+ schedule();
532
+ // WebKit: RO's initial callback may never fire in a sandboxed frame.
533
+ function settle() {
534
+ schedule();
535
+ [50, 150, 400, 1000].forEach(function (ms) { setTimeout(schedule, ms); });
536
+ }
537
+ if (document.readyState === 'complete') settle();
538
+ else window.addEventListener('load', settle);
539
+
540
+ function setHighlight(names) {
541
+ var on = document.querySelectorAll('.' + HL);
542
+ for (var i = 0; i < on.length; i++) on[i].classList.remove(HL);
543
+ if (names && names.length) {
544
+ var want = {};
545
+ for (var k = 0; k < names.length && k < MAX_PARTS; k++) want[String(names[k])] = true;
546
+ var els = document.querySelectorAll('[data-part]');
547
+ for (var j = 0; j < els.length; j++) {
548
+ if (want[els[j].getAttribute('data-part')] === true) els[j].classList.add(HL);
549
+ }
550
+ }
551
+ // Our own class flip must not look like the agent's page changing.
552
+ mo.takeRecords();
553
+ }
554
+
555
+ window.addEventListener('message', function (e) {
556
+ if (e.source !== parent) return;
557
+ var d = e.data;
558
+ if (!d || d.__mockpit !== true) return;
559
+ if (d.type === 'highlight') setHighlight(Array.isArray(d.parts) ? d.parts : []);
560
+ else if (d.type === 'clear') setHighlight(null);
561
+ else if (d.type === 'scroll') window.scrollBy(Number(d.dx) || 0, Number(d.dy) || 0);
562
+ else if (d.type === 'hit') {
563
+ var el = document.elementFromPoint(
564
+ (Number(d.x) || 0) - (window.scrollX || 0),
565
+ (Number(d.y) || 0) - (window.scrollY || 0)
566
+ );
567
+ var part = el && el.closest ? el.closest('[data-part]') : null;
568
+ parent.postMessage({
569
+ __mockpit: true,
570
+ type: 'hit',
571
+ ref: d.ref,
572
+ part: part ? text(part.getAttribute('data-part')) : null,
573
+ key: part ? text(part.getAttribute('data-part-key')) : null,
574
+ }, '*');
575
+ }
576
+ });
577
+ })();
578
+ `;
579
+ // Knobs: the `?k=` values are already on <html> (see knobPreamble); this binds
580
+ // text, announces them, and applies the host's live `knobs` commands.
581
+ export const KNOBS_JS = `
582
+ (function () {
583
+ ${KNOB_VALUE_JS}
584
+ var doc = window.__mockpitDoc || {};
585
+ var current = {};
586
+ function own(o, k) { return Object.prototype.hasOwnProperty.call(o, k); }
587
+ function bind(path, v) {
588
+ var els = document.querySelectorAll('[data-k-bind]');
589
+ for (var i = 0; i < els.length; i++) {
590
+ if (els[i].getAttribute('data-k-bind') !== path) continue;
591
+ els[i].textContent = v && typeof v === 'object' ? JSON.stringify(v) : String(v);
592
+ }
593
+ }
594
+ function apply(values, live) {
595
+ var root = document.documentElement;
596
+ for (var path in values) {
597
+ if (!own(values, path) || !__kPathRe.test(path)) continue;
598
+ var v = values[path];
599
+ if (v === null || v === undefined) continue;
600
+ current[path] = v;
601
+ if (live) {
602
+ var vars = __kVars(path, v);
603
+ if (!vars.length) root.style.removeProperty('--k-' + __kName(path));
604
+ for (var i = 0; i < vars.length; i++) root.style.setProperty('--k-' + vars[i][0], vars[i][1]);
605
+ var attr = __kAttr(v);
606
+ if (attr === null) root.removeAttribute('data-k-' + __kName(path));
607
+ else root.setAttribute('data-k-' + __kName(path), attr);
608
+ }
609
+ bind(path, v);
610
+ }
611
+ window.dispatchEvent(new CustomEvent('mockpit:knobs', {
612
+ detail: { values: JSON.parse(JSON.stringify(current)) },
613
+ }));
614
+ }
615
+ function initial() { apply(doc.knobs && typeof doc.knobs === 'object' ? doc.knobs : {}, false); }
616
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initial);
617
+ else initial();
618
+ window.addEventListener('message', function (e) {
619
+ if (e.source !== parent || parent === window) return;
620
+ var d = e.data;
621
+ if (!d || d.__mockpit !== true || d.type !== 'knobs') return;
622
+ if (d.values && typeof d.values === 'object' && !Array.isArray(d.values)) apply(d.values, true);
623
+ });
624
+ })();
625
+ `;
626
+ // Server-side twins of KNOB_VALUE_JS, used to bake `?k=` into the document.
627
+ const KNOB_PATH_RE = /^[A-Za-z_][\w-]{0,63}(\.[A-Za-z_][\w-]{0,63}){0,3}$/;
628
+ const KNOB_TOKEN_RE = /^[#\w(),.%/+-][#\w\s(),.%/+-]*$/;
629
+ const KNOB_FIELD_RE = /^[A-Za-z_][\w-]{0,63}$/;
630
+ const knobName = (path) => path.replace(/\./g, "-");
631
+ const knobCssString = (s) => `"${s
632
+ .replace(/[\\"]/g, (c) => `\\${c}`)
633
+ // oxlint-disable-next-line no-control-regex -- control characters are exactly what must be escaped
634
+ .replace(/[\u0000-\u001f\u007f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
635
+ export function knobCss(v) {
636
+ if (typeof v === "number")
637
+ return Number.isFinite(v) ? String(v) : null;
638
+ if (typeof v === "boolean")
639
+ return v ? "1" : "0";
640
+ if (typeof v === "string")
641
+ return KNOB_TOKEN_RE.test(v) ? v : knobCssString(v);
642
+ return null;
643
+ }
644
+ export function knobVars(path, v) {
645
+ const name = knobName(path);
646
+ if (v && typeof v === "object" && !Array.isArray(v)) {
647
+ const out = [];
648
+ for (const [field, inner] of Object.entries(v)) {
649
+ if (!KNOB_FIELD_RE.test(field))
650
+ continue;
651
+ const css = knobCss(inner);
652
+ if (css !== null)
653
+ out.push([`${name}-${field}`, css]);
654
+ }
655
+ return out;
656
+ }
657
+ const css = knobCss(v);
658
+ return css === null ? [] : [[name, css]];
659
+ }
660
+ export function knobAttr(v) {
661
+ if (typeof v === "number")
662
+ return Number.isFinite(v) ? String(v) : null;
663
+ if (typeof v === "boolean")
664
+ return v ? "true" : "false";
665
+ if (typeof v === "string")
666
+ return v;
667
+ return null;
668
+ }
669
+ // The source of KNOB_VALUE_JS, for the parity test.
670
+ export const KNOB_VALUE_SOURCE = KNOB_VALUE_JS;
671
+ // JSON inside an inline <script>: `<` can't open `</script>` or `<!--`, and the
672
+ // two JS line terminators that JSON allows can't end the statement early.
673
+ const scriptJson = (v) => JSON.stringify(v)
674
+ .replace(/</g, "\\u003c")
675
+ .replace(/\u2028/g, "\\u2028")
676
+ .replace(/\u2029/g, "\\u2029");
677
+ // The `<html>` open tag with the baked knob attributes and vars, plus the head
678
+ // script that hands the version and the values to PARTS_JS / KNOBS_JS. Values
679
+ // are agent/user data: attributes go through escapeHtml, CSS values through
680
+ // knobCss (quoted unless plain tokens) and then escapeHtml as the style
681
+ // attribute, and the JSON through scriptJson.
682
+ export function knobPreamble(version, knobs) {
683
+ const attrs = [];
684
+ const vars = [];
685
+ const values = {};
686
+ for (const [path, v] of Object.entries(knobs ?? {})) {
687
+ if (!KNOB_PATH_RE.test(path) || v === null || v === undefined)
688
+ continue;
689
+ values[path] = v;
690
+ const attr = knobAttr(v);
691
+ if (attr !== null)
692
+ attrs.push(` data-k-${knobName(path)}="${escapeHtml(attr)}"`);
693
+ for (const [name, css] of knobVars(path, v))
694
+ vars.push(`--k-${name}:${css}`);
695
+ }
696
+ const style = vars.length ? ` style="${escapeHtml(vars.join(";"))}"` : "";
697
+ return {
698
+ htmlTag: `<html lang="en"${attrs.join("")}${style}>`,
699
+ headScript: `<script>window.__mockpitDoc=${scriptJson({ version: version ?? null, knobs: values })};</script>`,
700
+ };
701
+ }
702
+ // Our own highlight, shipped in the base stylesheet so turning it on is a class
703
+ // flip rather than a DOM insertion the parts observer would report.
704
+ const PART_HIGHLIGHT_CSS = `.mockpit-part-hl{outline:2px solid #8b7bff !important;outline-offset:2px}`;
705
+ // Tailwind's browser build, pinned to a major (jsdelivr is already on the CDN
706
+ // allowlist, so this needs no CSP widening). Injected only when a project's
707
+ // design settings say `kit: "tailwind"`, i.e. the repo itself is a Tailwind
708
+ // repo — so the agent writes the same classes it writes in the codebase.
709
+ const TAILWIND_CDN = "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4";
710
+ // Sizing for the `<svg class="icon">` that icons.ts inlines for every
711
+ // `icon="prefix:name"`. Fill and stroke are left to the icon body, which
712
+ // declares them per set and draws in currentColor.
713
+ const ICON_CSS = `.icon{width:1em;height:1em;flex:none;vertical-align:-0.125em}`;
714
+ // ---------------------------------------------------------------------------
715
+ // Content-hashed static assets for surface documents
716
+ // ---------------------------------------------------------------------------
717
+ //
718
+ // The bridge script and the theme-independent stylesheets used to be inlined
719
+ // into EVERY surface document — ~11 KB of identical bytes per surface, paid
720
+ // again for every post, every version, every theme and every mode. They are
721
+ // served instead from `/asset/<name>.<hash>.<ext>`, so the browser fetches each
722
+ // one once and reuses it across the whole workspace.
723
+ //
724
+ // The hash is derived from the content at module load (FNV-1a — no crypto, no
725
+ // async, runtime-agnostic), so an upgrade that changes a byte changes the URL:
726
+ // a surface document cached as immutable for a year can never pair with stale
727
+ // asset bytes.
728
+ //
729
+ // Only content that does NOT depend on the theme or the resolved mode lives
730
+ // here. Theme tokens are per (theme, mode) and stay inline in the document,
731
+ // where they are already covered by its cache key.
732
+ const fnv1a = (input) => {
733
+ let h = 0x811c9dc5;
734
+ for (let i = 0; i < input.length; i++) {
735
+ h ^= input.charCodeAt(i);
736
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
737
+ }
738
+ return h.toString(36);
739
+ };
740
+ // Every path is registered at module load, so a document served from a
741
+ // long-lived cache after a restart always finds the asset it references.
742
+ export const STATIC_ASSET_PREFIX = "/asset/";
743
+ const STATIC_ASSETS = new Map();
744
+ function registerAsset(name, ext, body) {
745
+ const path = `${STATIC_ASSET_PREFIX}${name}.${fnv1a(body)}.${ext}`;
746
+ STATIC_ASSETS.set(path, {
747
+ path,
748
+ contentType: ext === "js" ? "text/javascript; charset=utf-8" : "text/css; charset=utf-8",
749
+ body,
750
+ });
751
+ return path;
752
+ }
753
+ export const staticAsset = (path) => STATIC_ASSETS.get(path) ?? null;
754
+ const BRIDGE_PATH = registerAsset("bridge", "js", BRIDGE_JS);
755
+ const HIT_TEST_PATH = registerAsset("hit-test", "js", HIT_TEST_JS);
756
+ const PARTS_PATH = registerAsset("parts", "js", PARTS_JS);
757
+ const KNOBS_PATH = registerAsset("knobs", "js", KNOBS_JS);
758
+ // The base html-surface stylesheet: static design tokens + the surface kit.
759
+ const BASE_CSS_PATH = registerAsset("base", "css", `${TOKENS_CSS}${KIT_CSS}${PART_HIGHLIGHT_CSS}${ICON_CSS}`);
760
+ const KIT_CORE_PATH = registerAsset("kit-core", "css", CORE_CSS);
761
+ const KIT_PATHS = new Map(KITS.map((k) => [k.id, registerAsset(`kit-${k.id}`, "css", k.css)]));
762
+ const scriptTag = (origin, path) => `<script src="${origin}${path}"></script>`;
763
+ const styleTag = (origin, path) => `<link rel="stylesheet" href="${origin}${path}">`;
764
+ // The same resolution kitAssets does (known ids, first occurrence wins), but
765
+ // yielding one stylesheet per kit instead of one concatenated string. Project
766
+ // kit URLs are re-checked here as well as on write: a stored setting is the
767
+ // last thing between a URL and a tag in the document.
768
+ function resolveKits(ids, projectKits = []) {
769
+ if (!ids || ids.length === 0)
770
+ return [];
771
+ const seen = new Set();
772
+ const chosen = [];
773
+ for (const id of ids) {
774
+ if (seen.has(id))
775
+ continue;
776
+ const kit = KITS.find((k) => k.id === id);
777
+ if (kit) {
778
+ seen.add(id);
779
+ chosen.push({ id, cssPath: KIT_PATHS.get(id), href: kit.href, script: kit.script });
780
+ continue;
781
+ }
782
+ const own = projectKits.find((k) => k.id === id);
783
+ if (!own)
784
+ continue;
785
+ const href = checkCdnUrl(own.href, "kit url");
786
+ if ("error" in href)
787
+ continue;
788
+ const script = own.script ? checkCdnUrl(own.script, "kit script") : null;
789
+ seen.add(id);
790
+ chosen.push({ id, href: href.url, script: script && "url" in script ? script.url : undefined });
791
+ }
792
+ return chosen;
793
+ }
794
+ // Everything a project's DesignSettings contributes to one html-surface doc.
795
+ // Kept in one place so the ordering rule is visible: the project's own tokens
796
+ // land AFTER mockpit's, because a repo that declares `--radius` or a brand
797
+ // color should win inside its own project's surfaces.
798
+ function designAssets(design, mode) {
799
+ if (!design)
800
+ return { css: "", kits: [], projectKits: [], headScripts: "", dark: false };
801
+ const tailwindCss = design.kit === "tailwind" ? (design.tailwindCss?.trim() ?? "") : "";
802
+ const raw = tailwindCss ? "" : (design.cssVars?.trim() ?? "");
803
+ // `cssVars` is stored as the repo's raw block; accept either the full
804
+ // `:root{…}` text or a bare declaration list.
805
+ const vars = raw ? (raw.includes("{") ? raw : `:root{${raw}}`) : "";
806
+ const kit = design.kit;
807
+ // The browser build reads its input from the style tags present when its
808
+ // script runs, so the stylesheet goes first. Its compiled output already
809
+ // carries the repo's `:root` block, hence no `cssVars` beside it.
810
+ const tailwind = kit !== "tailwind"
811
+ ? ""
812
+ : `${tailwindCss ? `<style type="text/tailwindcss">${tailwindCss.replace(/<\/style/gi, "<\\/style")}</style>\n` : ""}<script src="${TAILWIND_CDN}"></script>`;
813
+ return {
814
+ css: vars,
815
+ kits: kit && kit !== "tailwind" && kit !== "none" ? [kit] : [],
816
+ projectKits: Array.isArray(design.projectKits) ? design.projectKits : [],
817
+ headScripts: tailwind,
818
+ // Repos theme dark through a `.dark` ancestor (shadcn's `.dark {…}` block,
819
+ // `@custom-variant dark (&:is(.dark *))`), so the frame wears the class.
820
+ dark: mode === "dark" && /\.dark\b/.test(tailwindCss || raw),
821
+ };
822
+ }
823
+ export const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
824
+ // Wrap one html surface in the themed, sandboxed document the iframe loads. The
825
+ // workspace's color tokens (theme-dependent) are injected first so the static base
826
+ // + kit resolve against them; `theme` defaults to the github preset.
827
+ // CSP for a rich surface (markdown/mermaid/diff). These render markup our own
828
+ // libraries produced — they never load CDN scripts and never need the network,
829
+ // so the policy is *tighter* than an html surface's: only the inline bridge runs,
830
+ // and there is no `connect-src`, so even if a sanitizer regression let agent
831
+ // markup execute, the script is boxed into an opaque origin with no way to
832
+ // phone home. `img-src origin` lets inline markdown images at <origin>/a/:id
833
+ // load (the iframe is opaque-origin, so `'self'` matches nothing — same reason
834
+ // buildCsp adds it explicitly).
835
+ function buildRichCsp(origin) {
836
+ return [
837
+ `default-src 'none'`,
838
+ // Same fixed same-origin asset directory as buildCsp — only the bridge
839
+ // script lives there. Still no connect-src.
840
+ `script-src 'unsafe-inline' ${origin}${STATIC_ASSET_PREFIX}`,
841
+ `style-src 'unsafe-inline'`,
842
+ `img-src https: data: blob: ${origin}`,
843
+ `font-src data:`,
844
+ ].join("; ");
845
+ }
846
+ // Wrap pre-rendered, *untrusted* markup (markdown HTML, a mermaid SVG, a diff's
847
+ // SSR output) in the same opaque-origin sandbox html surfaces get. The markup was
848
+ // built as a STRING in the trusted viewer (string building is not a DOM sink),
849
+ // and only becomes live DOM here, inside the iframe — so a markdown-it / shiki /
850
+ // mermaid / DOMPurify / @pierre-diffs sanitizer bypass can no longer reach the
851
+ // workspace. `css` is the surface-specific stylesheet (prose/diff/mermaid rules);
852
+ // chrome theme vars come from viewerThemeCss so the surface matches the viewer.
853
+ // `mode` PINS those vars (and any shiki dark-flip the css carries) to the
854
+ // scheme the chrome resolved, so this frame can't diverge from it — and that
855
+ // includes `color-scheme`. A sandboxed, opaque-origin iframe loaded by URL
856
+ // defaults to `color-scheme: normal` (i.e. light): in dark mode the UA paints a
857
+ // WHITE canvas behind the transparent body, and the dark-mode text (--text) is
858
+ // washed out over it. (An earlier note here assumed omitting the property kept
859
+ // the frame transparent; it doesn't — it just leaves the canvas light.) Pinning
860
+ // the scheme, exactly as an html surface does, makes the UA canvas track the
861
+ // card so the frame reads as transparent in both schemes.
862
+ export function renderSandboxedPart(doc) {
863
+ const theme = typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme;
864
+ return `<!doctype html>
865
+ <html lang="en">
866
+ <head>
867
+ <meta charset="utf-8">
868
+ <meta name="viewport" content="width=device-width, initial-scale=1">
869
+ <meta http-equiv="Content-Security-Policy" content="${buildRichCsp(doc.origin)}">
870
+ <!-- srcdoc's base URL is about:srcdoc, so relative URLs (e.g. a markdown
871
+ image at /a/:id) would not resolve; pin the base to the server origin.
872
+ img-src in buildRichCsp allows that origin. (html surfaces don't need this —
873
+ they load via /s/:id, whose URL is already the base.) -->
874
+ <base href="${doc.origin}/">
875
+ <style>${viewerThemeCss(theme, doc.mode)}${doc.css}${colorSchemeCss(doc.mode)}</style>
876
+ </head>
877
+ <body>
878
+ ${doc.body}
879
+ ${scriptTag(doc.origin, BRIDGE_PATH)}
880
+ </body>
881
+ </html>`;
882
+ }
883
+ // Mermaid can't run without a DOM, so it can't be server-rendered like the
884
+ // other rich surfaces; instead the server emits a self-rendering doc that loads
885
+ // mermaid from the CDN allowlist and renders inside the sandboxed iframe (the
886
+ // "(B)" path). Unlike the other rich surfaces it needs CDN script/connect access,
887
+ // so it uses the html-surface CSP (buildCsp), NOT the tight rich CSP. mermaid's
888
+ // own DOMPurify (securityLevel 'strict') runs first; the opaque origin is the
889
+ // second boundary. Theme colors are baked into the diagram at render time, so —
890
+ // like shiki's flip — they're PINNED to the chrome-resolved mode the viewer
891
+ // passed. On a direct no-mode load, the iframe's own JS chooses the user's
892
+ // system scheme before mermaid renders.
893
+ const MERMAID_CSS = `
894
+ body { margin: 0; padding: 14px 16px; background: transparent; text-align: center; }
895
+ svg { max-width: 100%; height: auto; }
896
+ .mmd-error {
897
+ text-align: left; color: var(--danger);
898
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
899
+ }
900
+ .mmd-error pre {
901
+ margin: 6px 0 0; padding: 8px 10px; color: var(--text);
902
+ background: var(--panel); border: 0.5px solid var(--border);
903
+ border-radius: 8px; overflow: auto; white-space: pre-wrap;
904
+ }
905
+ `;
906
+ // Mermaid `base` theme variables + themeCSS derived from the resolved palette,
907
+ // so the diagram matches mockpit's look instead of mermaid's stock theme.
908
+ // Mirrors mockpitTheme() in the old viewer MermaidPart, but reads palette
909
+ // fields directly rather than getComputedStyle.
910
+ //
911
+ // `mode` must match the scheme `p` was resolved into (renderMermaidPage picks
912
+ // the palette off the same mode): mermaid's `base` theme DERIVES every variable
913
+ // we don't set here, and many of those derivations branch on a `darkMode` flag
914
+ // (row stripes, the cScale/surface color ramps, the edge-label background).
915
+ // Leave it unset and they're all computed for a light canvas, so they never
916
+ // flip — "some of the diagram changes on toggle, but not all of it."
917
+ function mermaidThemeVars(p, mode) {
918
+ const text = p.text;
919
+ const muted = p.muted;
920
+ const border = p.border2;
921
+ const panel = p.panel;
922
+ const surface = p.surface;
923
+ const bg = p.bg;
924
+ const accent = p.info.text;
925
+ const accentBg = p.info.bg;
926
+ return {
927
+ themeVariables: {
928
+ // Pin the scheme so mermaid's darkMode-branched derivations resolve the
929
+ // same way the palette we read from did (both come from `mode`).
930
+ darkMode: mode === "dark",
931
+ fontFamily: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`,
932
+ fontSize: "14px",
933
+ // The canvas mermaid derives against. Several colors default to
934
+ // invert(background) — most visibly `arrowheadColor` — so a pinned value
935
+ // here is what lets them track the theme instead of inverting the
936
+ // hardcoded #f4f4f4 default (which stays light in both modes). Use the
937
+ // real backdrop the SVG sits on (the card surface).
938
+ background: surface,
939
+ primaryColor: panel,
940
+ primaryBorderColor: border,
941
+ primaryTextColor: text,
942
+ secondaryColor: surface,
943
+ tertiaryColor: bg,
944
+ mainBkg: panel,
945
+ nodeBorder: border,
946
+ lineColor: muted,
947
+ // Arrowheads default to invert(background); point them at the line color
948
+ // so the whole edge reads as one color in both schemes.
949
+ arrowheadColor: muted,
950
+ textColor: text,
951
+ // Text colors mermaid would otherwise invert()-derive from box/canvas
952
+ // colors. Pin them to our text token so every label — node, title,
953
+ // cluster, class-member — reads as the viewer's text color in both modes.
954
+ nodeTextColor: text,
955
+ titleColor: text,
956
+ classText: text,
957
+ secondaryTextColor: text,
958
+ tertiaryTextColor: text,
959
+ clusterBkg: bg,
960
+ clusterBorder: border,
961
+ edgeLabelBackground: bg,
962
+ actorBkg: panel,
963
+ actorBorder: border,
964
+ actorTextColor: text,
965
+ actorLineColor: muted,
966
+ signalColor: muted,
967
+ signalTextColor: text,
968
+ labelBoxBkgColor: surface,
969
+ labelBoxBorderColor: border,
970
+ labelTextColor: text,
971
+ loopTextColor: text,
972
+ noteBkgColor: accentBg,
973
+ noteBorderColor: border,
974
+ noteTextColor: text,
975
+ sequenceNumberColor: surface,
976
+ },
977
+ themeCSS: `
978
+ .node rect, .node polygon, rect.actor, .labelBox { rx: 8px; ry: 8px; }
979
+ .node rect, rect.actor { stroke-width: 1px; }
980
+ .edgePath .path, .flowchart-link, .actor-line,
981
+ .messageLine0, .messageLine1 { stroke-width: 1px; }
982
+ .node.accent > rect, .node.accent > polygon, .node.accent > circle,
983
+ .node.accent > path { fill: ${accentBg}; stroke: ${accent}; }
984
+ .node.accent .nodeLabel, .node.accent span, .node.accent text { fill: ${accent}; color: ${accent}; }
985
+ .flowchart-link.accentLine, .edgePath.accentLine > .path { stroke: ${accent}; }
986
+ `,
987
+ };
988
+ }
989
+ // Pinned mermaid CDN module (within CDN_ALLOWLIST). Pinned to a major so a
990
+ // breaking mermaid release can't silently change rendering; bump deliberately.
991
+ const MERMAID_CDN = "https://esm.sh/mermaid@11";
992
+ export function renderMermaidPage(doc) {
993
+ const theme = typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme;
994
+ const enc = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
995
+ const pinned = doc.mode
996
+ ? mermaidThemeVars(doc.mode === "dark" ? theme.dark : theme.light, doc.mode)
997
+ : null;
998
+ const light = pinned ? null : mermaidThemeVars(theme.light, "light");
999
+ const dark = pinned ? null : mermaidThemeVars(theme.dark, "dark");
1000
+ const autoTheme = pinned
1001
+ ? ""
1002
+ : `const __mql = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
1003
+ const __systemDark = !!(__mql && __mql.matches);
1004
+ const themeVariables = __systemDark ? ${enc(dark.themeVariables)} : ${enc(light.themeVariables)};
1005
+ const themeCSS = __systemDark ? ${enc(dark.themeCSS)} : ${enc(light.themeCSS)};`;
1006
+ const themeConfig = pinned
1007
+ ? `themeVariables: ${enc(pinned.themeVariables)},\n themeCSS: ${enc(pinned.themeCSS)},`
1008
+ : `themeVariables,\n themeCSS,`;
1009
+ // Embed source + theme as JS literals; escape `<` so a `</script>` in the
1010
+ // diagram source can't break out of the module script.
1011
+ const loader = `
1012
+ import mermaid from ${enc(MERMAID_CDN)};
1013
+ const src = ${enc(doc.mermaid ?? "")};
1014
+ ${autoTheme}
1015
+ mermaid.initialize({
1016
+ startOnLoad: false,
1017
+ securityLevel: 'strict',
1018
+ suppressErrorRendering: true,
1019
+ theme: 'base',
1020
+ ${themeConfig}
1021
+ });
1022
+ const el = document.getElementById('m');
1023
+ try {
1024
+ const { svg } = await mermaid.render('mmd-svg', src);
1025
+ el.innerHTML = svg;
1026
+ } catch (e) {
1027
+ // Match the old viewer fallback: a message plus the source echoed so the
1028
+ // agent can see what failed. textContent keeps the source inert.
1029
+ el.className = 'mmd-error';
1030
+ el.textContent = 'Couldn\\u2019t render diagram \\u2014 ' + (e && e.message ? e.message : 'parse error');
1031
+ const pre = document.createElement('pre');
1032
+ pre.textContent = src;
1033
+ el.appendChild(pre);
1034
+ }`;
1035
+ return `<!doctype html>
1036
+ <html lang="en">
1037
+ <head>
1038
+ <meta charset="utf-8">
1039
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1040
+ <meta http-equiv="Content-Security-Policy" content="${buildCsp(doc.origin)}">
1041
+ <base href="${doc.origin}/">
1042
+ <style>${viewerThemeCss(theme, doc.mode)}${MERMAID_CSS}${colorSchemeCss(doc.mode)}</style>
1043
+ </head>
1044
+ <body>
1045
+ <div id="m"></div>
1046
+ <script type="module">${loader}</script>
1047
+ ${scriptTag(doc.origin, BRIDGE_PATH)}
1048
+ </body>
1049
+ </html>`;
1050
+ }
1051
+ export function renderHtmlPage(doc) {
1052
+ const theme = typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme;
1053
+ const design = designAssets(doc.design, doc.mode);
1054
+ const preamble = knobPreamble(doc.version, doc.knobs);
1055
+ const htmlTag = design.dark
1056
+ ? preamble.htmlTag.replace("<html ", '<html class="dark" ')
1057
+ : preamble.htmlTag;
1058
+ // The project kit is appended, so with both present its components win over
1059
+ // a surface-requested kit's same-named classes.
1060
+ const kitIds = [...(doc.kits ?? []), ...design.kits];
1061
+ const kits = resolveKits(kitIds, design.projectKits);
1062
+ const kit = kitAssets(kitIds);
1063
+ // Document order still decides the cascade, so the externalized stylesheets
1064
+ // sit exactly where their inlined text used to: theme tokens, base, kit
1065
+ // accents, kit(s), then the project's own vars (which must win last).
1066
+ const styles = [
1067
+ `<style>${tokenThemeCss(theme, doc.mode)}</style>`,
1068
+ styleTag(doc.origin, BASE_CSS_PATH),
1069
+ `<style>${kitAccentCss(doc.mode)}</style>`,
1070
+ ...(kits.some((k) => !k.href) ? [styleTag(doc.origin, KIT_CORE_PATH)] : []),
1071
+ ...kits.flatMap((k) => [
1072
+ ...(k.href ? [`<link rel="stylesheet" href="${escapeHtml(k.href)}">`] : []),
1073
+ ...(k.cssPath ? [styleTag(doc.origin, k.cssPath)] : []),
1074
+ ]),
1075
+ `<style>${design.css}${colorSchemeCss(doc.mode)}</style>`,
1076
+ ].join("\n");
1077
+ return `<!doctype html>
1078
+ ${htmlTag}
1079
+ <head>
1080
+ <meta charset="utf-8">
1081
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1082
+ <meta http-equiv="Content-Security-Policy" content="${buildCsp(doc.origin)}">
1083
+ <title>${escapeHtml(doc.title)}</title>
1084
+ ${preamble.headScript}
1085
+ ${styles}
1086
+ ${design.headScripts}
1087
+ ${kits.flatMap((k) => (k.script ? [`<script src="${escapeHtml(k.script)}" defer></script>`] : [])).join("\n")}
1088
+ </head>
1089
+ <body>
1090
+ ${SVG_DEFS}
1091
+ ${expandIcons(doc.html, doc.resolveIcon ?? (() => null)).html}
1092
+ ${scriptTag(doc.origin, BRIDGE_PATH)}
1093
+ ${scriptTag(doc.origin, HIT_TEST_PATH)}
1094
+ ${scriptTag(doc.origin, KNOBS_PATH)}
1095
+ ${scriptTag(doc.origin, PARTS_PATH)}
1096
+ ${kit.js ? `<script>${kit.js}</script>` : ""}
1097
+ </body>
1098
+ </html>`;
1099
+ }