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.
- package/LICENSE +22 -0
- package/README.md +232 -2
- package/bin/demoData.js +132 -0
- package/bin/initDesign.js +846 -0
- package/bin/mockpit.js +1830 -0
- package/bin/serveUrl.d.ts +1 -0
- package/bin/serveUrl.js +7 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +230 -0
- package/dist/server/apiViews.d.ts +329 -0
- package/dist/server/apiViews.js +135 -0
- package/dist/server/app.d.ts +53 -0
- package/dist/server/app.js +3112 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +16 -0
- package/dist/server/cdn.d.ts +6 -0
- package/dist/server/cdn.js +33 -0
- package/dist/server/codeRunner.d.ts +5 -0
- package/dist/server/codeRunner.js +89 -0
- package/dist/server/codeWorker.d.ts +1 -0
- package/dist/server/codeWorker.js +196 -0
- package/dist/server/designGuide.d.ts +16 -0
- package/dist/server/designGuide.js +311 -0
- package/dist/server/events.d.ts +53 -0
- package/dist/server/events.js +14 -0
- package/dist/server/feedbackBatch.d.ts +43 -0
- package/dist/server/feedbackBatch.js +98 -0
- package/dist/server/icons.d.ts +50 -0
- package/dist/server/icons.js +225 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/kits.d.ts +36 -0
- package/dist/server/kits.js +279 -0
- package/dist/server/knobs.d.ts +15 -0
- package/dist/server/knobs.js +333 -0
- package/dist/server/mcpHttp.d.ts +53 -0
- package/dist/server/mcpHttp.js +143 -0
- package/dist/server/mcpSpec.d.ts +191 -0
- package/dist/server/mcpSpec.js +402 -0
- package/dist/server/parts.d.ts +31 -0
- package/dist/server/parts.js +338 -0
- package/dist/server/postMarkdown.d.ts +10 -0
- package/dist/server/postMarkdown.js +271 -0
- package/dist/server/postSurfaces.d.ts +40 -0
- package/dist/server/postSurfaces.js +407 -0
- package/dist/server/public.d.ts +5 -0
- package/dist/server/public.js +5 -0
- package/dist/server/push.d.ts +45 -0
- package/dist/server/push.js +234 -0
- package/dist/server/richRender.d.ts +15 -0
- package/dist/server/richRender.js +392 -0
- package/dist/server/run.d.ts +82 -0
- package/dist/server/run.js +290 -0
- package/dist/server/runApi.d.ts +6 -0
- package/dist/server/runApi.js +82 -0
- package/dist/server/slots.d.ts +14 -0
- package/dist/server/slots.js +71 -0
- package/dist/server/sqlStore.d.ts +119 -0
- package/dist/server/sqlStore.js +954 -0
- package/dist/server/sqliteStorage.d.ts +2 -0
- package/dist/server/sqliteStorage.js +90 -0
- package/dist/server/surfacePage.d.ts +48 -0
- package/dist/server/surfacePage.js +1099 -0
- package/dist/server/themes.d.ts +48 -0
- package/dist/server/themes.js +305 -0
- package/dist/server/types.d.ts +599 -0
- package/dist/server/types.js +299 -0
- package/dist/workers/public.d.ts +1 -0
- package/dist/workers/public.js +3 -0
- package/extensions/mockpit.js +703 -0
- package/guide/AGENT_SETUP.md +23 -0
- package/guide/topics/asks.md +87 -0
- package/guide/topics/feedback.md +127 -0
- package/guide/topics/html.md +175 -0
- package/guide/topics/http.md +76 -0
- package/guide/topics/knobs.md +72 -0
- package/guide/topics/scripts.md +129 -0
- package/guide/topics/surfaces.md +100 -0
- package/package.json +165 -4
- package/skills/launch-video/SKILL.md +159 -0
- package/skills/mockpit/SKILL.md +40 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|