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 -5
View File
@@ -354,7 +354,7 @@
},
"packages/compiler": {
"name": "@wrnexus/compiler",
"version": "0.8.44",
"version": "0.8.45",
"dependencies": {
"@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*",
@@ -391,8 +391,12 @@
},
"packages/design-studio": {
"name": "@wrnexus/design-studio",
"version": "0.1.3",
"version": "0.1.4",
"bin": {
"wrnexus-design-studio": "src/cli.ts",
},
"dependencies": {
"@iconify-json/lucide": "1.2.123",
"@wrnexus/ai": "workspace:*",
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
@@ -405,7 +409,7 @@
},
"packages/design-system": {
"name": "@wrnexus/design-system",
"version": "0.1.0",
"version": "0.1.1",
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
@@ -778,7 +782,7 @@
},
"packages/ui": {
"name": "@wrnexus/ui",
"version": "0.8.118",
"version": "0.8.120",
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/design-system": "workspace:*",
@@ -999,7 +1003,7 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.123", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-0CozmpKXEOEEhltrfT2zt+/t1hez67Y+hM2VJWoIcBKdBrb83VYuKf+b5A0QU9HfQm+RNn2GjFWlTOwi+Ss6IA=="],
"@iconify/tailwind4": ["@iconify/tailwind4@1.2.3", "", { "dependencies": { "@iconify/tools": "^5.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.0" }, "peerDependencies": { "tailwindcss": ">= 4.0.0" } }, "sha512-z8SKiMHRASJKF/IY//87MF88lcB7ulxh8vlhQXXLWsBkNtOh6ese9R41MyGpQeqXdRvQVt+/fX2glQtHFjQ+MA=="],
@@ -2153,10 +2157,20 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
"admin/@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"auth-showcase/@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"basic-app/@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"captcha-showcase/@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"cheerio/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"component-showcase/@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
"data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
@@ -2173,6 +2187,8 @@
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"i18n-showcase/@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -2197,6 +2213,10 @@
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"web/@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"wrnexus-crm-example/@iconify-json/lucide": ["@iconify-json/lucide@1.2.125", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og=="],
"@types/jsdom/parse5/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
@@ -0,0 +1,55 @@
# Studio interactive gallery release
Packages: Studio 0.1.4, design-system 0.1.1, compiler 0.8.45, UI 0.8.120.
## Result
All 104 gallery entries now have explicit sample props and content. Collection
components receive rows, options, messages, tabs, tree nodes or metrics rather
than empty defaults. Layout components receive slot content, including the
conditional templates instantiated after hydration. Composite components render
their nested shipped components instead of leaving empty mount placeholders.
Previews include generated browser functions for the shipped components. Local
Chart.js and Drawflow bundles render their real providers. Icon masks use the
shipped Lucide collection, including its aliases; no CDN or icon font is needed.
The map uses a labeled local schematic and supports marker selection and zoom.
DeviceFrame shows local content and supports rotation. Toaster has a sample
notification trigger; Confetti has room to animate without nested scrollbars.
Large examples span the available gallery width. Sample output events are
visible, and preview links do not navigate the frame to unrelated routes. Forms
and uploads remain demonstrations: they do not submit user data. The editor
reports rendering, readiness, and failures while preserving the draft. Readiness
messages must come from the current preview window and match its document path.
Switch tracks retain pill geometry across presets and older saved recipes. The
component's semantic shape takes precedence over the ordinary control radius.
DeviceFrame now uses the existing motion-base token rather than an undefined
motion-normal token. The visual-contract update contains only those two files.
A generated compiler commit variable shadowed FlowCanvas's `pending` state and
threw during interaction. Commit bookkeeping now uses short names allocated
against authored bindings. Tests cover pending, values, committed and the helper
prefixes, while the generated-module size ratchet continues to pass.
The user authorized including the concurrently edited chart-label improvements.
Painted category ticks shorten at compact widths; complete labels remain in
wrapped tooltips. Existing caller-provided callbacks remain authoritative.
## Verification
- Combined compiler, UI, design-system and Studio run: 548 passing tests.
- TypeScript, public API baseline and UI visual-contract checks pass.
- Gallery tests verify 104 sample cards, local assets, generated functions,
populated slots and absence of unresolved nested component mounts.
- Browser review covers all seven pages, with 16 samples on the first six and
eight on the last. Actual checks include accordion expansion, Switch toggling
and its computed 999px radius, modal content, chart canvas creation, table rows,
graph nodes and selection, Confetti start, data-map selection, map zoom/selection
and a visible notification. No external map/embed access is required.
- Existing draft schema, validation, generation locks and APIs remain unchanged.
Source sample data is adapted from the existing component showcase profiles and
reviewed as fixed Studio fixtures. The package has no runtime dependency on the
component-showcase checkout. New contracts must receive a fixture before shipping.
+2 -2
View File
@@ -29,7 +29,7 @@
"packages/ui/components/DataMap.wrn": "66752f98718c748f25e0eed76d39f504fc20b26d3b3ae841db85f5d5f02df6cb",
"packages/ui/components/DataTable.wrn": "c25aa8849d140562970cd2ffaa33b720cc903e809ef7fb20a564425e8295d5b9",
"packages/ui/components/DatePicker.wrn": "107be1aa2ae2b329b50c9063f2f25bf4ac7dccebe6a2b6ca412cc12afac8f3af",
"packages/ui/components/DeviceFrame.wrn": "20485a867f404098afec5814174e3f83ca7110e193c787027c8b755bb1f5909e",
"packages/ui/components/DeviceFrame.wrn": "ae6a19ee27ba24d5673e3b4fa951bfcf8f12f6ef40bad0e61f349666e13afa64",
"packages/ui/components/Divider.wrn": "3d1065aa2dccb11f31eff35105ce7a3a0321317850e4df02b6c298e4a492cadc",
"packages/ui/components/DragAndDrop.wrn": "5af6c01a9b58eca44868205708f47738c1c2b25589b94035cb2f4f31eec06ba6",
"packages/ui/components/Drawer.wrn": "54f4a07464cc7bcd3f52453207c568d81044fca45850ca2ec1d8b6e6442b24d5",
@@ -103,7 +103,7 @@
"packages/ui/components/select.wrn": "9bb6c0a6238c76ef1b96ecc30eee1e7208abc6b1d195d9f0880244aada465791",
"packages/ui/components/skeleton.wrn": "8bf41be508416c9cb3dc05103bde9ae944499da12ea244fe853749daaed07f3f",
"packages/ui/components/spinner.wrn": "0144152d936a861ffcd3c37f226ede28d31c0dc945f3c3cf85474db5b4335cf9",
"packages/ui/components/switch.wrn": "00a40becd26bc7ddc63954f1c8856b3c9b0a4ad13b78c8db5c86910bc9e260d0",
"packages/ui/components/switch.wrn": "e769639a573798ceb658bf0e901cd4d7612c28f79de09655c6b7c9f1a95c1cf1",
"packages/ui/components/textarea.wrn": "e1402419f3f6bd91b2119a8a49704683cabfba3329a9806c4a78242fd0ff0036",
"packages/ui/components/tooltip.wrn": "2bd977b0b8e6fccfeac4ea96b71d01065a237779243d3b8236db34448ea3b381",
"packages/ui/styles/SelectStyles.wrn": "b84470cf11d6830963adc58c595776d843ed8b126c0fe242dd8e961aace991fa",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.8.44",
"version": "0.8.45",
"type": "module",
"main": "src/index.ts",
"exports": {
+18 -8
View File
@@ -520,20 +520,30 @@ export function generateBrowserModule(ast: PageAst): string {
[...sharedState, ...sharedProps].filter((name) => identifierReferenced(source, name)),
);
}
const sharedCommit = `const values = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const pending = {};
for (const name of Object.keys(values)) {
if (!Object.is(values[name], committed[name])) { pending[name] = values[name]; committed[name] = values[name]; }
const usedCommitNames = new Set([...sharedState, ...sharedProps, ...functionNames]);
const commitName = (base: string) => {
let name = base;
while (usedCommitNames.has(name)) name += "_";
usedCommitNames.add(name);
return name;
};
const commitValues = commitName("__v"),
commitPending = commitName("__p"),
commitSnapshot = commitName("__c");
const sharedCommit = `const ${commitValues} = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const ${commitPending} = {};
for (const name of Object.keys(${commitValues})) {
if (!Object.is(${commitValues}[name], ${commitSnapshot}[name])) { ${commitPending}[name] = ${commitValues}[name]; ${commitSnapshot}[name] = ${commitValues}[name]; }
}
if (Object.keys(pending).length === 0) return;
const apply = () => { for (const name of Object.keys(pending)) context.state[name] = pending[name]; };
if (Object.keys(${commitPending}).length === 0) return;
const apply = () => { for (const name of Object.keys(${commitPending})) context.state[name] = ${commitPending}[name]; };
if (typeof context.batch === "function") context.batch(apply); else apply();`;
const restores = [...sharedState, ...sharedProps]
.map(
(name) =>
`${JSON.stringify(name)}:()=>{${
sharedState.includes(name)
? `${name} = context.state.${name}; committed.${name} = ${name};`
? `${name} = context.state.${name}; ${commitSnapshot}.${name} = ${name};`
: `${name} = context.props.${name};`
}}`,
)
@@ -588,7 +598,7 @@ function __wrnexusCreateClientFunctions(context) {
const props = context.props;
const refs = context.refs;
${apiBindings(ast)}
const committed = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const ${commitSnapshot} = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const __wrnexusCommit = () => { ${sharedCommit} };
const __wrnexusRestores = { ${restores} };
const __wrnexusRestoreKeys = ${JSON.stringify(restoreKeys)};
@@ -11,6 +11,42 @@ const source = `component FunctionDefaults {
}
view { <button @click="choose()">Choose</button> }
}`;
test("browser state named pending or values does not collide with commit bookkeeping", () => {
const source = `component CommitNames {
state pending = "ready"
state values = 1
state committed = false
state __v = 3
state __p = false
state __c = "safe"
functions {
shared function status() { return pending + values + __c + __v }
client function advance() { pending = "done"; values += 1; committed = true }
}
view { <button @click="advance()">Advance</button> }
}`;
const module = generateBrowserModule(parse(source));
const functions = new Function(
module.replace(/\bexport\s+(?=const|function)/g, "") + ";return __wrnexusClientFunctions;",
)();
const context = {
state: { pending: "ready", values: 1, committed: false, __v: 3, __p: false, __c: "safe" },
props: {},
batch: (apply: () => void) => apply(),
};
expect(functions.status(context)).toBe("ready1safe3");
functions.advance(context);
expect(context.state).toEqual({
pending: "done",
values: 2,
committed: true,
__v: 3,
__p: false,
__c: "safe",
});
expect(functions.status(context)).toBe("done2safe3");
});
test("browser functions retain defaults, prior-argument references and prop references", () => {
const module = generateBrowserModule(parse(source));
const functions = new Function(
+6
View File
@@ -75,6 +75,12 @@ Run `bun run scripts/evaluate-design-agents.ts` for the six-page/twelve-preview
## Installed packages and upgrades
The component gallery uses 104 populated, interactive samples. Icons, browser
functions, chart and graph runtimes are included locally. Large examples get
appropriate space; output events appear below the sample. Map and device samples
use local content. Forms/uploads do not submit data, and preview links stay in
the preview. Loading and failure states preserve the current draft.
The preset and comparison menus offer 13 starting systems: Square, Soft, Angular,
Swiss, Forest, Ocean, Rose, Violet, Ember, Editorial, Terminal, Brutalist and
Contrast. Each has light and dark modes and uses the existing component contracts.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/design-studio",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -15,11 +15,12 @@
"./upgrades": "./src/upgrades.ts"
},
"dependencies": {
"@iconify-json/lucide": "1.2.123",
"@wrnexus/ai": "workspace:*",
"@wrnexus/design-system": "workspace:*",
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/csr": "workspace:*",
"@wrnexus/design-system": "workspace:*",
"@wrnexus/ssr": "workspace:*",
"@wrnexus/styles": "workspace:*",
"@wrnexus/ui": "workspace:*"
+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
@@ -0,0 +1,65 @@
import { expect, test } from "bun:test";
import { COMPONENT_CONTRACTS, CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
import { runWithRequestContext } from "@wrnexus/core";
import { gallerySample, previewSlots, previewSampleMarkup } from "../src/gallery.ts";
import { previewIconCss } from "../src/preview-assets.ts";
import { previewDocument } from "../src/preview.ts";
test("every contracted gallery component has an explicit populated standalone sample", () => {
for (const name of Object.keys(COMPONENT_CONTRACTS)) {
const sample = gallerySample(name);
expect(Object.keys(sample.props).length).toBeGreaterThan(0);
expect(sample.hint).toBeTruthy();
expect(JSON.stringify(sample.props)).not.toContain("/showcase-images/");
}
for (const name of ["Accordion", "DataMap", "Tabs", "TreeView", "FeatureGrid", "MetricGrid"]) {
expect((gallerySample(name).props.items as unknown[]).length).toBeGreaterThan(0);
}
expect(gallerySample("DrawflowCanvas").props.nodes).toHaveLength(2);
expect(gallerySample("Map").props.imageSrc).toMatch(/^data:image\/svg\+xml,/);
expect(gallerySample("DeviceFrame").content).toContain("Sample mobile page");
expect(gallerySample("BackToTop").props.alwaysVisible).toBe(true);
});
test("sample slot insertion preserves nested markup and fallback labels", () => {
const html =
'<slot data-wrn-events="open" name="trigger"></slot><slot data-wrn-events="close"></slot><slot name="footer">Fallback</slot>';
const result = previewSlots(
html,
'<div data-slot="trigger"><div><button>Open</button></div></div><p>Body</p>',
);
expect(result).toContain("<div><button>Open</button></div>");
expect(result).toContain("<p>Body</p>");
expect(result).toContain('name="footer">Fallback');
expect(previewSlots('<slot data-wrn-events="click"><span>Save</span></slot>', "")).toContain(
"<span>Save</span>",
);
expect(() =>
runWithRequestContext({} as never, () => previewSampleMarkup("<p>Sample</p>", "")),
).not.toThrow();
});
test("icons embed real local SVG masks, including upstream icon aliases", () => {
const css = previewIconCss("icon-[lucide--code-2] icon-[lucide--check-circle-2]");
expect(css).toContain("data:image/svg+xml,");
expect(css).toContain("stroke");
expect(css).toContain("icon-[lucide--code-2]");
expect(css).not.toMatch(/https?:\/\/[^"\s]*\.svg/);
});
test("all gallery pages include real browser functions and fully rendered nested components", async () => {
let count = 0;
for (let page = 0; page < 7; page++) {
const html = await previewDocument(CORE_DESIGN_SYSTEMS.square, { content: "catalog", page });
count += [...html.matchAll(/<section class="sample" data-gallery-component=/g)].length;
expect(html).toContain("Interactive samples");
expect(html).toContain("bindPreview()");
expect(html).toContain("__wrnexusClientFunctions");
expect(html).not.toMatch(/<div[^>]*\bdata-component=/);
expect(html).toContain("data:image/svg+xml,");
if (page === 0) expect(html).toContain("Supporting content belongs inside the default slot.");
if (page === 2) expect(html).toContain("Sample row 12");
if (page === 5) expect(html).toContain("data-preview-toast");
}
expect(count).toBe(104);
}, 20_000);
+1 -1
View File
@@ -168,7 +168,7 @@ test("catalog previews cover all 104 component defaults and unknown routes do no
});
const { url } = await result.json();
const html = await (await send(url)).text();
count += (html.match(/data-wrn-component=/g) ?? []).length;
count += (html.match(/<section class="sample" data-gallery-component=/g) ?? []).length;
}
expect(count).toBe(104);
expect((await send("/../../package.json")).status).toBe(404);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/design-system",
"version": "0.1.0",
"version": "0.1.1",
"description": "Typed, deterministic design tokens and legacy WrNexus theme integration.",
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -82,7 +82,7 @@ function recipes(angular: boolean, outlined: boolean): Record<string, ComponentR
Select: recipe({ control: field }),
Checkbox: recipe({ control: { borderRadius: ref("shape.control"), borderWidth: "1px" } }),
Radio: recipe({ control: { borderWidth: "1px" } }),
Switch: recipe({ control: { borderRadius: ref("shape.control") } }),
Switch: recipe({ control: { borderRadius: "999px" } }),
Card: recipe({
panel: { ...surface, ...(angular ? { rotate: "1deg" } : {}) },
body: { paddingInline: ref("space.panel"), paddingBlock: ref("space.panel") },
@@ -26,6 +26,17 @@ test("three presets compile deterministically in both modes", () => {
);
}
});
test("switch track stays a pill when control corners change", () => {
for (const original of Object.values(CORE_DESIGN_SYSTEMS)) {
const system = structuredClone(original);
system.tokens["shape.control"]!.value = "0";
for (const mode of ["light", "dark"]) {
const css = compileDesignSystem(system, { mode }).recipeCss;
expect(css).toMatch(/\[data-wrn-owner="Switch"\][^{]*\{[^}]*border-radius:999px/);
}
}
});
test("contracts reject unknown components, parts, axes, states, versions and executable style values", () => {
for (const recipes of [
{ Unknown: { contractVersion: 1 } },
+1 -1
View File
@@ -73,7 +73,7 @@ component DeviceFrame {
border: 0.65rem solid var(--wrn-color-text);
border-radius: 2rem;
background: var(--wrn-color-background);
transition: width var(--wrn-motion-normal) var(--wrn-ease-standard);
transition: width var(--wrn-motion-base) var(--wrn-ease-standard);
}
.wrn-next--device-frame[data-device="tablet"] .wrn-next__device-shell {
+2 -1
View File
@@ -52,7 +52,8 @@ component Switch {
height: 1.375rem;
margin: 0;
border: 1px solid color-mix(in srgb, var(--wrn-color-border-strong, var(--wrn-color-border)) 70%, transparent);
border-radius: 999px;
/* A switch track keeps its semantic pill geometry, including older themes. */
border-radius: 999px !important;
background: var(--wrn-color-surface-muted, var(--wrn-color-surface-2));
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
cursor: pointer;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.8.118",
"version": "0.8.120",
"private": true,
"type": "module",
"main": "src/index.ts",
+17
View File
@@ -1,5 +1,22 @@
import type { ChartConfiguration, ChartData, ChartType } from "chart.js";
/** Shorten only the painted tick. Dataset labels remain intact for tooltips and tables. */
export function chartTickLabel(label: unknown, limit: number): string {
const text = String(label ?? "");
if (text.length <= limit) return text;
const start = Math.ceil((limit - 1) / 2);
return text.slice(0, start) + "…" + text.slice(-(limit - 1 - start));
}
export function chartTooltipLines(label: unknown, limit: number): string[] {
const lines: string[] = [];
for (const part of String(label ?? "").split("\n")) {
if (!part.length) lines.push("");
for (let index = 0; index < part.length; index += limit)
lines.push(part.slice(index, index + limit));
}
return lines;
}
export interface ChartProps {
title?: string;
items?: unknown[];
+37 -1
View File
@@ -1,6 +1,12 @@
import Chart from "chart.js/auto";
import type { ChartConfiguration, Plugin } from "chart.js";
import { chartConfiguration, mergeOptions, type ChartProps } from "./chart-config";
import {
chartConfiguration,
mergeOptions,
chartTickLabel,
chartTooltipLines,
type ChartProps,
} from "./chart-config";
const selector = '[data-wrnexus-runtime~="chartjs"][data-chart-config]';
const mounted = new Map<
@@ -95,6 +101,36 @@ function configuration(root: HTMLElement, props: ChartProps): ChartConfiguration
},
config.options,
);
if (["bar", "line"].includes(config.type)) {
const axis = config.options!.indexAxis === "y" ? "y" : "x";
const interaction = config.options!.interaction;
if (interaction?.mode === "index" && !interaction.axis) interaction.axis = axis;
const scale = ((config.options!.scales ??= {})[axis] ??= {});
if (!scale.type || scale.type === "category") {
const ticks = (scale.ticks ??= {});
if (!ticks.callback)
ticks.callback = function (value) {
return chartTickLabel(
this.getLabelForValue(Number(value)),
this.chart.width < 480 ? 18 : 36,
);
};
}
}
const tooltip = config.options!.plugins && config.options!.plugins.tooltip;
if ((tooltip as unknown) !== false && (config.options!.plugins as unknown) !== false) {
const plugin = (config.options!.plugins ??= {});
const options = (plugin.tooltip ??= {});
const callbacks = (options.callbacks ??= {});
if (!callbacks.title)
callbacks.title = (items) =>
items.length
? chartTooltipLines(
items[0].label,
Math.max(12, Math.min(80, Math.floor((items[0].chart.width - 32) / 8))),
)
: [];
}
config.options!.onClick = (event, elements, chart) => {
if (!elements.length) return;
const { index, datasetIndex } = elements[0];
+15 -1
View File
@@ -1,11 +1,25 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { chartConfiguration, chartTypes } from "../src/chart-config";
import {
chartConfiguration,
chartTypes,
chartTickLabel,
chartTooltipLines,
} from "../src/chart-config";
import { renderComponent, mountHtml } from "../../test/src/index";
import { discoverPlugins, createPluginRunner } from "../../plugin/src/index";
import { resolve } from "node:path";
const source = readFileSync(new URL("../components/Chart.wrn", import.meta.url), "utf8");
test("compact category ticks preserve the complete tooltip text", () => {
const label = "2026-10-17T10:03:00 · completed · audit-four";
expect(chartTickLabel(label, 18)).toHaveLength(18);
expect(chartTickLabel(label, 18)).toContain("…");
expect(chartTickLabel("Short", 18)).toBe("Short");
const lines = chartTooltipLines(label, 18);
expect(lines.every((line) => line.length <= 18)).toBe(true);
expect(lines.join("")).toBe(label);
});
for (const type of chartTypes) {
test(`Chart.js ${type} preserves native datasets`, () => {
const data = {