fix(studio): render complete interactive gallery previews
Quality / quality (ubuntu-latest) (push) Failing after 10m12s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-10-19 17:40:50 +05:30
1 parent c765d76a06
commit 9520651330
26 files changed
+5761 -39

No files matched your search

+25 -1
View File
@@ -26,6 +26,7 @@ let application: { enabled?: boolean; revision: string; previous?: string; direc
};
let page = 0;
let previewGeneration = 0;
let previewLoadTimer: ReturnType<typeof setTimeout>;
let reviewed: { system: DesignSystem; name: string } | undefined;
let activeTab = "foundations";
let noticeTimer: ReturnType<typeof setTimeout>;
@@ -246,6 +247,8 @@ function render() {
}
async function previews() {
const generation = ++previewGeneration;
clearTimeout(previewLoadTimer);
$("preview-status").textContent = "Rendering preview…";
const settings = {
...selection(),
direction: value("preview-direction"),
@@ -281,9 +284,19 @@ async function previews() {
: {}),
}),
),
);
).catch((error) => {
if (generation === previewGeneration)
$("preview-status").textContent =
"Could not update the preview. Your draft is preserved. " + error.message;
throw error;
});
if (generation !== previewGeneration) return;
$<HTMLIFrameElement>("preview-frame").src = result[0].url;
previewLoadTimer = setTimeout(() => {
if (generation === previewGeneration)
$("preview-status").textContent =
"The preview did not finish loading. Use Refresh to try again; your draft is preserved.";
}, 15_000);
if (comparison) $<HTMLIFrameElement>("compare-frame").src = result[1].url;
for (const id of ["preview-frame", "compare-frame"]) $(id).style.width = value("preview-width");
$("preview-label").textContent = reviewed
@@ -293,6 +306,17 @@ async function previews() {
: "Current draft";
$("show-draft").hidden = !reviewed;
}
window.addEventListener("message", (event) => {
const frame = $<HTMLIFrameElement>("preview-frame");
if (
event.source === frame.contentWindow &&
event.data?.type === "wrn-studio-preview-ready" &&
event.data.path === new URL(frame.src).pathname
) {
clearTimeout(previewLoadTimer);
$("preview-status").textContent = "Preview ready";
}
});
async function changed() {
reviewed = undefined;
render();
File diff suppressed because it is too large. Load diff
+160
View File
@@ -0,0 +1,160 @@
import samples from "./gallery-samples.json";
import { resolveControlTemplate } from "@wrnexus/core";
const illustration = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360"><rect width="640" height="360" fill="#dbeafe"/><path d="M0 260L190 100l130 140 120-100 200 160v60H0Z" fill="#2563eb"/><circle cx="470" cy="80" r="36" fill="#f59e0b"/></svg>')}`;
const map = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360"><rect width="640" height="360" fill="#e2e8d5"/><path d="M0 140h640M200 0v360M440 0v360" stroke="#fff" stroke-width="32"/><path d="M0 140h640M200 0v360M440 0v360" stroke="#b7c2ca" stroke-width="2"/><rect x="260" y="210" width="120" height="100" rx="20" fill="#a4c7a1"/></svg>')}`;
function localAssets(value: unknown, key = ""): unknown {
if (typeof value === "string")
return /^(?:https?:\/\/|\/showcase-images\/)/.test(value) &&
/src|image|avatar|poster|logo/i.test(key)
? illustration
: /^https?:\/\//.test(value) && /href|action/i.test(key)
? "#sample"
: value;
if (Array.isArray(value)) return value.map((entry) => localAssets(entry, key));
if (value && typeof value === "object")
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, localAssets(v, k)]));
return value;
}
/** Fixed, populated sample content; never contains draft-supplied executable markup. */
export function gallerySample(name: string) {
const source = (samples as Record<string, { props: Record<string, unknown>; content: string }>)[
name
];
if (!source) throw Error(`Missing gallery sample: ${name}`);
const props = localAssets(structuredClone(source.props)) as Record<string, unknown>;
const field = [
"Input",
"Textarea",
"Select",
"Checkbox",
"Radio",
"Switch",
"AdvancedSelect",
"ComboBox",
"DatePicker",
"TimePicker",
"RangeSlider",
"InputNumber",
"PinInput",
"FileInput",
"StrongPassword",
"TogglePassword",
].includes(name);
if (Object.hasOwn(props, "id") || field) props.id = `sample-${name.toLowerCase()}`;
if (field && !props.name) props.name = `sample-${name.toLowerCase()}`;
props.class = "";
let content = source.content;
let hint = "Sample data. Interact with the controls to try this component.";
if (name === "Map") {
Object.assign(props, {
title: "Sample location map",
description: "Schematic sample: select a marker or use the zoom controls.",
imageSrc: map,
imageAlt: "Schematic sample map",
items: [
{ label: "Office", x: 32, y: 42 },
{ label: "Park", x: 68, y: 70 },
],
});
hint = "Local schematic sample; no map service or API key is needed.";
}
if (name === "DrawflowCanvas" || name === "FlowCanvas")
Object.assign(props, {
nodes: [
{
id: "start",
label: "Start",
x: 30,
y: 50,
input: false,
ports: [{ key: "next", label: "Next", target: "review" }],
},
{
id: "review",
label: "Review",
x: 260,
y: 100,
ports: [],
},
],
palette: [{ kind: "task", label: "Add task" }],
});
if (name === "Toaster")
content = '<button type="button" data-preview-toast>Show sample notification</button>';
if (name === "BackToTop") {
props.threshold = 0;
props.alwaysVisible = true;
props.showLabel = true;
hint = "Scroll this preview, then use the back-to-top control.";
}
if (name === "DeviceFrame") {
props.items = [];
content =
"<article><h2>Sample mobile page</h2><p>This local content sits inside the device frame.</p><h3>Account preferences</h3><p>Rotate the preview to compare portrait and landscape layouts.</p></article>";
}
if (name === "VisuallyHidden")
hint = "This accessibility component intentionally hides its content visually.";
if (name === "CustomScrollbar")
content = Array.from(
{ length: 12 },
(_, i) => `<p>Sample row ${i + 1} — scroll to see more content.</p>`,
).join("");
if (["AdvancedSelect", "Checkbox", "Select"].includes(name)) delete props.values;
if (["AuthForm", "AuthSplitLayout", "FileInput"].includes(name))
hint = "Preview only. Submissions and file uploads are not sent anywhere.";
return { props, content, hint };
}
export function previewSlots(html: string, content: string) {
const named = new Map<string, string>();
// Author-controlled sample slots are the only HTML accepted here.
let defaultContent = "";
let cursor = 0;
const slots = /<div data-slot="([\w-]+)">/g;
for (let match = slots.exec(content); match; match = slots.exec(content)) {
defaultContent += content.slice(cursor, match.index);
const tags = /<\/?div\b[^>]*>/g;
tags.lastIndex = slots.lastIndex;
let depth = 1;
let end = tags.lastIndex;
for (let tag = tags.exec(content); tag; tag = tags.exec(content)) {
depth += tag[0].startsWith("</") ? -1 : 1;
if (depth === 0) {
named.set(match[1]!, content.slice(slots.lastIndex, tag.index));
end = tags.lastIndex;
break;
}
}
if (depth !== 0) throw Error("Unbalanced sample slot markup.");
cursor = end;
slots.lastIndex = end;
}
defaultContent += content.slice(cursor);
return html.replace(/<slot\b([^>]*)>([\s\S]*?)<\/slot>/g, (all, attributes) => {
const name = /\bname=["']([^"']+)["']/.exec(attributes)?.[1];
const replacement = name ? named.get(name) : defaultContent;
return replacement ? `<slot${attributes}>${replacement}</slot>` : all;
});
}
/** Conditional/loop bodies are request-owned templates, and need the same slots as SSR. */
export function previewSampleMarkup(html: string, content: string) {
const seen = new Set<string>();
const visit = (markup: string): string => {
for (const match of markup.matchAll(/data-wrn-(?:if|each)="(@[^"]+)"/g)) {
if (seen.has(match[1]!)) continue;
seen.add(match[1]!);
const definition = resolveControlTemplate(match[1]!);
for (const entry of Array.isArray(definition) ? definition : [definition]) {
if (!entry || typeof entry !== "object") continue;
for (const key of ["body", "empty"]) {
if (typeof entry[key] === "string") entry[key] = visit(entry[key]);
}
}
}
return previewSlots(markup, content);
};
return visit(html);
}
@@ -0,0 +1,57 @@
import lucide from "@iconify-json/lucide/icons.json";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
/** Inline the real shipped icon shapes: preview frames never need an icon CDN. */
export function previewIconCss(markup: string) {
const names = new Set([...markup.matchAll(/icon-\[lucide--([\w-]+)\]/g)].map((m) => m[1]!));
// Controllers introduce these icons after interaction.
for (const name of ["check", "x", "chevron-down", "chevron-up", "loader-circle"]) names.add(name);
const icons = lucide.icons as Record<string, { body: string }>;
return [...names]
.map((name) => {
const aliases = lucide.aliases as Record<string, { parent: string }>;
let key = name;
const visited = new Set<string>();
while (!icons[key] && aliases[key] && !visited.has(key)) {
visited.add(key);
key = aliases[key]!.parent;
}
const icon = icons[key];
if (!icon) throw Error(`Unknown preview icon: lucide--${name}`);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">${icon.body}</svg>`;
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
return `[class~="icon-[lucide--${name}]"]{display:inline-block;width:1em;height:1em;flex-shrink:0;background-color:currentColor;mask:${url} center/contain no-repeat;-webkit-mask:${url} center/contain no-repeat}`;
})
.join("\n");
}
const runtimeCache = new Map<string, Promise<string>>();
export async function previewProviderRuntime(name: "chart" | "drawflow") {
if (!runtimeCache.has(name))
runtimeCache.set(
name,
(async () => {
const result = await Bun.build({
entrypoints: [fileURLToPath(import.meta.resolve(`@wrnexus/ui/${name}-runtime`))],
target: "browser",
format: "iife",
minify: true,
});
if (!result.success) throw Error(result.logs.join("\n"));
return result.outputs[0]!.text();
})().catch((error) => {
runtimeCache.delete(name);
throw error;
}),
);
return runtimeCache.get(name)!;
}
export function previewDrawflowCss() {
const require = createRequire(import.meta.resolve("@wrnexus/ui/drawflow-runtime"));
return (
readFileSync(require.resolve("drawflow/dist/drawflow.min.css"), "utf8") +
readFileSync(fileURLToPath(import.meta.resolve("@wrnexus/ui/drawflow.css")), "utf8")
);
}
@@ -0,0 +1,87 @@
import { resolveControlTemplate } from "@wrnexus/core";
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
import { previewSampleMarkup } from "./gallery.ts";
/** Resolve shipped component mounts in both visible markup and request-owned branches. */
export async function preparePreviewMarkup(
body: string,
render: (name: string, props: Record<string, unknown>) => Promise<string>,
) {
const prepared = new Set<string>();
const prototypes = new Map<string, string>();
const names = new Set<string>();
const decode = (value: string) =>
value.replace(
/&(?:quot|#39|lt|gt|amp);/g,
(entity) =>
({ "&quot;": '"', "&#39;": "'", "&lt;": "<", "&gt;": ">", "&amp;": "&" })[entity]!,
);
const visit = async (markup: string, depth = 0): Promise<string> => {
if (depth > 30) throw Error("Preview component composition exceeded its depth limit.");
for (const match of markup.matchAll(/data-wrn-hydration="([^:"]+):/g)) names.add(match[1]!);
for (const match of markup.matchAll(/data-wrn-(?:if|each)="(@[^"]+)"/g)) {
if (prepared.has(match[1]!)) continue;
prepared.add(match[1]!);
const definition = resolveControlTemplate(match[1]!);
for (const entry of Array.isArray(definition) ? definition : [definition])
if (entry && typeof entry === "object")
for (const key of ["body", "empty"])
if (typeof entry[key] === "string") entry[key] = await visit(entry[key], depth + 1);
}
const mounts = /<div\b([^>]*\bdata-component="([\w-]+)"[^>]*)>/g;
let output = "",
cursor = 0;
for (let match = mounts.exec(markup); match; match = mounts.exec(markup)) {
output += markup.slice(cursor, match.index);
const tags = /<\/?div\b[^>]*>/g;
tags.lastIndex = mounts.lastIndex;
let nesting = 1,
end = tags.lastIndex,
close = end;
for (let tag = tags.exec(markup); tag; tag = tags.exec(markup)) {
nesting += tag[0].startsWith("</") ? -1 : 1;
if (!nesting) {
close = tag.index;
end = tags.lastIndex;
break;
}
}
if (nesting) throw Error("Unbalanced component preview mount.");
const name = [...Object.keys(COMPONENT_CONTRACTS), "SelectStyles"].find(
(n) =>
n.toLowerCase().replace(/[-_]/g, "") === match[2]!.toLowerCase().replace(/[-_]/g, ""),
);
if (!name) throw Error(`Unknown nested preview component: ${match[2]}`);
const props: Record<string, string> = {};
for (const attr of match[1]!.matchAll(/([\w-]+)="([^"]*)"/g)) {
if (attr[1] === "data-component" || attr[1]!.startsWith("data-wrn-")) continue;
const value = decode(attr[2]!);
if (/^\{[\s\S]*\}$/.test(value)) {
try {
JSON.parse(value);
} catch {
continue;
}
}
props[attr[1]!] = value;
}
const inner = markup.slice(mounts.lastIndex, close);
const key = JSON.stringify([name, props, inner]);
if (!prototypes.has(key))
prototypes.set(key, previewSampleMarkup(await render(name, props), inner));
const bindings = [
...match[1]!.matchAll(/\s(data-wrn-(?:prop-bind-[\w-]+|loop-locals)="[^"]*")/g),
]
.map((m) => " " + m[1])
.join("");
const rendered = prototypes
.get(key)!
.replace(/(<[^>]*\bdata-scope="[^"]*")/, "$1" + bindings);
output += await visit(rendered, depth + 1);
cursor = end;
mounts.lastIndex = end;
}
return output + markup.slice(cursor);
};
return { body: await visit(body), names };
}
+52 -11
View File
@@ -1,10 +1,13 @@
import { validateAgentPage } from "./agent-pages.ts";
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createHash } from "node:crypto";
import { compileWrnFile } from "@wrnexus/compiler";
import { compileWrnFile, generateBrowserModule, parse } from "@wrnexus/compiler";
import { gallerySample, previewSampleMarkup } from "./gallery.ts";
import { preparePreviewMarkup } from "./preview-composition.ts";
import { previewIconCss, previewProviderRuntime, previewDrawflowCss } from "./preview-assets.ts";
import { runWithRequestContext } from "@wrnexus/core";
import { REACTIVE_RUNTIME } from "@wrnexus/csr";
import { renderDocument } from "@wrnexus/ssr";
@@ -17,16 +20,31 @@ import {
} from "@wrnexus/design-system";
const modules = new Map<string, Promise<{ render: (props: Record<string, unknown>) => string }>>();
const controllers = new Map<string, string>();
const iconSources = new Map<string, string>();
const escape = (text: string) =>
text.replace(
/[&<>"']/g,
(char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!,
);
async function component(name: string, props: Record<string, unknown> = {}) {
if (!Object.hasOwn(COMPONENT_CONTRACTS, name)) throw Error("Unknown component.");
if (!Object.hasOwn(COMPONENT_CONTRACTS, name) && name !== "SelectStyles")
throw Error("Unknown component.");
if (!modules.has(name)) {
const sourcePath = uiComponentPath(name);
const sourcePath =
name === "SelectStyles"
? join(dirname(uiComponentPath("AdvancedSelect")), "../styles/SelectStyles.wrn")
: uiComponentPath(name);
const source = readFileSync(sourcePath, "utf8");
const ast = parse(source);
iconSources.set(ast.name, source);
const browser = generateBrowserModule(ast);
if (/^import\s/m.test(browser))
throw Error(`Preview controller has an unresolved import: ${name}`);
controllers.set(
ast.name,
`(()=>{${browser.replace(/\bexport\s+(?=(?:const|function)\b)/g, "")}\nreturn {bindClientScope,__wrnexusClientFunctions,__wrnexusImportedBindings};})()`,
);
const directory = join(tmpdir(), "wrnexus-design-studio");
mkdirSync(directory, { recursive: true });
const target = join(
@@ -60,15 +78,19 @@ export async function previewDocument(
const mode = options.mode ?? system.defaultMode ?? "light";
return runWithRequestContext({} as never, async () => {
let body: string;
let galleryIcons = "";
if (options.content === "catalog") {
const page = Math.max(0, Math.min(6, options.page ?? 0));
const names = Object.keys(COMPONENT_CONTRACTS).slice(page * 16, (page + 1) * 16);
const cards = [];
for (const name of names)
for (const name of names) {
const sample = gallerySample(name);
galleryIcons += JSON.stringify(sample.props) + sample.content;
cards.push(
`<section class="sample"><h2>${escape(name)}</h2>${await component(name)}</section>`,
`<section class="sample" data-gallery-component="${name}"><h2>${escape(name)}</h2><p class="sample-hint">${escape(sample.hint)}</p><div class="sample-stage">${previewSampleMarkup(await component(name, sample.props), sample.content)}${name === "Toaster" ? sample.content : ""}</div><output class="sample-event" aria-live="polite"></output></section>`,
);
body = `<h1>Component gallery</h1><p>Page ${page + 1} of 7 · Default states</p><div class="gallery">${cards.join("")}</div>`;
}
body = `<h1>Component gallery</h1><p>Page ${page + 1} of 7 · Interactive samples</p><div class="gallery">${cards.join("")}</div>`;
} else {
const nav = await component("Nav", {
items: [
@@ -135,14 +157,33 @@ export async function previewDocument(
});
body = `${nav}${heading}<div class="layout"><section id="profile">${card}<form>${input}${select}${textarea}<div id="preferences">${toggle}${checkbox}</div><div class="actions">${button}${disabled}${loading}</div></form></section><aside>${alert}<h2>More components</h2>${radio}${number}<div class="actions">${modal}</div></aside></div>`;
}
const runtime = REACTIVE_RUNTIME;
const prepared = await preparePreviewMarkup(body, component);
body = prepared.body;
const names = prepared.names;
const definitions = [...names]
.filter((name) => controllers.has(name))
.map((name) => `${JSON.stringify(name)}:${controllers.get(name)}`)
.join(",");
const bindings = `const previewModules={${definitions}};function bindPreview(){document.querySelectorAll('[data-wrn-hydration]').forEach(el=>{const name=el.getAttribute('data-wrn-hydration').split(':')[0];if(previewModules[name])el.__wrnexusClientModule=previewModules[name];});}bindPreview();new MutationObserver(bindPreview).observe(document.documentElement,{childList:true,subtree:true});`;
let providers = "";
let providerCss = "";
if (body.includes("data-chart-config=")) providers += await previewProviderRuntime("chart");
if (body.includes("data-drawflow-editor")) {
providers += await previewProviderRuntime("drawflow");
providerCss += previewDrawflowCss();
}
const runtime =
bindings +
REACTIVE_RUNTIME +
providers +
"\ndocument.addEventListener('DOMContentLoaded',()=>parent.postMessage({type:'wrn-studio-preview-ready',path:location.pathname},'*'),{once:true});";
return renderDocument({
meta: { title: system.name + " preview" },
body,
htmlAttrs: ` dir="${options.direction === "rtl" ? "rtl" : "ltr"}" data-theme="${escape(mode)}"`,
extraHead: `<style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{padding:24px;margin:0;font-family:var(--wrn-font-sans);color:var(--wrn-color-text);background:var(--wrn-color-bg);line-height:1.5}h1{font-size:28px}h2{font-size:18px}.layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px}.layout>*,form{min-width:0;display:grid;gap:16px;align-content:start}.actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.sample{min-width:0;overflow:auto;border:1px solid var(--wrn-color-border);padding:16px}@media(max-width:650px){.layout{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}</style>`,
extraHead: `<style>${uiCss()}${renderThemeCss(theme)}${previewIconCss(body + galleryIcons + [...names].map((name) => iconSources.get(name) ?? "").join(""))}${providerCss}*{box-sizing:border-box}body{padding:24px;margin:0;font-family:var(--wrn-font-sans);color:var(--wrn-color-text);background:var(--wrn-color-bg);line-height:1.5}h1{font-size:28px}h2{font-size:18px}.layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px}.layout>*,form{min-width:0;display:grid;gap:16px;align-content:start}.actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.sample{min-width:0;border:1px solid var(--wrn-color-border);padding:20px;align-self:start}.sample-stage{min-width:0;position:relative}.sample-hint{font-size:12px;color:var(--wrn-color-text-muted);margin:0 0 16px}.sample-event{display:block;font-size:12px;min-height:1.5em;margin-top:12px}.sample canvas{max-width:100%}.sample [data-ui-component="BackToTop"]{position:static;inset:auto}.sample .showcase-slot,.sample .showcase-visual-panel{padding:20px;border:1px dashed var(--wrn-color-border);background:var(--wrn-color-surface-2)}.sample [data-gallery-component="DeviceFrame"]{min-width:0}.sample[data-gallery-component="Confetti"] .sample-stage{overflow:clip;padding:60px 16px;min-height:180px}.sample:is([data-gallery-component="SplitHero"],[data-gallery-component="FeatureGrid"],[data-gallery-component="MetricGrid"],[data-gallery-component="AnnouncementBar"],[data-gallery-component="CTASection"],[data-gallery-component="Chart"],[data-gallery-component="DataTable"],[data-gallery-component="DrawflowCanvas"],[data-gallery-component="FlowCanvas"],[data-gallery-component="Hero"],[data-gallery-component="Navbar"],[data-gallery-component="PortalDashboard"],[data-gallery-component="PublicPageShell"],[data-gallery-component="AuthSplitLayout"]){grid-column:1/-1}@media(max-width:650px){.layout{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}</style>`,
componentRecipeCss: theme.recipeCss,
extraBody: `<script>${runtime.replace(/<\/script/gi, "<\\/script")}\ndocument.addEventListener('submit',e=>e.preventDefault());</script>`,
extraBody: `<script>${runtime.replace(/<\/script/gi, "<\\/script")}\ndocument.addEventListener('submit',e=>e.preventDefault());document.addEventListener('click',e=>{const link=e.target.closest('a[href]');if(link){e.preventDefault();const card=link.closest('[data-gallery-component]');if(card)card.querySelector('.sample-event').textContent='Preview link: '+link.getAttribute('href');const href=link.getAttribute('href');if(href?.startsWith('#'))document.getElementById(href.slice(1))?.scrollIntoView({block:'nearest'});}if(e.target.closest('[data-preview-toast]'))window.toast.success('This is a sample notification.',{title:'Preview notification'});});document.querySelectorAll('[data-gallery-component]').forEach(card=>{['input','change','select','open','close','toggle','start','complete','add','connect','move'].forEach(name=>card.addEventListener(name,()=>{card.querySelector('.sample-event').textContent='Last event: '+name;}));});</script>`,
});
});
}
@@ -165,7 +206,7 @@ export async function previewAgentPage(
meta: { title: page.title },
body: `<main class="wrn-agent-page"><h1>${escape(page.title)}</h1>${body.join("")}</main>`,
htmlAttrs: ` data-theme="${escape(options.mode ?? system.defaultMode ?? "light")}"`,
extraHead: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; form-action 'none'; base-uri 'none'"><style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{margin:0}.wrn-agent-page{display:grid;min-width:0;padding:var(--wrn-space-panel);gap:var(--wrn-space-panel);color:var(--wrn-color-text);background:var(--wrn-color-bg);font-family:var(--wrn-font-sans)}</style>`,
extraHead: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; form-action 'none'; base-uri 'none'"><style>${uiCss()}${renderThemeCss(theme)}${previewIconCss(body.join(""))}*{box-sizing:border-box}body{margin:0}.wrn-agent-page{display:grid;min-width:0;padding:var(--wrn-space-panel);gap:var(--wrn-space-panel);color:var(--wrn-color-text);background:var(--wrn-color-bg);font-family:var(--wrn-font-sans)}</style>`,
componentRecipeCss: theme.recipeCss,
});
});
+1 -1
View File
@@ -23,6 +23,6 @@ ${guidanceHtml}
.map(([id, system]) => `<option value="${id}">${system.name}</option>`)
.join(
"",
)}</select></label></div><p class="help">Previews use isolated frames. Invalid drafts keep the last valid design. Gallery shows default component states; provider regions retain their own styling.</p><div class="frames"><div><p id="preview-label">Current draft</p><div class="frame-scroll"><iframe id="preview-frame" title="Current design preview" sandbox="allow-scripts"></iframe></div></div><div id="comparison" hidden><p>Comparison</p><div class="frame-scroll"><iframe id="compare-frame" title="Comparison design preview" sandbox="allow-scripts"></iframe></div></div></div></section></main><div id="message" role="status" aria-live="polite"></div></body></html>`;
)}</select></label></div><p class="help">Previews use isolated frames. Invalid drafts keep the last valid design. Gallery uses populated interactive samples. Forms and uploads do not send data.</p><p id="preview-status" role="status" aria-live="polite">Loading preview…</p><div class="frames"><div><p id="preview-label">Current draft</p><div class="frame-scroll"><iframe id="preview-frame" title="Current design preview" sandbox="allow-scripts"></iframe></div></div><div id="comparison" hidden><p>Comparison</p><div class="frame-scroll"><iframe id="compare-frame" title="Comparison design preview" sandbox="allow-scripts"></iframe></div></div></div></section></main><div id="message" role="status" aria-live="polite"></div></body></html>`;
export const studioCss = `${guidanceCss}:root{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;color:#171719;background:#fff;line-height:1.45;font-size:14px;--accent:#002fa7}*{box-sizing:border-box}body{margin:0}header{display:flex;align-items:center;gap:10px;min-height:72px;padding:16px 24px;border-bottom:1px solid #d9d9de;position:sticky;top:0;background:white;z-index:2}.brand{text-decoration:none;color:inherit;font-size:17px;letter-spacing:-.5px;margin-right:auto}.brand strong{margin-left:8px}#save-state{font-size:12px;color:#555;max-width:230px}button,input,select,textarea{font:inherit}button{padding:9px 12px;border:1px solid #c8c8d0;background:#fff;color:#171719;cursor:pointer;border-radius:0}button:hover{border-color:var(--accent);color:var(--accent)}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}main{display:grid;grid-template-columns:235px minmax(340px,.9fr) minmax(420px,1.1fr);min-height:calc(100vh - 72px)}aside{border-right:1px solid #d9d9de;padding:28px 20px}.eyebrow{font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:#666;margin:0 0 12px}h1{font-size:31px;line-height:1.08;letter-spacing:-1.4px;font-weight:500;margin:0 0 16px}h2{font-size:23px;line-height:1.2;letter-spacing:-.7px;font-weight:500;margin:0}h3{font-weight:500}.intro,.help,.footnote{color:#63636d;font-size:12px}.intro{margin-bottom:28px;max-width:28em}.footnote{margin-top:32px}label{display:block;font-size:12px;margin:12px 0;color:#3d3d45}input,select,textarea{display:block;width:100%;margin-top:6px;padding:9px;border:1px solid #c8c8d0;border-radius:0;background:white;color:#171719;min-width:0}textarea{font-family:Consolas,monospace;font-size:12px;tab-size:2;resize:vertical}nav{display:grid;margin:28px -20px 0}nav button{text-align:left;border:0;border-top:1px solid #e2e2e7;padding:15px 20px}nav button[aria-current=page]{border-left:3px solid var(--accent);padding-left:17px;color:var(--accent);background:#f7f7f8}.workspace{min-width:0;padding:28px 24px;border-right:1px solid #d9d9de}.workspace-title,.preview-heading{display:flex;justify-content:space-between;gap:12px;align-items:start;margin-bottom:24px}.badge{font-size:11px;border:1px solid #c8c8d0;padding:5px 8px;white-space:nowrap}.row{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.row>label{flex:1;min-width:100px}.row>button{margin-bottom:12px}.token-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0 18px}.token{border-top:1px solid #e2e2e7;padding:10px 0;min-width:0}.token label{margin:0}.token small{font-size:11px;display:block;overflow-wrap:anywhere;color:#686870;margin-top:7px}.token .token-name{font-weight:600;font-size:12px}.token-actions{display:flex;gap:8px}.token-actions input{flex:1}.swatch{width:18px;height:18px;border:1px solid #999;display:inline-block;vertical-align:middle;margin-right:6px}.preview{min-width:0;padding:28px 24px;background:#f7f7f8;align-self:start;position:sticky;top:72px;max-height:calc(100vh - 72px);overflow:auto}.preview-controls{display:flex;flex-wrap:wrap;gap:10px}.preview-controls label{flex:1;min-width:100px;margin-top:0}.frames{display:grid;gap:16px}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr 1fr}.frames>div{min-width:0}.frame-scroll{overflow:auto;background:white;border:1px solid #d9d9de}iframe{border:0;width:100%;height:680px;display:block;background:white}.frames p{font-size:12px}details{border-top:1px solid #d9d9de;padding-top:16px;margin-top:24px}summary{cursor:pointer}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;background:#f7f7f8;padding:12px;max-height:350px;overflow:auto}#diagnostics:not(:empty){border-left:3px solid #b72032;padding:8px 12px;margin-bottom:20px;color:#8c1526;font-size:12px}#message:not(:empty){position:fixed;bottom:16px;left:24px;max-width:min(650px,calc(100vw - 48px));padding:14px 20px;background:#171719;color:white;z-index:4;white-space:pre-wrap}#contract{font-size:12px}.editor[hidden],[hidden]{display:none!important}#revisions li{margin-bottom:10px}#revisions button{text-align:left;width:100%}@media(min-width:1600px){main{grid-template-columns:250px minmax(470px,.8fr) minmax(600px,1.2fr)}}@media(max-width:1150px){main{grid-template-columns:210px minmax(0,1fr)}.preview{grid-column:2;position:static;max-height:none}.workspace{border-right:0}aside{grid-row:span 2}}@media(max-width:650px){header{flex-wrap:wrap;padding:12px}.brand{width:100%}#save-state{margin-right:auto}main{display:block}aside{border-right:0;padding:24px}h1 br{display:none}nav{display:flex;overflow:auto;margin:24px -24px 0}nav button{white-space:nowrap;padding:12px}.workspace,.preview{padding:24px}aside .intro,aside .footnote{display:none}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr}.token- Line truncated