fix(studio): render complete interactive gallery previews
This commit is contained in:
1 parent
c765d76a06
commit
9520651330
26 files changed
+5761
-39
No files matched your search
@@ -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
@@ -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) =>
|
||||
({ """: '"', "'": "'", "<": "<", ">": ">", "&": "&" })[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 };
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user