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
@@ -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.
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.44",
|
||||
"version": "0.8.45",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.118",
|
||||
"version": "0.8.120",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user