@writepanda/mcp 1.259.0 → 1.265.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.
@@ -0,0 +1,189 @@
1
+ // Recorder view (ui://pandastudio/recorder.html) for recording_open_recorder:
2
+ // the script the agent loaded into PandaStudio's teleprompter (editable, with
3
+ // a reading-time estimate), Open the recorder again, and "I've recorded it",
4
+ // which tells the agent to carry on with the take. The user always presses
5
+ // Record in PandaStudio itself; this view never starts a camera or mic.
6
+
7
+ import { viewPage } from "./viewKit.mjs";
8
+
9
+ export const RECORDER_URI = "ui://pandastudio/recorder.html";
10
+
11
+ const CSS = `
12
+ .head { margin-bottom: 10px; }
13
+ .status { margin-top: 8px; padding: 9px 11px; border-radius: var(--radius); background: var(--bg2); font-size: 13px; }
14
+ .status.error { color: var(--danger); }
15
+ .modes { display: flex; gap: 4px; margin-top: 10px; flex-wrap: wrap; }
16
+ .modes .btn.on { background: var(--fg); color: var(--bg); border-color: var(--fg); }
17
+ textarea { width: 100%; min-height: 150px; margin-top: 10px; padding: 10px 11px; border-radius: var(--radius);
18
+ border: 1px solid var(--line); background: var(--bg); color: var(--fg); font: 14px/1.5 var(--font); resize: vertical; }
19
+ .meta { margin-top: 4px; }
20
+ .acts { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
21
+ `;
22
+
23
+ /** Runs in the view. Self-contained: serialized into the page. */
24
+ function main(kit, ui) {
25
+ const { h, duration, actionButton, toast } = ui;
26
+ const app = document.getElementById("app");
27
+ const view = {
28
+ script: "",
29
+ mode: "camera",
30
+ orientation: null,
31
+ opened: false,
32
+ error: null,
33
+ sent: false,
34
+ };
35
+ const MODES = [
36
+ ["camera", "Camera"],
37
+ ["screen", "Screen"],
38
+ ["both", "Screen + camera"],
39
+ ];
40
+ const ORIENTATIONS = [
41
+ ["portrait", "Portrait 9:16"],
42
+ ["landscape", "Landscape 16:9"],
43
+ ];
44
+
45
+ const estimate = (text) => {
46
+ const words = text.split(/\s+/).filter(Boolean).length;
47
+ return { words, readSeconds: Math.round((words / 150) * 60) };
48
+ };
49
+
50
+ async function open() {
51
+ const { data } = await kit.callTool("recording_open_recorder", {
52
+ ...(view.script.trim() ? { script: view.script.trim() } : {}),
53
+ mode: view.mode,
54
+ ...(view.orientation ? { orientation: view.orientation } : {}),
55
+ });
56
+ view.opened = !!data?.opened;
57
+ view.error = null;
58
+ toast("The recorder is open in PandaStudio.");
59
+ render();
60
+ }
61
+
62
+ function render() {
63
+ const est = estimate(view.script);
64
+ const area = h("textarea", {
65
+ "aria-label": "Script for the teleprompter",
66
+ placeholder: "The script to read on camera",
67
+ });
68
+ area.value = view.script;
69
+ area.addEventListener("input", () => {
70
+ view.script = area.value;
71
+ const e = estimate(view.script);
72
+ meta.textContent = e.words
73
+ ? `${e.words} words · about ${duration(e.readSeconds * 1000)} to read`
74
+ : "No script: the recorder opens without the teleprompter.";
75
+ });
76
+ const meta = h(
77
+ "div",
78
+ { class: "small faint meta" },
79
+ est.words
80
+ ? `${est.words} words · about ${duration(est.readSeconds * 1000)} to read`
81
+ : "No script: the recorder opens without the teleprompter.",
82
+ );
83
+ app.replaceChildren(
84
+ h(
85
+ "div",
86
+ { class: "head" },
87
+ h("div", { class: "brand" }, h("i"), "PandaStudio recorder"),
88
+ h(
89
+ "div",
90
+ { class: view.error ? "status error" : "status" },
91
+ view.error ??
92
+ (view.opened
93
+ ? "The recorder is open in PandaStudio with this script in the teleprompter. Press Record in the bar at the bottom of your screen; the script scrolls as you talk."
94
+ : "Opening the recorder in PandaStudio…"),
95
+ ),
96
+ ),
97
+ h(
98
+ "div",
99
+ { class: "modes", role: "radiogroup", "aria-label": "What to record" },
100
+ MODES.map(([id, label]) =>
101
+ h(
102
+ "button",
103
+ {
104
+ type: "button",
105
+ class: view.mode === id ? "btn on" : "btn",
106
+ "aria-pressed": String(view.mode === id),
107
+ onclick: () => {
108
+ view.mode = id;
109
+ render();
110
+ },
111
+ },
112
+ label,
113
+ ),
114
+ ),
115
+ ),
116
+ h(
117
+ "div",
118
+ { class: "modes", role: "radiogroup", "aria-label": "Shape" },
119
+ ORIENTATIONS.map(([id, label]) =>
120
+ h(
121
+ "button",
122
+ {
123
+ type: "button",
124
+ class: view.orientation === id ? "btn on" : "btn",
125
+ "aria-pressed": String(view.orientation === id),
126
+ onclick: () => {
127
+ view.orientation = id;
128
+ render();
129
+ },
130
+ },
131
+ label,
132
+ ),
133
+ ),
134
+ ),
135
+ area,
136
+ meta,
137
+ h(
138
+ "div",
139
+ { class: "acts" },
140
+ actionButton(view.opened ? "Update and reopen" : "Open the recorder", open, {
141
+ primary: !view.opened,
142
+ busy: "Opening…",
143
+ }),
144
+ view.opened
145
+ ? actionButton(
146
+ view.sent ? "Told the assistant" : "I've recorded it",
147
+ async () => {
148
+ if (view.sent) return;
149
+ await kit.sendMessage(
150
+ "I've finished recording in PandaStudio. Find the new take (project_current) and carry on.",
151
+ );
152
+ view.sent = true;
153
+ render();
154
+ },
155
+ { primary: true, busy: "Sending…" },
156
+ )
157
+ : null,
158
+ ),
159
+ );
160
+ }
161
+
162
+ render();
163
+ kit.on("input", (args) => {
164
+ if (typeof args?.script === "string") view.script = args.script;
165
+ if (args?.mode === "camera" || args?.mode === "screen" || args?.mode === "both")
166
+ view.mode = args.mode;
167
+ else view.mode = view.script.trim() ? "camera" : "both";
168
+ if (args?.orientation === "portrait" || args?.orientation === "landscape")
169
+ view.orientation = args.orientation;
170
+ render();
171
+ });
172
+ kit.on("result", (result) => {
173
+ view.error = kit.resultError(result);
174
+ const data = kit.resultData(result) ?? {};
175
+ view.opened = !view.error && !!data.opened;
176
+ if (data.mode) view.mode = data.mode;
177
+ if (data.orientation) view.orientation = data.orientation;
178
+ render();
179
+ });
180
+ kit.on("context", () => render());
181
+ kit.connect().catch((err) => {
182
+ view.error = `Couldn't connect to the chat: ${err?.message ?? err}`;
183
+ render();
184
+ });
185
+ }
186
+
187
+ export function recorderHtml(version) {
188
+ return viewPage({ title: "PandaStudio recorder", version, css: CSS, main });
189
+ }
@@ -0,0 +1,506 @@
1
+ // Shared by the MCP Apps views (edit preview, clips): the host bridge, theme
2
+ // handling and base styles.
3
+ //
4
+ // The views are plain HTML with inline script, so they run in a host's
5
+ // sandboxed iframe with the default CSP (no network, data: images). The
6
+ // functions below are real code serialized with Function.prototype.toString
7
+ // into the page; they must not reference anything outside their own body.
8
+
9
+ /**
10
+ * The view side of MCP Apps (SEP-1865, protocol 2026-01-26): JSON-RPC 2.0
11
+ * over postMessage to the host. Installs `window.PandaKit`.
12
+ *
13
+ * ChatGPT implements the same protocol; when a host doesn't answer
14
+ * ui/initialize but exposes the older `window.openai` bridge, that is used.
15
+ */
16
+ export function installKit(appVersion) {
17
+ const PROTOCOL = "2026-01-26";
18
+ const pending = new Map();
19
+ const listeners = {};
20
+ let nextId = 1;
21
+ let legacy = null;
22
+ const state = {
23
+ hostContext: {},
24
+ hostCapabilities: {},
25
+ toolInput: null,
26
+ toolResult: null,
27
+ };
28
+
29
+ const post = (msg) => window.parent.postMessage(msg, "*");
30
+ const emit = (name, value) => {
31
+ for (const fn of listeners[name] ?? []) {
32
+ try {
33
+ fn(value);
34
+ } catch (err) {
35
+ console.error(err);
36
+ }
37
+ }
38
+ };
39
+
40
+ function request(method, params, timeoutMs = 600_000) {
41
+ const id = nextId++;
42
+ post({ jsonrpc: "2.0", id, method, params });
43
+ return new Promise((resolve, reject) => {
44
+ const timer = setTimeout(() => {
45
+ if (pending.delete(id)) reject(new Error(`${method} timed out`));
46
+ }, timeoutMs);
47
+ pending.set(id, {
48
+ resolve: (v) => {
49
+ clearTimeout(timer);
50
+ resolve(v);
51
+ },
52
+ reject: (e) => {
53
+ clearTimeout(timer);
54
+ reject(e);
55
+ },
56
+ });
57
+ });
58
+ }
59
+ const notify = (method, params) => post({ jsonrpc: "2.0", method, params });
60
+
61
+ function applyContext(ctx) {
62
+ if (!ctx || typeof ctx !== "object") return;
63
+ state.hostContext = { ...state.hostContext, ...ctx };
64
+ const root = document.documentElement;
65
+ if (ctx.theme === "dark" || ctx.theme === "light") {
66
+ root.dataset.theme = ctx.theme;
67
+ root.style.colorScheme = ctx.theme;
68
+ }
69
+ const vars = ctx.styles?.variables;
70
+ if (vars && typeof vars === "object") {
71
+ for (const [k, v] of Object.entries(vars)) {
72
+ if (k.startsWith("--") && typeof v === "string" && v) root.style.setProperty(k, v);
73
+ }
74
+ }
75
+ const fonts = ctx.styles?.css?.fonts;
76
+ if (typeof fonts === "string" && fonts && !document.getElementById("host-fonts")) {
77
+ const style = document.createElement("style");
78
+ style.id = "host-fonts";
79
+ style.textContent = fonts;
80
+ document.head.appendChild(style);
81
+ }
82
+ emit("context", state.hostContext);
83
+ }
84
+
85
+ window.addEventListener("message", (event) => {
86
+ if (event.source !== window.parent) return;
87
+ const msg = event.data;
88
+ if (!msg || msg.jsonrpc !== "2.0") return;
89
+ if (typeof msg.method === "string") {
90
+ if (msg.id !== undefined && msg.id !== null) {
91
+ // A request from the host.
92
+ if (msg.method === "ui/resource-teardown" || msg.method === "ping") {
93
+ emit("teardown");
94
+ post({ jsonrpc: "2.0", id: msg.id, result: {} });
95
+ } else {
96
+ post({
97
+ jsonrpc: "2.0",
98
+ id: msg.id,
99
+ error: { code: -32601, message: `Method not found: ${msg.method}` },
100
+ });
101
+ }
102
+ return;
103
+ }
104
+ const p = msg.params ?? {};
105
+ if (msg.method === "ui/notifications/tool-input") {
106
+ state.toolInput = p.arguments ?? {};
107
+ emit("input", state.toolInput);
108
+ } else if (msg.method === "ui/notifications/tool-result") {
109
+ state.toolResult = p;
110
+ emit("result", p);
111
+ } else if (msg.method === "ui/notifications/tool-cancelled") {
112
+ emit("cancelled", p.reason);
113
+ } else if (msg.method === "ui/notifications/host-context-changed") {
114
+ applyContext(p);
115
+ }
116
+ return;
117
+ }
118
+ const waiter = pending.get(msg.id);
119
+ if (!waiter) return;
120
+ pending.delete(msg.id);
121
+ if (msg.error) waiter.reject(new Error(msg.error.message ?? "Request failed"));
122
+ else waiter.resolve(msg.result);
123
+ });
124
+
125
+ // Keep the iframe as tall as the content (measured like the reference
126
+ // App SDK: html at max-content, once per frame), from initialization on.
127
+ let lastWidth = 0;
128
+ let lastHeight = 0;
129
+ let sizeQueued = false;
130
+ const reportSize = () => {
131
+ if (sizeQueued || legacy) return;
132
+ sizeQueued = true;
133
+ requestAnimationFrame(() => {
134
+ sizeQueued = false;
135
+ const root = document.documentElement;
136
+ const prev = root.style.height;
137
+ root.style.height = "max-content";
138
+ const height = Math.ceil(root.getBoundingClientRect().height);
139
+ root.style.height = prev;
140
+ const width = Math.ceil(window.innerWidth);
141
+ if (width === lastWidth && height === lastHeight) return;
142
+ lastWidth = width;
143
+ lastHeight = height;
144
+ notify("ui/notifications/size-changed", { width, height });
145
+ });
146
+ };
147
+ const watchSize = () => {
148
+ const ro = new ResizeObserver(reportSize);
149
+ ro.observe(document.documentElement);
150
+ ro.observe(document.body);
151
+ reportSize();
152
+ };
153
+
154
+ /** The data of a PandaStudio tool result: structuredContent, else the JSON
155
+ * text block, unwrapped from the automation server's { ok, data }. */
156
+ function resultData(result) {
157
+ if (!result) return null;
158
+ let body = result.structuredContent;
159
+ if (!body) {
160
+ const text = (result.content ?? []).find((c) => c?.type === "text")?.text;
161
+ if (typeof text === "string") {
162
+ try {
163
+ body = JSON.parse(text);
164
+ } catch {
165
+ body = null;
166
+ }
167
+ }
168
+ }
169
+ if (body && typeof body === "object" && "ok" in body && "data" in body) return body.data;
170
+ return body ?? null;
171
+ }
172
+ function resultError(result) {
173
+ if (!result?.isError) return null;
174
+ const data = resultData(result);
175
+ if (data && typeof data === "object" && typeof data.error === "string") return data.error;
176
+ const text = (result.content ?? []).find((c) => c?.type === "text")?.text;
177
+ if (typeof text === "string") {
178
+ try {
179
+ const body = JSON.parse(text);
180
+ if (typeof body?.error === "string") return body.error;
181
+ } catch {
182
+ /* plain text */
183
+ }
184
+ return text.replace(/^tool error: /, "");
185
+ }
186
+ return "Something went wrong";
187
+ }
188
+
189
+ /** Call one of this server's tools; resolves to its data, throws its error. */
190
+ async function callTool(name, args = {}) {
191
+ let result;
192
+ if (legacy) {
193
+ const out = await legacy.callTool(name, args);
194
+ result = out?.content || out?.structuredContent ? out : { structuredContent: out };
195
+ } else {
196
+ result = await request("tools/call", { name, arguments: args });
197
+ }
198
+ const error = resultError(result);
199
+ if (error) throw new Error(error);
200
+ return { data: resultData(result), result };
201
+ }
202
+
203
+ /** Load images the app reported (frames, clip stills) as data: URLs. Paths
204
+ * the server hasn't seen this session come back as null. */
205
+ async function loadImages(paths) {
206
+ const wanted = [...new Set(paths.filter((p) => typeof p === "string" && p))];
207
+ const out = new Map();
208
+ if (!wanted.length) return out;
209
+ const { result } = await callTool("ui_read_media", { paths: wanted });
210
+ const images = (result.content ?? []).filter((c) => c?.type === "image");
211
+ const items = result.structuredContent?.items ?? [];
212
+ for (const item of items) {
213
+ const img = typeof item.image === "number" ? images[item.image] : null;
214
+ out.set(item.path, img ? `data:${img.mimeType};base64,${img.data}` : null);
215
+ }
216
+ return out;
217
+ }
218
+
219
+ const canMessage = () => !legacy && !!state.hostCapabilities.message;
220
+ async function sendMessage(text) {
221
+ if (legacy?.sendFollowUpMessage) return legacy.sendFollowUpMessage({ prompt: text });
222
+ return request("ui/message", { role: "user", content: [{ type: "text", text }] });
223
+ }
224
+ async function updateModelContext(text) {
225
+ if (legacy || !state.hostCapabilities.updateModelContext) return;
226
+ try {
227
+ await request("ui/update-model-context", { content: [{ type: "text", text }] }, 10_000);
228
+ } catch {
229
+ /* best effort */
230
+ }
231
+ }
232
+ const canFullscreen = () =>
233
+ !legacy && (state.hostContext.availableDisplayModes ?? []).includes("fullscreen");
234
+ async function requestDisplayMode(mode) {
235
+ if (legacy?.requestDisplayMode) return legacy.requestDisplayMode({ mode });
236
+ return request("ui/request-display-mode", { mode }, 10_000);
237
+ }
238
+
239
+ function adoptLegacyBridge(openai) {
240
+ legacy = openai;
241
+ state.hostContext = { theme: openai.theme, displayMode: openai.displayMode };
242
+ applyContext(state.hostContext);
243
+ if (openai.toolInput) {
244
+ state.toolInput = openai.toolInput;
245
+ emit("input", state.toolInput);
246
+ }
247
+ const deliver = () => {
248
+ if (!openai.toolOutput) return false;
249
+ state.toolResult = { structuredContent: openai.toolOutput };
250
+ emit("result", state.toolResult);
251
+ return true;
252
+ };
253
+ if (!deliver()) {
254
+ window.addEventListener("openai:set_globals", deliver, { once: true });
255
+ }
256
+ }
257
+
258
+ /** ui/initialize, sent again while unanswered: a host that starts
259
+ * listening after the page loads would otherwise miss it. */
260
+ function initialize() {
261
+ const params = {
262
+ appInfo: { name: "PandaStudio", version: appVersion },
263
+ appCapabilities: { availableDisplayModes: ["inline", "fullscreen"] },
264
+ protocolVersion: PROTOCOL,
265
+ };
266
+ const deadline = Date.now() + (window.openai ? 1500 : 30_000);
267
+ return new Promise((resolve, reject) => {
268
+ let settled = false;
269
+ let tries = 0;
270
+ const attempt = () => {
271
+ if (settled) return;
272
+ tries += 1;
273
+ request("ui/initialize", params, Math.max(250, deadline - Date.now())).then(
274
+ (r) => {
275
+ if (settled) return;
276
+ settled = true;
277
+ resolve(r);
278
+ },
279
+ (err) => {
280
+ if (settled || Date.now() < deadline) return;
281
+ settled = true;
282
+ reject(err);
283
+ },
284
+ );
285
+ if (Date.now() + 500 * tries < deadline) setTimeout(attempt, 500 * tries);
286
+ };
287
+ attempt();
288
+ });
289
+ }
290
+
291
+ async function connect() {
292
+ try {
293
+ const init = await initialize();
294
+ state.hostCapabilities = init?.hostCapabilities ?? {};
295
+ applyContext(init?.hostContext ?? {});
296
+ notify("ui/notifications/initialized", {});
297
+ watchSize();
298
+ } catch (err) {
299
+ if (window.openai) adoptLegacyBridge(window.openai);
300
+ else throw err;
301
+ }
302
+ }
303
+
304
+ window.PandaKit = {
305
+ state,
306
+ on: (name, fn) => {
307
+ if (!listeners[name]) listeners[name] = [];
308
+ listeners[name].push(fn);
309
+ },
310
+ connect,
311
+ callTool,
312
+ loadImages,
313
+ resultData,
314
+ resultError,
315
+ canMessage,
316
+ sendMessage,
317
+ updateModelContext,
318
+ canFullscreen,
319
+ requestDisplayMode,
320
+ toolName: () => state.hostContext.toolInfo?.tool?.name ?? null,
321
+ };
322
+ }
323
+
324
+ /** Small DOM + formatting helpers every view uses (window.PandaUI). */
325
+ export function installUi() {
326
+ const h = (tag, attrs, ...children) => {
327
+ const el = document.createElement(tag);
328
+ for (const [k, v] of Object.entries(attrs ?? {})) {
329
+ if (v === undefined || v === null || v === false) continue;
330
+ if (k === "class") el.className = v;
331
+ else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
332
+ else if (k === "style" && typeof v === "object") {
333
+ for (const [prop, val] of Object.entries(v)) {
334
+ if (prop.startsWith("--")) el.style.setProperty(prop, val);
335
+ else el.style[prop] = val;
336
+ }
337
+ } else el.setAttribute(k, v === true ? "" : String(v));
338
+ }
339
+ for (const c of children.flat()) {
340
+ if (c === null || c === undefined || c === false) continue;
341
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
342
+ }
343
+ return el;
344
+ };
345
+ const duration = (ms) => {
346
+ const total = Math.max(0, Math.round((ms ?? 0) / 1000));
347
+ const hr = Math.floor(total / 3600);
348
+ const m = Math.floor((total % 3600) / 60);
349
+ const s = String(total % 60).padStart(2, "0");
350
+ return hr > 0 ? `${hr}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
351
+ };
352
+ /** A button that disables itself and shows a busy label while `run` works. */
353
+ const actionButton = (label, run, opts = {}) => {
354
+ const btn = h("button", {
355
+ class: opts.primary ? "btn primary" : "btn",
356
+ type: "button",
357
+ title: opts.title,
358
+ });
359
+ btn.textContent = label;
360
+ btn.addEventListener("click", async (e) => {
361
+ e.stopPropagation();
362
+ if (btn.disabled) return;
363
+ btn.disabled = true;
364
+ const prev = btn.textContent;
365
+ if (opts.busy) btn.textContent = opts.busy;
366
+ try {
367
+ await run(btn);
368
+ } catch (err) {
369
+ window.PandaUI.toast(err?.message ?? String(err), true);
370
+ } finally {
371
+ if (btn.isConnected) {
372
+ btn.disabled = false;
373
+ if (btn.textContent === opts.busy) btn.textContent = prev;
374
+ }
375
+ }
376
+ });
377
+ return btn;
378
+ };
379
+ let toastTimer = 0;
380
+ const toast = (text, isError = false) => {
381
+ let el = document.getElementById("toast");
382
+ if (!el) {
383
+ el = h("div", { id: "toast", role: "status" });
384
+ document.body.append(el);
385
+ }
386
+ el.textContent = text;
387
+ el.className = isError ? "show error" : "show";
388
+ clearTimeout(toastTimer);
389
+ toastTimer = setTimeout(() => {
390
+ el.className = "";
391
+ }, 4000);
392
+ };
393
+ window.PandaUI = { h, duration, actionButton, toast };
394
+ }
395
+
396
+ export const BASE_CSS = `
397
+ :root {
398
+ color-scheme: light dark;
399
+ --bg: var(--color-background-primary, #ffffff);
400
+ --bg2: var(--color-background-secondary, #f4f4f5);
401
+ --bg3: var(--color-background-tertiary, #e9e9ec);
402
+ --fg: var(--color-text-primary, #18181b);
403
+ --fg2: var(--color-text-secondary, #52525b);
404
+ --fg3: var(--color-text-tertiary, #8a8a93);
405
+ --line: var(--color-border-primary, rgba(0, 0, 0, 0.12));
406
+ --line2: var(--color-border-secondary, rgba(0, 0, 0, 0.07));
407
+ --danger: var(--color-text-danger, #c2410c);
408
+ --success: var(--color-text-success, #15803d);
409
+ --accent: #ff5a1f;
410
+ --radius: var(--border-radius-md, 8px);
411
+ --radius-lg: var(--border-radius-lg, 12px);
412
+ --font: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
413
+ }
414
+ @media (prefers-color-scheme: dark) {
415
+ :root:not([data-theme="light"]) {
416
+ --bg: var(--color-background-primary, #1c1c1f);
417
+ --bg2: var(--color-background-secondary, #26262a);
418
+ --bg3: var(--color-background-tertiary, #303035);
419
+ --fg: var(--color-text-primary, #f4f4f5);
420
+ --fg2: var(--color-text-secondary, #b4b4bb);
421
+ --fg3: var(--color-text-tertiary, #85858d);
422
+ --line: var(--color-border-primary, rgba(255, 255, 255, 0.14));
423
+ --line2: var(--color-border-secondary, rgba(255, 255, 255, 0.08));
424
+ --danger: var(--color-text-danger, #fb923c);
425
+ --success: var(--color-text-success, #4ade80);
426
+ }
427
+ }
428
+ :root[data-theme="dark"] {
429
+ --bg: var(--color-background-primary, #1c1c1f);
430
+ --bg2: var(--color-background-secondary, #26262a);
431
+ --bg3: var(--color-background-tertiary, #303035);
432
+ --fg: var(--color-text-primary, #f4f4f5);
433
+ --fg2: var(--color-text-secondary, #b4b4bb);
434
+ --fg3: var(--color-text-tertiary, #85858d);
435
+ --line: var(--color-border-primary, rgba(255, 255, 255, 0.14));
436
+ --line2: var(--color-border-secondary, rgba(255, 255, 255, 0.08));
437
+ --danger: var(--color-text-danger, #fb923c);
438
+ --success: var(--color-text-success, #4ade80);
439
+ }
440
+ * { box-sizing: border-box; }
441
+ html, body { margin: 0; padding: 0; }
442
+ body {
443
+ background: var(--bg);
444
+ color: var(--fg);
445
+ font: 14px/1.45 var(--font);
446
+ -webkit-font-smoothing: antialiased;
447
+ }
448
+ #app { padding: 14px; }
449
+ .row { display: flex; align-items: center; gap: 8px; }
450
+ .spread { justify-content: space-between; }
451
+ .wrap { flex-wrap: wrap; }
452
+ .muted { color: var(--fg2); }
453
+ .faint { color: var(--fg3); }
454
+ .small { font-size: 12px; }
455
+ h1 { font-size: 15px; font-weight: 600; margin: 0; line-height: 1.3; }
456
+ .brand { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600;
457
+ letter-spacing: 0.04em; text-transform: uppercase; color: var(--fg3); }
458
+ .brand i { width: 8px; height: 8px; border-radius: 2px; background: var(--accent); display: inline-block; }
459
+ .btn {
460
+ font: inherit; font-size: 13px; font-weight: 500; color: var(--fg); background: var(--bg2);
461
+ border: 1px solid var(--line); border-radius: var(--radius); padding: 5px 11px; cursor: pointer;
462
+ white-space: nowrap;
463
+ }
464
+ .btn:hover:not(:disabled) { background: var(--bg3); }
465
+ .btn:disabled { opacity: 0.55; cursor: default; }
466
+ .btn.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
467
+ .btn.primary:hover:not(:disabled) { opacity: 0.88; background: var(--fg); }
468
+ .btn.danger { color: var(--danger); }
469
+ .btn.ghost { background: transparent; border-color: transparent; padding: 4px 6px; }
470
+ .btn.ghost:hover:not(:disabled) { background: var(--bg2); }
471
+ .chip { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 3px 9px;
472
+ border-radius: 999px; background: var(--bg2); border: 1px solid var(--line2); color: var(--fg2); }
473
+ .chip.strong { color: var(--fg); font-weight: 500; }
474
+ .skeleton { background: linear-gradient(90deg, var(--bg2), var(--bg3), var(--bg2)); background-size: 200% 100%;
475
+ animation: shimmer 1.4s infinite linear; }
476
+ @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
477
+ .bar { height: 4px; border-radius: 2px; background: var(--bg3); overflow: hidden; }
478
+ .bar > div { height: 100%; background: var(--accent); transition: width 0.4s ease; }
479
+ .error { color: var(--danger); }
480
+ #toast { position: fixed; left: 50%; bottom: 12px; transform: translate(-50%, 20px); opacity: 0;
481
+ background: var(--fg); color: var(--bg); padding: 7px 12px; border-radius: var(--radius); font-size: 13px;
482
+ transition: all 0.2s ease; pointer-events: none; max-width: calc(100% - 24px); }
483
+ #toast.show { opacity: 1; transform: translate(-50%, 0); }
484
+ #toast.error { background: var(--danger); color: #fff; }
485
+ `;
486
+
487
+ /** One self-contained view page. `main` runs once the kit is connected. */
488
+ export function viewPage({ title, version, css, main }) {
489
+ return `<!doctype html>
490
+ <html lang="en">
491
+ <head>
492
+ <meta charset="utf-8">
493
+ <meta name="viewport" content="width=device-width, initial-scale=1">
494
+ <title>${title}</title>
495
+ <style>${BASE_CSS}${css}</style>
496
+ </head>
497
+ <body>
498
+ <div id="app"></div>
499
+ <script>
500
+ (${installKit.toString()})(${JSON.stringify(version)});
501
+ (${installUi.toString()})();
502
+ (${main.toString()})(window.PandaKit, window.PandaUI);
503
+ </script>
504
+ </body>
505
+ </html>`;
506
+ }