Compare commits
13
Commits
59f1d9d28a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5244eab15 | ||
|
|
7ef475119a | ||
|
|
e0298ac471 | ||
|
|
ae35ac6714 | ||
|
|
5259668006 | ||
|
|
3539dff45c | ||
|
|
bb40dffee8 | ||
|
|
f642a84c58 | ||
|
|
7c1fcd19d4 | ||
|
|
9520651330 | ||
|
|
c765d76a06 | ||
|
|
ba752e556d | ||
|
|
65d9c844d6 |
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:*",
|
||||
@@ -376,7 +376,7 @@
|
||||
},
|
||||
"packages/csr": {
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.74",
|
||||
"version": "0.8.75",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.8.12",
|
||||
},
|
||||
@@ -391,8 +391,12 @@
|
||||
},
|
||||
"packages/design-studio": {
|
||||
"name": "@wrnexus/design-studio",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.10",
|
||||
"bin": {
|
||||
"wrnexus-design-studio": "src/cli.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "1.2.123",
|
||||
"@wrnexus/ai": "workspace:*",
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
@@ -405,11 +409,11 @@
|
||||
},
|
||||
"packages/design-system": {
|
||||
"name": "@wrnexus/design-system",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.4",
|
||||
},
|
||||
"packages/dev-server": {
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.83",
|
||||
"version": "0.8.84",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/cache": "workspace:*",
|
||||
@@ -778,7 +782,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.118",
|
||||
"version": "0.8.122",
|
||||
"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.
|
||||
@@ -14,3 +14,5 @@ APIs, design schemas, browser drafts and generation contracts are unchanged.
|
||||
Private package publication and the standalone component site's dependency pin
|
||||
are updated as part of this fix. The VM must install the updated lockfile,
|
||||
rebuild and restart before the live preview error can disappear.
|
||||
|
||||
Published @wrnexus/design-studio@0.1.1 with restricted access to the WorkRoot private registry. Registry integrity matches the built tarball: sha512-BV8O34W/ey+izIgGuFevq+c0c3vjxB75KqHPhVaALt97X8I1FUvALzzODIhUyIMOSMlHGq0qaTW+2LQvbeUYyg==
|
||||
@@ -0,0 +1,39 @@
|
||||
# Studio 0.1.3: presets and proposal previews
|
||||
|
||||
The Studio now offers 13 presets in both its starting and comparison menus.
|
||||
The three existing engine presets and public engine API remain unchanged. Ten
|
||||
Studio choices add distinct palettes, fonts, corner shapes and panel treatments.
|
||||
All 13 validate and compile in light and dark mode; the preview regression test
|
||||
renders all 26 combinations using real contracted components.
|
||||
|
||||
The first valid generated alternative automatically appears in the main preview.
|
||||
Review switches that preview without modifying the draft. Its label identifies
|
||||
the reviewed proposal; Show current draft returns to the saved design. Accept
|
||||
still creates an ordinary recoverable, undoable revision. New designs start from
|
||||
the selected preset instead of an empty token map. Existing empty drafts remain
|
||||
untouched and can still be edited or replaced explicitly.
|
||||
|
||||
Provider instructions now explicitly describe flat dotted token names, complete
|
||||
typed token objects for additions, and mode overrides. A regression test rejects
|
||||
nested color groups on an empty draft, then validates three repaired proposals.
|
||||
Validation, lock checks, the single repair attempt and deadlines remain enforced.
|
||||
|
||||
## Live APIMart verification
|
||||
|
||||
On October 19, the deployed 60-second configuration returned three valid proposals
|
||||
in one APIMart attempt using gpt-6-luna: Square — 0px, Rounded — 8px, Soft — 16px.
|
||||
The brief requested a booking website and these three specific control shapes.
|
||||
Their frames rendered in the deployed Studio. The exported generation report was
|
||||
independently checked: three distinct canonical designs, zero schema diagnostics,
|
||||
and successful compilation of every proposal in both light and dark modes.
|
||||
No server key was copied or inspected.
|
||||
|
||||
Earlier broader requests in this session produced an invalid nested token group
|
||||
and a provider failure. They preserved the draft and prior alternatives. The
|
||||
successful constrained request establishes real provider connectivity and valid
|
||||
output, not universal success for arbitrary briefs. The improved provider prompt
|
||||
and main-preview behavior require deploying 0.1.3 before live verification.
|
||||
|
||||
Browser checks of the new local client cover automatic first-proposal preview,
|
||||
switching reviewed proposals, acceptance and undo. HTTP success alone is not
|
||||
treated as proof of visible preview rendering.
|
||||
@@ -3,7 +3,7 @@
|
||||
"releaseLine": "0.8",
|
||||
"artifacts": {
|
||||
"packages/ui/components/Accordion.wrn": "51714cc2103e4727fc64a5e6cb37f060d5b495c72e1660c1a04948982b7f4c53",
|
||||
"packages/ui/components/AdvancedSelect.wrn": "b812acc74cd717af6f102d29fd07b47de2b2ad6e3879706730e0a32eada6f3ee",
|
||||
"packages/ui/components/AdvancedSelect.wrn": "4b867a384feb3c089804d7c7f20bb7b152ffc650ece503480d109a532e7ef3a7",
|
||||
"packages/ui/components/AnnouncementBar.wrn": "b2b833350eb9a37627e4047f371c12ff7a8c25385a70294c0fc25ddfd4bb74c8",
|
||||
"packages/ui/components/AuthForm.wrn": "8a7bf17ee92d98caae1e987eeade29d0effc89b0eca4b8787dc10b2abad25de8",
|
||||
"packages/ui/components/AuthSplitLayout.wrn": "e5ca3b96f68fbc666d47ad2efac9f6c37fe321fcf222109aec5f05f4433b99d6",
|
||||
@@ -17,19 +17,19 @@
|
||||
"packages/ui/components/Carousel.wrn": "5d8bd031007e7522c81f6acb16538534230cb8ea5fad4eef12b10db5f109c67a",
|
||||
"packages/ui/components/Chart.wrn": "d87254752284ba10eb6f36b8654872b0defe84222c01c739c0086809e05dba12",
|
||||
"packages/ui/components/ChatBubble.wrn": "f81c2630542b7c8a171acf2c9102a91b64cc06f925a014bc7f96d3778adbafdc",
|
||||
"packages/ui/components/Clipboard.wrn": "fcc4e8a3b3a24b599eb6a52337a31d06c2c4dee3b1a000281d324b8b71678f1b",
|
||||
"packages/ui/components/Clipboard.wrn": "0fb6a19dbcd3b20c28154c9482839ff32b221059c2b1125578d3edeb13cbe6ef",
|
||||
"packages/ui/components/Collapse.wrn": "c5ecae44b455498e5eae527100abdc27bf9e18f7a361ce563d45087c0cc4a3d3",
|
||||
"packages/ui/components/ColorPicker.wrn": "561b1cef73ff86f50072fefabade212b0cf10a48e6dd6c6aa93fd6e900fe3f50",
|
||||
"packages/ui/components/Columns.wrn": "c892798d910745453b617a7f0dcf20ab1fd6506095c150de0f36e9fcf7661783",
|
||||
"packages/ui/components/Combobox.wrn": "1268286b6dcb00f187d985d5e7fedf0371c8febf04c16907beb10cf36ced2d25",
|
||||
"packages/ui/components/Combobox.wrn": "2096855460f58c250a056037ae3355496b0cdbf179f33baa8c2a55d7532ca60b",
|
||||
"packages/ui/components/Confetti.wrn": "91f72aa66babd721d5b8406992b13df442cd3d2a093540b0e504380242479ca6",
|
||||
"packages/ui/components/Container.wrn": "eefa6f3dfaecb1e86172f6f2b886adfad7dcacf07f75098f7d07948bff5d0a50",
|
||||
"packages/ui/components/ContextMenu.wrn": "5dcf067343e35307c1e0de1c319112e6741bcc9b1c827cef5d09721383f4d066",
|
||||
"packages/ui/components/CustomScrollbar.wrn": "5325c9429b432b113e232a19786bb3f873454f8f4c7c0d6a3ecf81897fa35f09",
|
||||
"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/DataTable.wrn": "eb2e2cfe1eb5306b85261199a833605213e0d2094f3823a5acfedd30147144a1",
|
||||
"packages/ui/components/DatePicker.wrn": "7ab1567a878bb78bb7c3eac637236c7ca157e942a79ac9262b1a78f3190d6c98",
|
||||
"packages/ui/components/DeviceFrame.wrn": "ae6a19ee27ba24d5673e3b4fa951bfcf8f12f6ef40bad0e61f349666e13afa64",
|
||||
"packages/ui/components/Divider.wrn": "3d1065aa2dccb11f31eff35105ce7a3a0321317850e4df02b6c298e4a492cadc",
|
||||
"packages/ui/components/DragAndDrop.wrn": "5af6c01a9b58eca44868205708f47738c1c2b25589b94035cb2f4f31eec06ba6",
|
||||
"packages/ui/components/Drawer.wrn": "54f4a07464cc7bcd3f52453207c568d81044fca45850ca2ec1d8b6e6442b24d5",
|
||||
@@ -41,7 +41,7 @@
|
||||
"packages/ui/components/FileInput.wrn": "76b0719609ff2204cccbad9692a3b820ce63ee98754f2394be6dbd9b8510e873",
|
||||
"packages/ui/components/FileUploadProgress.wrn": "4f4dbaf9eb165d9aa48742c03ce51d895891bb234f80b2865bce03d4da0e24ba",
|
||||
"packages/ui/components/FlowCanvas.wrn": "8ca776800047ae4f4e1032352f2e17a295c9b8198d48d7ce3fc9ee26c2033ecd",
|
||||
"packages/ui/components/Footer.wrn": "ea931e624eb820beaef698e93191ed707403ea8b4be4117fa5b83d5a6b0b0f55",
|
||||
"packages/ui/components/Footer.wrn": "989bae53515398e24fbea19a3020dcd802d1e58d82d02a9b40a7752eb08a1833",
|
||||
"packages/ui/components/Grid.wrn": "b44e041a627cf0f62f676b83ca3c0ea2b671e07876f0649310081edefde4217a",
|
||||
"packages/ui/components/Hero.wrn": "dbc4cb3099d422edc2a7bc0500499db5c7c67aa272cbe9329a21e6d665aca047",
|
||||
"packages/ui/components/HeroActions.wrn": "f3e633751e1bdfca11d5fe3d4eb96d4aaba11cba298a62794ed3e2201d3ca038",
|
||||
@@ -52,7 +52,7 @@
|
||||
"packages/ui/components/LayoutSplitter.wrn": "394a765f26c6634d4d75cf91b47b2874bee2250f15f399c325b740fc671cb1ad",
|
||||
"packages/ui/components/LegendIndicator.wrn": "6d72694259ad877ae987f260887a1d60301f692bf05622d9a36b3a7f02b57dd3",
|
||||
"packages/ui/components/Link.wrn": "59b7590d68ddbc8c70dd1926ebeed22dbe7b3d0d60613eb74d156633808d9db9",
|
||||
"packages/ui/components/List.wrn": "79ba3e76767a8ffccfc6a25c1d4d13e765cfaf2ef02c961d0490b385fe199036",
|
||||
"packages/ui/components/List.wrn": "14c4690cd600de9035f5a979bd29749eea31059de2f6bef70cd38543fb1130dc",
|
||||
"packages/ui/components/ListGroup.wrn": "40c1e8f3030fdc41101f65a06ef8752e52a9e88d85f2c85773fa1544bb6527b9",
|
||||
"packages/ui/components/Map.wrn": "aba6b73a89fe69f84a245d6685771cd7f34e813ca4c54d45a46c2e96135ecb70",
|
||||
"packages/ui/components/MarketingSectionHeader.wrn": "b05e7fa1341fbd633145a702ab07fe04b163194bd2513f14b572eb63e09348ea",
|
||||
@@ -62,13 +62,13 @@
|
||||
"packages/ui/components/MetricGrid.wrn": "c9f228b4b6bd371ad94c1e534501b9708ea555bef1e64c8bdf95fa5e07576d1d",
|
||||
"packages/ui/components/Modal.wrn": "7ad50502de6f6ff20ec2dc62fdc923f78afc222509771c5f26e5a56810ead374",
|
||||
"packages/ui/components/Nav.wrn": "dd7d67b15ffe88fb991321a3707c82770bc0c14ced5cc6f2710adf7ef2045c6c",
|
||||
"packages/ui/components/Navbar.wrn": "02081bcbbf88f9fbbaff7348ad7183d914a424c8fe22ed3c870b61620c2920fa",
|
||||
"packages/ui/components/Navbar.wrn": "22b2b22befd57605d3fa8aa0714d46230790720ea12425dbe642feaef6a47675",
|
||||
"packages/ui/components/PageHeader.wrn": "7586f4579031e9391d673eb413dfe2ca65f9e3da2a0257c6f987bfc3b3482927",
|
||||
"packages/ui/components/Pagination.wrn": "df6faf0dbafd1249092b45e586159b575c3d37c73d81847376a28c3ea8fdb5cd",
|
||||
"packages/ui/components/PinInput.wrn": "c17b2a1bb38ba0420de7c9179e64fd104cbd8d3b84127a308a4333d46232b3fb",
|
||||
"packages/ui/components/Popover.wrn": "aa73c63bde88d6f52061a98fdc8c84f1147e784abfeb802f094a89c061b9a893",
|
||||
"packages/ui/components/PortalDashboard.wrn": "ef67fdf9faac850ef34b1d2564386d41a0cfa4c754ed0988e85f2e7ea2f50cdb",
|
||||
"packages/ui/components/PreferenceSwitcher.wrn": "b1a5a72997dec35a1294f8a7724abc52ba9e50a108e175da9442816ca9adf0ec",
|
||||
"packages/ui/components/PortalDashboard.wrn": "342d001689a386533bb4f864e93557460d051466eca010b8aa46ee7586c85cb0",
|
||||
"packages/ui/components/PreferenceSwitcher.wrn": "09a70986d444d5985277e3cbff5d90804ea3136c0b23ee8426daa0c0d6b41351",
|
||||
"packages/ui/components/PublicPageShell.wrn": "1e28f4f387f235198a723ed13b0128fe484894e4962b73a482a5a28c8acf003f",
|
||||
"packages/ui/components/RangeSlider.wrn": "a4005dcca80e3e23b6488e865b92486b368f46e00d3f05e7b0663d421652be06",
|
||||
"packages/ui/components/Rating.wrn": "8456d577da017eac6e54db78b4beced73930da6fe8ef7a5b74c65c2902571ea9",
|
||||
@@ -84,7 +84,7 @@
|
||||
"packages/ui/components/StyledIcon.wrn": "64ac0aeaa9f0b833f68943dc0116acc1d0a9c8816d3b4bb2c91d00cf7ec3dc0a",
|
||||
"packages/ui/components/Tabs.wrn": "462b8d821dd4390213fc6e75263d30d2b0f9a3d19f7b83053ae41f780c2c2671",
|
||||
"packages/ui/components/TextLink.wrn": "3d8ec57f065fbb12a2ba42e85f1d66ff24adf041e8a1ec4b59cc3f1a17004f4e",
|
||||
"packages/ui/components/TimePicker.wrn": "1695e1cc66f1c41f61bd194db0ba8abfba45a528ca414bb700272a1884f908de",
|
||||
"packages/ui/components/TimePicker.wrn": "385a90f2f6db134ab86450bc2a3e60f2237b511989ce3c8b1bdc2bfe1f0a8276",
|
||||
"packages/ui/components/Timeline.wrn": "1f46194e222b7eb04750785542a8933028fb4d23a94f199874ffe27d9dc74e79",
|
||||
"packages/ui/components/Toaster.wrn": "e46b15818d8f50467521e58c5dae605871c107c09e80e8dade60da675b40b8e6",
|
||||
"packages/ui/components/ToggleCount.wrn": "b95bc9967d40d954427caea544c1b465f30dede623ed8d25b0aefee5b2453910",
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.74",
|
||||
"version": "0.8.75",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -3244,6 +3244,16 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
function clampAnchored(element) {
|
||||
// Measure the untouched position; the offset is recomputed from scratch.
|
||||
element.style.translate = "";
|
||||
// Restore authored limits before measuring, including after a resize.
|
||||
if (element.__wrnAnchorLimits) {
|
||||
var saved = element.__wrnAnchorLimits;
|
||||
element.style.maxWidth = saved[0];
|
||||
element.style.maxHeight = saved[1];
|
||||
element.style.minWidth = saved[2];
|
||||
element.style.minHeight = saved[3];
|
||||
element.style.overflow = saved[4];
|
||||
element.style.boxSizing = saved[5];
|
||||
}
|
||||
var arrow = element.querySelector("[data-wrn-anchor-arrow]");
|
||||
if (arrow) arrow.style.translate = "";
|
||||
if (!isRenderedElement(element)) return;
|
||||
@@ -3251,6 +3261,38 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var rect = element.getBoundingClientRect();
|
||||
var viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
var visual = window.visualViewport;
|
||||
var left = (visual ? visual.offsetLeft : 0) + ANCHOR_MARGIN;
|
||||
var top = (visual ? visual.offsetTop : 0) + ANCHOR_MARGIN;
|
||||
var right = left + (visual ? visual.width : viewportWidth) - ANCHOR_MARGIN * 2;
|
||||
var bottom = top + (visual ? visual.height : viewportHeight) - ANCHOR_MARGIN * 2;
|
||||
// A preview, sidebar or scroll container may clip well before the screen edge.
|
||||
for (var parent = element.parentElement; parent && parent !== document.documentElement; parent = parent.parentElement) {
|
||||
var css = window.getComputedStyle(parent);
|
||||
var clipX = /auto|scroll|hidden|clip/.test(css.overflowX || css.overflow);
|
||||
var clipY = /auto|scroll|hidden|clip/.test(css.overflowY || css.overflow);
|
||||
if (!clipX && !clipY) continue;
|
||||
var bounds = parent.getBoundingClientRect();
|
||||
if (clipX) {
|
||||
left = Math.max(left, bounds.left + parent.clientLeft + ANCHOR_MARGIN);
|
||||
right = Math.min(right, bounds.left + parent.clientLeft + parent.clientWidth - ANCHOR_MARGIN);
|
||||
}
|
||||
if (clipY) {
|
||||
top = Math.max(top, bounds.top + parent.clientTop + ANCHOR_MARGIN);
|
||||
bottom = Math.min(bottom, bounds.top + parent.clientTop + parent.clientHeight - ANCHOR_MARGIN);
|
||||
}
|
||||
}
|
||||
if (right <= left || bottom <= top) return;
|
||||
if (!element.__wrnAnchorLimits) element.__wrnAnchorLimits = [element.style.maxWidth, element.style.maxHeight, element.style.minWidth, element.style.minHeight, element.style.overflow, element.style.boxSizing];
|
||||
if (rect.width > right - left || rect.height > bottom - top) {
|
||||
element.style.boxSizing = "border-box";
|
||||
element.style.minWidth = "0px";
|
||||
element.style.minHeight = "0px";
|
||||
element.style.maxWidth = Math.min(rect.width, right - left) + "px";
|
||||
element.style.maxHeight = Math.min(rect.height, bottom - top) + "px";
|
||||
element.style.overflow = "auto";
|
||||
rect = element.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/*
|
||||
* Clamp only while the overlay's own trigger is on screen.
|
||||
@@ -3263,7 +3305,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
* one very much does need moving; skipping it left the menu invisible
|
||||
* with no way to reach it. The trigger tells the two apart.
|
||||
*/
|
||||
var host = element.closest("[data-ui-component]") || element.parentElement;
|
||||
var host = element.parentElement && element.parentElement.closest("[data-wrn-menu]") || element.closest("[data-ui-component]") || element.parentElement;
|
||||
var anchor = host ? host.getBoundingClientRect() : rect;
|
||||
var anchorVisible =
|
||||
anchor.bottom > 0 &&
|
||||
@@ -3278,17 +3320,20 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var shiftX = 0;
|
||||
var shiftY = 0;
|
||||
|
||||
if (rect.width < viewportWidth - ANCHOR_MARGIN * 2) {
|
||||
if (rect.left < ANCHOR_MARGIN) shiftX = ANCHOR_MARGIN - rect.left;
|
||||
else if (rect.right > viewportWidth - ANCHOR_MARGIN) {
|
||||
shiftX = viewportWidth - ANCHOR_MARGIN - rect.right;
|
||||
if (rect.width <= right - left) {
|
||||
if (rect.left < left) shiftX = left - rect.left;
|
||||
else if (rect.right > right) {
|
||||
shiftX = right - rect.right;
|
||||
}
|
||||
}
|
||||
|
||||
if (rect.height < viewportHeight - ANCHOR_MARGIN * 2) {
|
||||
if (rect.top < ANCHOR_MARGIN) shiftY = ANCHOR_MARGIN - rect.top;
|
||||
else if (rect.bottom > viewportHeight - ANCHOR_MARGIN) {
|
||||
shiftY = viewportHeight - ANCHOR_MARGIN - rect.bottom;
|
||||
if (rect.height <= bottom - top) {
|
||||
if (rect.top < top) shiftY = top - rect.top;
|
||||
else if (rect.bottom > bottom) {
|
||||
// Prefer opening above the trigger when there is enough room.
|
||||
shiftY = anchor.top - rect.height - ANCHOR_MARGIN >= top && rect.top >= anchor.bottom
|
||||
? anchor.top - rect.height - ANCHOR_MARGIN - rect.top
|
||||
: bottom - rect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3392,6 +3437,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
window.addEventListener("resize", scheduleAnchoredReposition);
|
||||
window.addEventListener("scroll", scheduleAnchoredReposition, true);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener("resize", scheduleAnchoredReposition);
|
||||
window.visualViewport.addEventListener("scroll", scheduleAnchoredReposition);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||
|
||||
function fixture(panel: Record<string, number>, clipped = false) {
|
||||
const win = new Window();
|
||||
win.document.body.innerHTML =
|
||||
'<div id="clip"><div data-ui-component="ComboBox"><div data-wrn-anchored></div></div></div>';
|
||||
const element = win.document.querySelector("[data-wrn-anchored]")! as any;
|
||||
const host = element.parentElement;
|
||||
const rect = (left: number, top: number, width: number, height: number) => ({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
});
|
||||
host.getBoundingClientRect = () => rect(270, 170, 40, 30);
|
||||
element.getBoundingClientRect = () =>
|
||||
rect(
|
||||
panel.left,
|
||||
panel.top,
|
||||
Math.min(panel.width, parseFloat(element.style.maxWidth) || Infinity),
|
||||
Math.min(panel.height, parseFloat(element.style.maxHeight) || Infinity),
|
||||
);
|
||||
if (clipped) {
|
||||
const clip = win.document.querySelector("#clip")! as any;
|
||||
clip.style.overflow = "hidden";
|
||||
clip.getBoundingClientRect = () => rect(100, 50, 220, 220);
|
||||
Object.defineProperty(clip, "clientWidth", { value: 220 });
|
||||
Object.defineProperty(clip, "clientHeight", { value: 220 });
|
||||
}
|
||||
const start = REACTIVE_RUNTIME.indexOf(" function isRenderedElement(");
|
||||
const end = REACTIVE_RUNTIME.indexOf(" function repositionAnchored(", start);
|
||||
const clamp = new Function(
|
||||
"window",
|
||||
"document",
|
||||
"ANCHOR_MARGIN",
|
||||
REACTIVE_RUNTIME.slice(start, end) + "; return clampAnchored;",
|
||||
)(win, win.document, 8);
|
||||
return { win, element, clamp };
|
||||
}
|
||||
|
||||
test("anchored panels fit clipping containers and positioning does not drift", async () => {
|
||||
const { win, element, clamp } = fixture({ left: 40, top: 208, width: 180, height: 100 }, true);
|
||||
clamp(element);
|
||||
expect(element.style.translate).toBe("68px -146px");
|
||||
clamp(element);
|
||||
expect(element.style.translate).toBe("68px -146px");
|
||||
await win.happyDOM.close();
|
||||
});
|
||||
|
||||
test("oversized panels become scrollable and recover authored sizing when space returns", async () => {
|
||||
const { win, element, clamp } = fixture({ left: 40, top: 60, width: 500, height: 400 }, true);
|
||||
element.style.minWidth = "300px";
|
||||
clamp(element);
|
||||
expect(element.style.maxWidth).toBe("204px");
|
||||
expect(element.style.maxHeight).toBe("204px");
|
||||
expect(element.style.overflow).toBe("auto");
|
||||
element.parentElement.parentElement.style.overflow = "visible";
|
||||
clamp(element);
|
||||
expect(element.style.minWidth).toBe("300px");
|
||||
expect(element.style.maxWidth).toBe("");
|
||||
await win.happyDOM.close();
|
||||
});
|
||||
@@ -75,4 +75,21 @@ 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.
|
||||
The original three engine presets and existing saved drafts retain their formats.
|
||||
Create new seeds an editable design from the selected preset (Swiss when none is selected).
|
||||
|
||||
Generation automatically previews the first valid proposal in the large preview.
|
||||
Reviewing another proposal switches that preview and labels it as an unchanged
|
||||
draft review. Use **Show current draft** to return, or **Use reviewed proposal**
|
||||
to save the design as an undoable draft revision. Export/apply remains separate.
|
||||
|
||||
The staged package ships an installed `wrnexus-design-studio` binary, a prebuilt browser client and explicit server/agent subpaths. MCP consumes Studio as an optional peer. See [RELEASE.md](RELEASE.md) for installed usage, verified shared design bundles, upgrade planning, migrations, recovery and the release gate. The new package/upgrade APIs are programmatic; existing Studio authoring controls and APIs remain available.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/design-studio",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.10",
|
||||
"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:*"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { setupManual } from "./manual-editor.ts";
|
||||
import { handlePreviewClipboard } from "./preview-clipboard.ts";
|
||||
import { STUDIO_PRESETS } from "./presets.ts";
|
||||
import { setupGuidance } from "./guidance.ts";
|
||||
import { StudioSession, importDesign, exportDesign, reviewDesign } from "./session.ts";
|
||||
import {
|
||||
COMPONENT_CONTRACTS,
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
TOKEN_TYPES,
|
||||
type DesignSystem,
|
||||
type ComponentDesignContract,
|
||||
@@ -26,7 +28,9 @@ let application: { enabled?: boolean; revision: string; previous?: string; direc
|
||||
};
|
||||
let page = 0;
|
||||
let previewGeneration = 0;
|
||||
let activeTab = "foundations";
|
||||
let previewLoadTimer: ReturnType<typeof setTimeout>;
|
||||
let reviewed: { system: DesignSystem; name: string } | undefined;
|
||||
let activeTab = "manual";
|
||||
let noticeTimer: ReturnType<typeof setTimeout>;
|
||||
function message(text: string) {
|
||||
$("message").textContent = text;
|
||||
@@ -90,7 +94,7 @@ function selection() {
|
||||
}
|
||||
function tab(name: string) {
|
||||
activeTab = name;
|
||||
for (const id of ["guided", "foundations", "components", "source", "history", "review"])
|
||||
for (const id of ["manual", "guided", "foundations", "components", "source", "history", "review"])
|
||||
$(id === "review" ? "review-panel" : id).hidden = id !== name;
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>("[data-tab]")
|
||||
@@ -99,6 +103,7 @@ function tab(name: string) {
|
||||
);
|
||||
$("editor-title").textContent =
|
||||
{
|
||||
manual: "Manual design",
|
||||
guided: "Guided design",
|
||||
foundations: "Foundations",
|
||||
components: "Components",
|
||||
@@ -192,7 +197,7 @@ function renderProperties() {
|
||||
typeof raw === "object" ? "@" + raw.ref : String(raw ?? "");
|
||||
}
|
||||
function render() {
|
||||
$<HTMLSelectElement>("preset").value = Object.hasOwn(CORE_DESIGN_SYSTEMS, session.valid.id)
|
||||
$<HTMLSelectElement>("preset").value = Object.hasOwn(STUDIO_PRESETS, session.valid.id)
|
||||
? session.valid.id
|
||||
: "";
|
||||
$("system-name").setAttribute("value", session.valid.name);
|
||||
@@ -242,9 +247,12 @@ function render() {
|
||||
: "Export-only workspace. Enable local apply by starting the Studio with --app-root <existing application folder>.";
|
||||
tab(activeTab);
|
||||
guidance?.render();
|
||||
manual?.render();
|
||||
}
|
||||
async function previews() {
|
||||
const generation = ++previewGeneration;
|
||||
clearTimeout(previewLoadTimer);
|
||||
$("preview-status").textContent = "Rendering preview…";
|
||||
const settings = {
|
||||
...selection(),
|
||||
direction: value("preview-direction"),
|
||||
@@ -254,12 +262,12 @@ async function previews() {
|
||||
const comparison = value("compare");
|
||||
$("comparison").hidden = !comparison;
|
||||
const systems = [
|
||||
session.valid,
|
||||
reviewed?.system ?? session.valid,
|
||||
...(comparison
|
||||
? [
|
||||
comparison === "applied"
|
||||
? session.applied
|
||||
: CORE_DESIGN_SYSTEMS[comparison as keyof typeof CORE_DESIGN_SYSTEMS],
|
||||
: STUDIO_PRESETS[comparison as keyof typeof STUDIO_PRESETS],
|
||||
]
|
||||
: []),
|
||||
];
|
||||
@@ -280,16 +288,48 @@ 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 = session.diagnostics.length
|
||||
? "Last valid draft · source has errors"
|
||||
: "Current draft";
|
||||
$("preview-label").textContent = reviewed
|
||||
? `Reviewing ${reviewed.name} · draft unchanged`
|
||||
: session.diagnostics.length
|
||||
? "Last valid draft · source has errors"
|
||||
: "Current draft";
|
||||
$("show-draft").hidden = !reviewed;
|
||||
}
|
||||
window.addEventListener("message", (event) => {
|
||||
void handlePreviewClipboard(
|
||||
event,
|
||||
[$<HTMLIFrameElement>("preview-frame"), $<HTMLIFrameElement>("compare-frame")],
|
||||
(text) => navigator.clipboard.writeText(text),
|
||||
navigator.userActivation?.isActive ?? false,
|
||||
);
|
||||
|
||||
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();
|
||||
await previews();
|
||||
}
|
||||
@@ -328,10 +368,10 @@ on("redo", async () => {
|
||||
await changed();
|
||||
});
|
||||
on("new", async () => {
|
||||
session.replace(
|
||||
{ schemaVersion: 1, id: "my-design", name: "My design", version: "1.0.0", tokens: {} },
|
||||
"Created new system",
|
||||
);
|
||||
const system = structuredClone(STUDIO_PRESETS[value("preset")] ?? STUDIO_PRESETS.swiss!);
|
||||
system.id = "my-design";
|
||||
system.name = "My design";
|
||||
session.replace(system, "Created new system");
|
||||
await changed();
|
||||
});
|
||||
on(
|
||||
@@ -339,7 +379,7 @@ on(
|
||||
async () => {
|
||||
if (!value("preset")) return;
|
||||
session.replace(
|
||||
CORE_DESIGN_SYSTEMS[value("preset") as keyof typeof CORE_DESIGN_SYSTEMS],
|
||||
STUDIO_PRESETS[value("preset") as keyof typeof STUDIO_PRESETS],
|
||||
"Selected " + value("preset"),
|
||||
);
|
||||
await changed();
|
||||
@@ -641,6 +681,10 @@ for (let i = 0; i < 7; i++) {
|
||||
}
|
||||
$("preview-content").closest(".preview-controls")!.after(pagination);
|
||||
on("refresh-preview", previews);
|
||||
on("show-draft", async () => {
|
||||
reviewed = undefined;
|
||||
await previews();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
@@ -663,9 +707,24 @@ window.addEventListener("beforeunload", () => {
|
||||
}
|
||||
} else session.persist();
|
||||
});
|
||||
const manual = setupManual(session, {
|
||||
message,
|
||||
changed,
|
||||
mode: (mode) => {
|
||||
const select = $<HTMLSelectElement>("preview-mode");
|
||||
const next = mode === "base" ? "" : mode;
|
||||
if (select.value === next) return;
|
||||
select.value = next;
|
||||
previews().catch((e) => message(e.message));
|
||||
},
|
||||
});
|
||||
const guidance = setupGuidance(session, {
|
||||
message,
|
||||
changed,
|
||||
review: async (system, name) => {
|
||||
reviewed = system ? { system, name: name ?? system.name } : undefined;
|
||||
await previews();
|
||||
},
|
||||
preview: async (system) => (await request("/api/preview", { system, content: "composed" })).url,
|
||||
});
|
||||
render();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,196 @@
|
||||
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") {
|
||||
props.max = 1;
|
||||
content =
|
||||
'<button type="button" class="preview-action" data-preview-toast>Show sample notification</button>';
|
||||
}
|
||||
if (name === "PreferenceSwitcher")
|
||||
Object.assign(props, {
|
||||
themeLabel: "Theme",
|
||||
colorLabel: "Accent color",
|
||||
languageLabel: "Language",
|
||||
});
|
||||
if (name === "Tooltip")
|
||||
Object.assign(props, {
|
||||
variant: "soft",
|
||||
title: "Helpful information",
|
||||
description: "Hover or focus a control to see a short explanation.",
|
||||
});
|
||||
if (name === "PortalDashboard")
|
||||
Object.assign(props, {
|
||||
title: "Sample service dashboard",
|
||||
description: "Sample tasks, shortcuts and recent activity.",
|
||||
tasks: [
|
||||
{
|
||||
label: "Review submitted documents",
|
||||
detail: "Confirm the required attachments before approval.",
|
||||
status: "urgent",
|
||||
meta: "Due today",
|
||||
href: "#review",
|
||||
},
|
||||
{
|
||||
label: "Update contact information",
|
||||
detail: "Check the applicant’s preferred email address.",
|
||||
status: "warning",
|
||||
meta: "In progress",
|
||||
href: "#contact",
|
||||
},
|
||||
],
|
||||
});
|
||||
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,72 @@
|
||||
/** Read gateway failures without exposing an HTML error page as a JSON parse error. */
|
||||
export async function generationResponse(response: Response): Promise<any> {
|
||||
const text = await response.text();
|
||||
let value: any;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw Error(
|
||||
[502, 503, 504].includes(response.status)
|
||||
? `The deployment gateway could not complete this request (HTTP ${response.status}). Try again shortly.`
|
||||
: `The generation server returned an unreadable response (HTTP ${response.status}). Reload after deploying the latest server.`,
|
||||
);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
typeof value?.error === "string"
|
||||
? value.error
|
||||
: `Generation request failed (HTTP ${response.status}).`,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function requestGeneration(
|
||||
url: URL,
|
||||
input: unknown,
|
||||
signal: AbortSignal,
|
||||
transport: typeof fetch = fetch,
|
||||
) {
|
||||
let jobUrl: URL | undefined;
|
||||
const deadline = AbortSignal.any([signal, AbortSignal.timeout(75_000)]);
|
||||
try {
|
||||
let result = await generationResponse(
|
||||
await transport(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", prefer: "respond-async" },
|
||||
body: JSON.stringify(input),
|
||||
signal: deadline,
|
||||
}),
|
||||
);
|
||||
if (!result.job) return result; // Compatible with local engines and older synchronous servers.
|
||||
if (typeof result.job.id !== "string" || !/^[a-f0-9-]{36}$/.test(result.job.id))
|
||||
throw Error("Invalid generation job response.");
|
||||
jobUrl = new URL(url.href.replace(/\/$/, "") + "/jobs/" + result.job.id);
|
||||
while (true) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const aborted = () => {
|
||||
clearTimeout(timer);
|
||||
reject(deadline.reason);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
deadline.removeEventListener("abort", aborted);
|
||||
resolve();
|
||||
}, 1000);
|
||||
if (deadline.aborted) aborted();
|
||||
else deadline.addEventListener("abort", aborted, { once: true });
|
||||
});
|
||||
result = await generationResponse(
|
||||
await transport(jobUrl, { signal: deadline, cache: "no-store" }),
|
||||
);
|
||||
if (result.status === "complete") return result.result;
|
||||
if (result.status === "failed" || result.status === "cancelled")
|
||||
throw Error(result.error ?? "Generation did not complete.");
|
||||
if (result.status !== "pending") throw Error("Invalid generation job status.");
|
||||
}
|
||||
} finally {
|
||||
if (jobUrl && deadline.aborted) {
|
||||
void transport(jobUrl, { method: "DELETE", signal: AbortSignal.timeout(3000) }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,7 +479,7 @@ export async function generateDesigns(
|
||||
capabilities,
|
||||
};
|
||||
const rules =
|
||||
'Return JSON only: {"alternatives":[{"name":"...","rationale":"...","patch":[{"op":"set","path":"/tokens/shape.control/value","value":"0"}]}]}. Return exactly three DISTINCT alternatives for directions, one for edit. Capability part values index propertyProfiles; states list accepted state names. Paths are JSON pointers, escape dots as literal characters (no escape needed). Only set/remove existing schema decisions: tokens, modes, densities, recipes, id/name/version/defaultMode. Never change locked raw or effective values, including token aliases. Use only declared component parts/properties/axes. No tools, code execution, remote assets or new schema fields. Do not embed secrets. A set operation has a value. Preserve source identity unless explicitly requested. Each alternative has at most 48 operations. Respect the brief and explain its tradeoffs.';
|
||||
'Return JSON only: {"alternatives":[{"name":"...","rationale":"...","patch":[{"op":"set","path":"/tokens/shape.control/value","value":"0"}]}]}. Return exactly three DISTINCT alternatives for directions, one for edit. Capability part values index propertyProfiles; states list accepted state names. Paths are JSON pointers, escape dots as literal characters (no escape needed). Token maps are FLAT: color.primary is ONE key; never use /tokens/color/primary. Change an existing token with /tokens/color.primary/value. When adding a missing token, set /tokens/color.primary to a COMPLETE token object {"type":"color","value":"#002FA7","legacy":"color-primary"}, never a bare string. Allowed token types are color, dimension, number, fontFamily, fontWeight, shadow, duration, colorScheme. Use the same flat keys in /modes/light/color.primary/value and /modes/dark/color.primary/value. Existing mode overrides win over base tokens: update the active and other applicable modes when changing colors. Only set/remove schema decisions: tokens, modes, densities, recipes, id/name/version/defaultMode. Never change locked raw or effective values, including token aliases. Use only declared component parts/properties/axes. No tools, code execution, remote assets or new schema fields. Do not embed secrets. A set operation has a value. Preserve source identity unless explicitly requested. Each alternative has at most 48 operations. Respect the brief and explain its tradeoffs.';
|
||||
let repair = "";
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
signal.throwIfAborted();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requestGeneration } from "./generation-transport.ts";
|
||||
import {
|
||||
DEFAULT_DESIGN_BRIEF,
|
||||
type DesignBrief,
|
||||
@@ -78,6 +79,7 @@ export function setupGuidance(
|
||||
message: (text: string) => void;
|
||||
changed: () => Promise<void>;
|
||||
preview: (system: DesignSystem) => Promise<string>;
|
||||
review?: (system: DesignSystem | undefined, name?: string) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
@@ -197,6 +199,7 @@ export function setupGuidance(
|
||||
)
|
||||
.join("\n\n") || "No changes.";
|
||||
$("accept-proposal").removeAttribute("disabled");
|
||||
hooks.review?.(proposal.system, proposal.name).catch((e) => hooks.message(e.message));
|
||||
}
|
||||
function render() {
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-lock-group]").forEach((button) => {
|
||||
@@ -277,24 +280,24 @@ export function setupGuidance(
|
||||
status = "Generating proposals. Your draft is unchanged.";
|
||||
render();
|
||||
try {
|
||||
const response = await fetch(new URL("./api/generate", document.baseURI), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
await hooks.review?.(undefined);
|
||||
const result = await requestGeneration(
|
||||
new URL("./api/generate", document.baseURI),
|
||||
{
|
||||
system,
|
||||
locks,
|
||||
brief: requestedBrief,
|
||||
baseRevision,
|
||||
kind,
|
||||
instruction: kind === "edit" ? value("edit-instruction").value : undefined,
|
||||
}),
|
||||
},
|
||||
signal,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw Error(result.error ?? "Generation failed.");
|
||||
);
|
||||
if (current !== epoch || signal.aborted) return;
|
||||
if (result.diagnostics.length) {
|
||||
status = result.diagnostics.map((d: { message: string }) => d.message).join("\n");
|
||||
status =
|
||||
"No valid proposals returned. Your draft and previous alternatives are unchanged.\n" +
|
||||
result.diagnostics.map((d: { message: string }) => d.message).join("\n");
|
||||
return;
|
||||
}
|
||||
batch = result;
|
||||
@@ -305,6 +308,10 @@ export function setupGuidance(
|
||||
)
|
||||
status =
|
||||
"Proposals arrived after the draft or locks changed. They are preserved for inspection; regenerate before accepting.";
|
||||
else if (batch!.alternatives[0]) {
|
||||
review(batch!.alternatives[0]);
|
||||
status += " The first proposal is shown in the main preview; your draft is unchanged.";
|
||||
}
|
||||
save();
|
||||
} catch (error) {
|
||||
if (current === epoch)
|
||||
@@ -436,7 +443,7 @@ export function setupGuidance(
|
||||
$("generation-provider").textContent =
|
||||
config.provider === "local-rules"
|
||||
? "Local rules · no network generation"
|
||||
: `${config.provider} / ${config.model ?? "configured model"} · Sends the current design, brief and contract capabilities to this configured provider only when you generate. At most two attempts, 4096 output tokens per attempt, 20-second deadline.`;
|
||||
: `${config.provider} / ${config.model ?? "configured model"} · Sends the current design, brief and contract capabilities to this configured provider only when you generate. At most ${config.maxAttempts ?? 2} attempts, ${config.maxOutputTokens ?? 4096} output tokens per attempt, ${(config.timeoutMs ?? 20000) / 1000}-second deadline.`;
|
||||
})
|
||||
.catch(() => {});
|
||||
render();
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
GOOGLE_FONTS,
|
||||
MANUAL_COLORS,
|
||||
MANUAL_FONTS,
|
||||
MANUAL_TYPOGRAPHY,
|
||||
manualDesign,
|
||||
type ManualEdit,
|
||||
} from "./manual.ts";
|
||||
import { checkDecisionLocks } from "./decisions.ts";
|
||||
import type { StudioSession } from "./session.ts";
|
||||
const escape = (value: unknown) =>
|
||||
String(value).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
||||
);
|
||||
export const manualHtml = `<section id="manual" class="editor"><p>Create a theme yourself. Choose your settings, then select Update preview to save a recoverable draft. No AI or API key is needed.</p><button type="button" class="primary" data-manual-apply>Update preview</button><label>Default theme<select id="manual-default-mode"></select></label><label>Edit color mode<select id="manual-mode"></select></label><h3>Colors</h3><p class="help">Edit light and dark palettes separately. Brand and status colors get matching hover and readable contrast colors automatically. Foundations below apply to both modes.</p><div id="manual-colors" class="manual-grid"></div><details><summary>All other color tokens</summary><p class="help">Fine-tune hover, contrast, focus, input, code and custom color tokens.</p><div id="manual-extra-colors" class="manual-grid"></div></details><h3>Shape and spacing</h3><div id="manual-shapes" class="manual-grid"></div><p class="help">Use 0 for square corners, 8–16 for rounded corners, or 999 for pill-shaped controls. Switches stay pill-shaped.</p><div class="manual-grid"><label>Button style<select id="manual-button-style"><option value="solid">Solid</option><option value="outline">Outlined</option></select></label><label>Tilt<select id="manual-tilt"><option value="none">None</option><option value="subtle">Subtle</option><option value="expressive">Expressive</option></select></label></div><h3>Typography</h3><div id="manual-fonts"></div><div id="manual-type-scale" class="manual-grid"></div><p class="help">Choose a Google Font to load it automatically in previews and exported themes. Or choose a device font or enter a custom font stack. For another Google Font, enter its exact family name in Other Google Font. Google Fonts requires an internet connection.</p><h3>Surface</h3><label>Shadow<select id="manual-shadow"><option value="none">Flat / no shadow</option><option value="0 8px 24px #00000018">Soft shadow</option><option value="2px 2px 0 #000000">Crisp shadow</option></select></label><p class="help">Use Create new or Duplicate in the workspace to name your theme. Export JSON keeps a portable copy. Review & apply exports the CSS or theme configuration for your app. Decision locks also protect manual edits.</p><button type="button" class="primary" data-manual-apply>Update preview</button></section>`;
|
||||
export const manualCss = `.manual-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0 16px}.manual-color{display:flex;gap:8px;align-items:center}.manual-color input[type=color]{width:44px;height:38px;padding:3px;flex:none}.manual-color input[type=text]{flex:1;font-family:Consolas,monospace}.manual-grid input[type=number]{max-width:150px}`;
|
||||
export function setupManual(
|
||||
session: StudioSession,
|
||||
hooks: {
|
||||
changed: () => Promise<void>;
|
||||
message: (text: string) => void;
|
||||
mode: (name: string) => void;
|
||||
},
|
||||
) {
|
||||
const $ = (id: string) => document.getElementById(id)!;
|
||||
let identity = session.valid.id;
|
||||
let mode = session.valid.defaultMode ?? Object.keys(session.valid.modes ?? {})[0] ?? "base";
|
||||
let dirty = false;
|
||||
async function applyForm() {
|
||||
try {
|
||||
if (session.diagnostics.length)
|
||||
throw Error(
|
||||
"Resolve draft errors before editing manually. Your last valid design is preserved.",
|
||||
);
|
||||
const resolved = session.resolved(mode === "base" ? {} : { mode });
|
||||
const edits: ManualEdit[] = [];
|
||||
$("manual")
|
||||
.querySelectorAll<HTMLInputElement>("input[type=text][data-manual-color]")
|
||||
.forEach((input) => {
|
||||
if (
|
||||
input.value &&
|
||||
input.value !==
|
||||
String(resolved.tokens["color." + input.dataset.manualColor]?.value ?? "")
|
||||
)
|
||||
edits.push({
|
||||
kind: "color",
|
||||
mode,
|
||||
name: input.dataset.manualColor!,
|
||||
value: input.value,
|
||||
});
|
||||
});
|
||||
$("manual")
|
||||
.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-manual-token]")
|
||||
.forEach((input) => {
|
||||
const name = input.dataset.manualToken!,
|
||||
raw = String(resolved.tokens[name]?.value ?? "");
|
||||
if (input instanceof HTMLInputElement && input.type === "number") {
|
||||
const previous = Number(input.dataset.manualOriginal);
|
||||
if (Number(input.value) !== previous)
|
||||
edits.push({
|
||||
kind: "token",
|
||||
name,
|
||||
value: input.value + (input.dataset.manualUnit ?? "px"),
|
||||
});
|
||||
} else if (input.value.startsWith("google:")) {
|
||||
edits.push({
|
||||
kind: "googleFont",
|
||||
name: name as "type.body" | "type.heading" | "type.code",
|
||||
value: input.value.slice(7),
|
||||
});
|
||||
} else if (input.value !== raw) edits.push({ kind: "token", name, value: input.value });
|
||||
});
|
||||
const value = (id: string) => ($(id) as HTMLSelectElement).value;
|
||||
if (
|
||||
value("manual-default-mode") &&
|
||||
value("manual-default-mode") !== session.valid.defaultMode
|
||||
)
|
||||
edits.push({ kind: "mode", value: value("manual-default-mode") });
|
||||
const rule = session.valid.recipes?.Button?.variants?.find(
|
||||
(r) => r.when.variant === "default",
|
||||
);
|
||||
if (
|
||||
value("manual-button-style") !==
|
||||
(rule?.parts.control?.backgroundColor === "transparent" ? "outline" : "solid")
|
||||
)
|
||||
edits.push({
|
||||
kind: "buttonStyle",
|
||||
value: value("manual-button-style") as "solid" | "outline",
|
||||
});
|
||||
const tilt = String(session.valid.recipes?.Button?.base?.control?.rotate ?? "0deg"),
|
||||
oldTilt = tilt === "0deg" ? "none" : tilt === "-0.5deg" ? "subtle" : "expressive";
|
||||
if (value("manual-tilt") !== oldTilt)
|
||||
edits.push({
|
||||
kind: "tilt",
|
||||
value: value("manual-tilt") as "none" | "subtle" | "expressive",
|
||||
});
|
||||
if (value("manual-shadow") !== String(resolved.tokens["elevation.panel"]?.value ?? "none"))
|
||||
edits.push({ kind: "token", name: "elevation.panel", value: value("manual-shadow") });
|
||||
$("manual-fonts")
|
||||
.querySelectorAll<HTMLInputElement>("[data-google-family]")
|
||||
.forEach((input) => {
|
||||
if (input.value.trim())
|
||||
edits.push({
|
||||
kind: "googleFont",
|
||||
name: input.dataset.googleFamily as "type.body" | "type.heading" | "type.code",
|
||||
value: input.value.trim(),
|
||||
});
|
||||
});
|
||||
const next = edits.reduce((system, edit) => manualDesign(system, edit), session.valid);
|
||||
if (checkDecisionLocks(session.valid, next, session.locks).length)
|
||||
throw Error("This change affects a locked decision. Unlock it in Guided design first.");
|
||||
session.replace(next, "Manual theme settings");
|
||||
dirty = false;
|
||||
const defaultEdit = edits.find((e) => e.kind === "mode");
|
||||
if (defaultEdit) mode = defaultEdit.value;
|
||||
await hooks.changed();
|
||||
hooks.mode(mode);
|
||||
hooks.message("Manual theme saved. Preview updated. Undo restores the previous settings.");
|
||||
} catch (error) {
|
||||
hooks.message((error as Error).message);
|
||||
}
|
||||
}
|
||||
$("manual")
|
||||
.querySelectorAll("[data-manual-apply]")
|
||||
.forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
void applyForm();
|
||||
}),
|
||||
);
|
||||
$("manual").addEventListener("input", (event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.id === "manual-mode") return;
|
||||
dirty = true;
|
||||
if (input.dataset.manualColor) {
|
||||
const pair = input
|
||||
.closest("label")
|
||||
?.querySelector<HTMLInputElement>(
|
||||
input.type === "color" ? "input[type=text]" : "input[type=color]",
|
||||
);
|
||||
if (pair && /^#[a-f0-9]{6}$/i.test(input.value)) pair.value = input.value;
|
||||
}
|
||||
});
|
||||
$("manual").addEventListener("change", (event) => {
|
||||
if ((event.target as HTMLElement).id !== "manual-mode") dirty = true;
|
||||
});
|
||||
$("manual-mode").addEventListener("change", () => {
|
||||
if (dirty) {
|
||||
($("manual-mode") as HTMLSelectElement).value = mode;
|
||||
hooks.message("Select Update preview to save these settings before switching color modes.");
|
||||
return;
|
||||
}
|
||||
mode = ($("manual-mode") as HTMLSelectElement).value;
|
||||
render();
|
||||
hooks.mode(mode);
|
||||
});
|
||||
document.getElementById("preview-mode")!.addEventListener("change", () => {
|
||||
mode =
|
||||
(document.getElementById("preview-mode") as HTMLSelectElement).value ||
|
||||
session.valid.defaultMode ||
|
||||
"base";
|
||||
render();
|
||||
});
|
||||
function render() {
|
||||
dirty = false;
|
||||
if (identity !== session.valid.id) {
|
||||
identity = session.valid.id;
|
||||
mode =
|
||||
(document.getElementById("preview-mode") as HTMLSelectElement).value ||
|
||||
session.valid.defaultMode ||
|
||||
"base";
|
||||
}
|
||||
const modes = Object.keys(session.valid.modes ?? {});
|
||||
if (!modes.includes(mode)) mode = modes[0] ?? "base";
|
||||
($("manual-mode") as HTMLSelectElement).innerHTML = (modes.length ? modes : ["base"])
|
||||
.map(
|
||||
(m) => `<option ${m === mode ? "selected" : ""} value="${escape(m)}">${escape(m)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
($("manual-default-mode") as HTMLSelectElement).innerHTML = modes
|
||||
.map(
|
||||
(m) =>
|
||||
`<option value="${escape(m)}" ${m === session.valid.defaultMode ? "selected" : ""}>${escape(m)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
const resolved = session.resolved(mode === "base" ? {} : { mode });
|
||||
$("manual-colors").innerHTML = Object.entries(MANUAL_COLORS)
|
||||
.map(([name, label]) => {
|
||||
const raw = String(resolved.tokens["color." + name]?.value ?? "");
|
||||
const hex = /^#[a-f0-9]{6}$/i.test(raw) ? raw : "#666666";
|
||||
return `<label>${label}<span class="manual-color"><input type="color" aria-label="${label} color picker" data-manual-color="${name}" value="${hex}"><input type="text" aria-label="${label} hex color" data-manual-color="${name}" value="${escape(raw)}" maxlength="7" placeholder="Inherited"></span></label>`;
|
||||
})
|
||||
.join("");
|
||||
$("manual-extra-colors").innerHTML = Object.entries(resolved.tokens)
|
||||
.filter(
|
||||
([name, token]) =>
|
||||
name.startsWith("color.") &&
|
||||
token.type === "color" &&
|
||||
!Object.hasOwn(MANUAL_COLORS, name.slice(6)),
|
||||
)
|
||||
.map(([name, token]) => {
|
||||
const color = name.slice(6),
|
||||
raw = String(token.value),
|
||||
hex = /^#[a-f0-9]{6}$/i.test(raw) ? raw : "#666666";
|
||||
return `<label>${escape(color)}<span class="manual-color"><input type="color" aria-label="${escape(color)} color picker" data-manual-color="${escape(color)}" value="${hex}"><input type="text" aria-label="${escape(color)} hex color" data-manual-color="${escape(color)}" value="${escape(raw)}" placeholder="#00677B"></span></label>`;
|
||||
})
|
||||
.join("");
|
||||
$("manual-shapes").innerHTML = [
|
||||
["shape.control", "Control corners (px)"],
|
||||
["shape.panel", "Panel corners (px)"],
|
||||
["shape.dialog", "Dialog corners (px)"],
|
||||
["space.control", "Control padding (px)"],
|
||||
["space.panel", "Panel padding (px)"],
|
||||
]
|
||||
.map(([name, label]) => {
|
||||
const raw = String(resolved.tokens[name!]?.value ?? "0");
|
||||
const px = parseFloat(raw) * (raw.endsWith("rem") ? 16 : 1);
|
||||
return `<label>${label}<input type="number" min="0" max="${name!.startsWith("shape.") ? 999 : 64}" data-manual-token="${name}" data-manual-original="${Number.isFinite(px) ? px : 0}" value="${Number.isFinite(px) ? px : 0}"></label>`;
|
||||
})
|
||||
.join("");
|
||||
const buttonRule = session.valid.recipes?.Button?.variants?.find(
|
||||
(rule) => rule.when.variant === "default",
|
||||
);
|
||||
($("manual-button-style") as HTMLSelectElement).value =
|
||||
buttonRule?.parts.control?.backgroundColor === "transparent" ? "outline" : "solid";
|
||||
const tilt = String(session.valid.recipes?.Button?.base?.control?.rotate ?? "0deg");
|
||||
($("manual-tilt") as HTMLSelectElement).value =
|
||||
tilt === "0deg" ? "none" : tilt === "-0.5deg" ? "subtle" : "expressive";
|
||||
$("manual-fonts").innerHTML = [
|
||||
["type.body", "Body font"],
|
||||
["type.heading", "Heading font"],
|
||||
["type.code", "Code font"],
|
||||
]
|
||||
.map(([name, label]) => {
|
||||
const current = String(
|
||||
resolved.tokens[name!]?.value ??
|
||||
(name === "type.code" ? MANUAL_FONTS.mono : MANUAL_FONTS.sans),
|
||||
);
|
||||
return `<label>${label}<select data-manual-token="${name}"><option value="${escape(current)}">Current: ${escape(current)}</option>${Object.entries(
|
||||
MANUAL_FONTS,
|
||||
)
|
||||
.map(([label, font]) => `<option value="${escape(font)}">${label}</option>`)
|
||||
.join(
|
||||
"",
|
||||
)}<optgroup label="Google Fonts">${GOOGLE_FONTS.map((font) => `<option value="google:${font}">${font}</option>`).join("")}</optgroup></select><input aria-label="Custom ${label!.toLowerCase()} stack" data-manual-token="${name}" value="${escape(current)}"><input aria-label="Other Google ${label!.toLowerCase()}" data-google-family="${name}" placeholder="Other Google Font family, e.g. Rubik"></label>`;
|
||||
})
|
||||
.join("");
|
||||
$("manual-type-scale").innerHTML = Object.entries(MANUAL_TYPOGRAPHY)
|
||||
.map(([name, setting]) => {
|
||||
const raw = String(resolved.tokens[name]?.value ?? setting.initial);
|
||||
const numeric = parseFloat(raw) * (raw.endsWith("rem") ? 16 : 1);
|
||||
return `<label>${setting.label}<input type="number" step="${setting.type === "fontWeight" ? 100 : 0.1}" min="${setting.min}" max="${setting.max}" data-manual-token="${name}" data-manual-unit="${setting.unit}" data-manual-original="${numeric}" value="${numeric}"></label>`;
|
||||
})
|
||||
.join("");
|
||||
const shadow = $("manual-shadow") as HTMLSelectElement;
|
||||
const current = String(resolved.tokens["elevation.panel"]?.value ?? "none");
|
||||
Array.from(shadow.options)
|
||||
.filter((o) => o.dataset.custom)
|
||||
.forEach((o) => o.remove());
|
||||
if (!Array.from(shadow.options).some((o) => o.value === current)) {
|
||||
const option = new Option("Current: " + current, current);
|
||||
option.dataset.custom = "true";
|
||||
shadow.add(option);
|
||||
}
|
||||
shadow.value = current;
|
||||
}
|
||||
return { render };
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
COMPONENT_CONTRACTS,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type DesignToken,
|
||||
} from "@wrnexus/design-system";
|
||||
|
||||
export const MANUAL_COLORS = {
|
||||
primary: "Brand / primary",
|
||||
secondary: "Secondary",
|
||||
bg: "Page background",
|
||||
surface: "Surface",
|
||||
"surface-2": "Subtle surface",
|
||||
text: "Text",
|
||||
"text-muted": "Muted text",
|
||||
border: "Borders",
|
||||
success: "Success",
|
||||
warning: "Warning",
|
||||
danger: "Error",
|
||||
info: "Information",
|
||||
} as const;
|
||||
export const MANUAL_FONTS = {
|
||||
sans: '"Helvetica Neue", Arial, sans-serif',
|
||||
system: "system-ui, sans-serif",
|
||||
arial: "Arial, Helvetica, sans-serif",
|
||||
verdana: "Verdana, Geneva, sans-serif",
|
||||
tahoma: "Tahoma, sans-serif",
|
||||
georgia: "Georgia, serif",
|
||||
times: '"Times New Roman", Times, serif',
|
||||
palatino: '"Palatino Linotype", Palatino, serif',
|
||||
consolas: 'Consolas, "Courier New", monospace',
|
||||
humanist: '"Trebuchet MS", Arial, sans-serif',
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
mono: '"Courier New", monospace',
|
||||
};
|
||||
export const GOOGLE_FONTS = [
|
||||
"Inter",
|
||||
"Roboto",
|
||||
"Open Sans",
|
||||
"Lato",
|
||||
"Montserrat",
|
||||
"Poppins",
|
||||
"Nunito",
|
||||
"Raleway",
|
||||
"Ubuntu",
|
||||
"Merriweather",
|
||||
"Playfair Display",
|
||||
"Lora",
|
||||
"Source Serif 4",
|
||||
"DM Sans",
|
||||
"Manrope",
|
||||
"Outfit",
|
||||
"Space Grotesk",
|
||||
"IBM Plex Sans",
|
||||
"Noto Sans",
|
||||
"Noto Sans Devanagari",
|
||||
"JetBrains Mono",
|
||||
"Fira Code",
|
||||
"Source Code Pro",
|
||||
];
|
||||
export const MANUAL_TYPOGRAPHY = {
|
||||
"type.body-size": {
|
||||
label: "Body size (px)",
|
||||
type: "dimension",
|
||||
min: 10,
|
||||
max: 32,
|
||||
initial: 16,
|
||||
unit: "px",
|
||||
},
|
||||
"type.heading-size": {
|
||||
label: "Heading size (px)",
|
||||
type: "dimension",
|
||||
min: 16,
|
||||
max: 96,
|
||||
initial: 28,
|
||||
unit: "px",
|
||||
},
|
||||
"type.code-size": {
|
||||
label: "Code size (px)",
|
||||
type: "dimension",
|
||||
min: 10,
|
||||
max: 32,
|
||||
initial: 14,
|
||||
unit: "px",
|
||||
},
|
||||
"type.body-weight": {
|
||||
label: "Body weight",
|
||||
type: "fontWeight",
|
||||
min: 100,
|
||||
max: 900,
|
||||
initial: 400,
|
||||
unit: "",
|
||||
},
|
||||
"type.heading-weight": {
|
||||
label: "Heading weight",
|
||||
type: "fontWeight",
|
||||
min: 100,
|
||||
max: 900,
|
||||
initial: 700,
|
||||
unit: "",
|
||||
},
|
||||
"type.body-leading": {
|
||||
label: "Body line height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 2.5,
|
||||
initial: 1.5,
|
||||
unit: "",
|
||||
},
|
||||
"type.heading-leading": {
|
||||
label: "Heading line height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 2,
|
||||
initial: 1.2,
|
||||
unit: "",
|
||||
},
|
||||
"type.body-tracking": {
|
||||
label: "Body letter spacing (px)",
|
||||
type: "dimension",
|
||||
min: -2,
|
||||
max: 8,
|
||||
initial: 0,
|
||||
unit: "px",
|
||||
},
|
||||
"type.heading-tracking": {
|
||||
label: "Heading letter spacing (px)",
|
||||
type: "dimension",
|
||||
min: -4,
|
||||
max: 12,
|
||||
initial: 0,
|
||||
unit: "px",
|
||||
},
|
||||
} as const;
|
||||
function typographyRecipes(system: DesignSystem, token: string) {
|
||||
const suffix = token.replace(/^type\./, "");
|
||||
const group = suffix.split("-")[0]!;
|
||||
const property =
|
||||
(
|
||||
{
|
||||
size: "fontSize",
|
||||
weight: "fontWeight",
|
||||
leading: "lineHeight",
|
||||
tracking: "letterSpacing",
|
||||
} as Record<string, string>
|
||||
)[suffix.split("-")[1]!] ?? "fontFamily";
|
||||
for (const [name, contract] of Object.entries(COMPONENT_CONTRACTS)) {
|
||||
for (const [part, descriptor] of Object.entries(contract.parts)) {
|
||||
const properties: readonly string[] = descriptor.properties;
|
||||
if (!properties.includes(property)) continue;
|
||||
const code = /code|kbd/i.test(name) || /code/i.test(part);
|
||||
const heading = /title|heading|headline/i.test(part);
|
||||
if ((code ? "code" : heading ? "heading" : "body") !== group) continue;
|
||||
// Keep icon geometry and managed/provider content independent of text settings.
|
||||
if (
|
||||
/icon|spinner|thumb|track|srOnly/i.test(part) ||
|
||||
("ownership" in descriptor && descriptor.ownership !== "framework")
|
||||
)
|
||||
continue;
|
||||
const recipe = ((system.recipes ??= {})[name] ??= { contractVersion: 1 });
|
||||
((recipe.base ??= {})[part] ??= {})[property] = { ref: token };
|
||||
}
|
||||
}
|
||||
}
|
||||
export type ManualEdit =
|
||||
| { kind: "googleFont"; name: "type.body" | "type.heading" | "type.code"; value: string }
|
||||
| { kind: "color"; mode: string; name: string; value: string }
|
||||
| { kind: "token"; name: string; value: string }
|
||||
| { kind: "mode"; value: string }
|
||||
| { kind: "buttonStyle"; value: "solid" | "outline" }
|
||||
| { kind: "tilt"; value: "none" | "subtle" | "expressive" };
|
||||
function ink(hex: string) {
|
||||
const channels = hex
|
||||
.slice(1)
|
||||
.match(/../g)!
|
||||
.map((s) => parseInt(s, 16) / 255)
|
||||
.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
const luminance = channels.reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i]!, 0);
|
||||
return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#FFFFFF";
|
||||
}
|
||||
export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSystem {
|
||||
if (edit.kind === "googleFont") {
|
||||
if (!/^[A-Za-z][A-Za-z0-9 ]{0,79}$/.test(edit.value) || edit.value.trim() !== edit.value)
|
||||
throw Error("Enter a Google Font family name, such as Inter or Noto Sans Devanagari.");
|
||||
const next = manualDesign(source, {
|
||||
kind: "token",
|
||||
name: edit.name,
|
||||
value: `"${edit.value}", ${edit.name === "type.code" ? "monospace" : "sans-serif"}`,
|
||||
});
|
||||
const fontTokens = [
|
||||
next.tokens,
|
||||
...Object.values(next.modes ?? {}),
|
||||
...Object.values(next.densities ?? {}),
|
||||
]
|
||||
.flatMap((layer) => Object.values(layer))
|
||||
.filter((token) => token.type === "fontFamily");
|
||||
next.googleFonts = [...new Set([...(next.googleFonts ?? []), edit.value])].filter((family) =>
|
||||
fontTokens.some(
|
||||
(token) => typeof token.value === "string" && token.value.includes(`"${family}"`),
|
||||
),
|
||||
);
|
||||
|
||||
const errors = validateDesignSystem(next);
|
||||
if (errors.length) throw Error(errors.map((e) => e.message).join("\n"));
|
||||
return next;
|
||||
}
|
||||
const system = structuredClone(source);
|
||||
const set = (
|
||||
tokens: Record<string, DesignToken>,
|
||||
name: string,
|
||||
value: string,
|
||||
type: DesignToken["type"] = "color",
|
||||
legacy?: string,
|
||||
) => {
|
||||
tokens[name] = {
|
||||
...tokens[name],
|
||||
type,
|
||||
value,
|
||||
...(legacy ? { legacy: tokens[name]?.legacy ?? legacy } : {}),
|
||||
};
|
||||
};
|
||||
if (edit.kind === "buttonStyle") {
|
||||
const recipe = ((system.recipes ??= {}).Button ??= { contractVersion: 1, base: {} });
|
||||
const variant = {
|
||||
when: { variant: "default" },
|
||||
parts: {
|
||||
control: {
|
||||
backgroundImage: "none",
|
||||
backgroundColor: edit.value === "outline" ? "transparent" : { ref: "color.primary" },
|
||||
color: { ref: edit.value === "outline" ? "color.primary" : "color.primary-contrast" },
|
||||
borderColor: { ref: "color.primary" },
|
||||
},
|
||||
},
|
||||
};
|
||||
recipe.variants = [
|
||||
...(recipe.variants ?? []).filter(
|
||||
(rule) => !(Object.keys(rule.when).length === 1 && rule.when.variant === "default"),
|
||||
),
|
||||
variant,
|
||||
];
|
||||
} else if (edit.kind === "tilt") {
|
||||
for (const [name, part, angle] of [
|
||||
[
|
||||
"Button",
|
||||
"control",
|
||||
edit.value === "none" ? "0deg" : edit.value === "subtle" ? "-0.5deg" : "-2deg",
|
||||
],
|
||||
[
|
||||
"Card",
|
||||
"panel",
|
||||
edit.value === "none" ? "0deg" : edit.value === "subtle" ? "0.25deg" : "1deg",
|
||||
],
|
||||
]) {
|
||||
const recipe = ((system.recipes ??= {})[name!] ??= { contractVersion: 1, base: {} });
|
||||
((recipe.base ??= {})[part!] ??= {}).rotate = angle!;
|
||||
}
|
||||
} else if (edit.kind === "mode") {
|
||||
if (!Object.hasOwn(system.modes ?? {}, edit.value))
|
||||
throw Error("Choose an existing light or dark mode.");
|
||||
system.defaultMode = edit.value;
|
||||
} else if (edit.kind === "color") {
|
||||
if (
|
||||
(!Object.hasOwn(MANUAL_COLORS, edit.name) &&
|
||||
system.tokens["color." + edit.name]?.type !== "color" &&
|
||||
system.modes?.[edit.mode]?.["color." + edit.name]?.type !== "color") ||
|
||||
!/^#[a-f0-9]{6}$/i.test(edit.value)
|
||||
)
|
||||
throw Error("Choose a color or enter a six-digit hex value, such as #00677B.");
|
||||
const layer = edit.mode === "base" ? system.tokens : system.modes?.[edit.mode];
|
||||
if (!layer) throw Error("Choose an existing light or dark mode.");
|
||||
const color = (name: string, value = edit.value) =>
|
||||
set(
|
||||
layer,
|
||||
"color." + name,
|
||||
value,
|
||||
"color",
|
||||
layer["color." + name]?.legacy ?? system.tokens["color." + name]?.legacy ?? "color-" + name,
|
||||
);
|
||||
color(edit.name);
|
||||
for (const alias of (
|
||||
{
|
||||
bg: ["background"],
|
||||
surface: ["surface-raised", "input-background"],
|
||||
"surface-2": [
|
||||
"surface-soft",
|
||||
"surface-muted",
|
||||
"primary-soft",
|
||||
"primary-muted",
|
||||
"code-background",
|
||||
],
|
||||
text: ["foreground", "code-text"],
|
||||
"text-muted": ["text-subtle", "muted"],
|
||||
border: ["border-strong", "input-border", "code-border"],
|
||||
} as Record<string, string[]>
|
||||
)[edit.name] ?? [])
|
||||
color(alias);
|
||||
if (["primary", "secondary", "success", "warning", "danger", "info"].includes(edit.name)) {
|
||||
color(edit.name + "-contrast", ink(edit.value));
|
||||
color(edit.name + "-soft", edit.value + "1A");
|
||||
if (edit.name === "primary") color("primary-muted", edit.value + "1A");
|
||||
color(edit.name + "-text");
|
||||
for (const [suffix, factor] of [
|
||||
["-hover", 0.88],
|
||||
["-active", 0.75],
|
||||
] as const)
|
||||
color(
|
||||
edit.name + suffix,
|
||||
"#" +
|
||||
edit.value
|
||||
.slice(1)
|
||||
.match(/../g)!
|
||||
.map((c) =>
|
||||
Math.round(parseInt(c, 16) * factor)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join(""),
|
||||
);
|
||||
if (edit.name === "primary") color("focus");
|
||||
}
|
||||
} else {
|
||||
const allowed: Record<string, [DesignToken["type"], string | undefined]> = {
|
||||
"shape.control": ["dimension", "radius-sm"],
|
||||
"shape.panel": ["dimension", "radius-md"],
|
||||
"shape.dialog": ["dimension", "radius-lg"],
|
||||
"type.body": ["fontFamily", "font-sans"],
|
||||
"type.heading": ["fontFamily", undefined],
|
||||
"type.code": ["fontFamily", "font-mono"],
|
||||
...Object.fromEntries(
|
||||
Object.entries(MANUAL_TYPOGRAPHY).map(([name, setting]) => [
|
||||
name,
|
||||
[setting.type, undefined],
|
||||
]),
|
||||
),
|
||||
"elevation.panel": ["shadow", "shadow-1"],
|
||||
"space.control": ["dimension", undefined],
|
||||
"space.panel": ["dimension", "space-panel"],
|
||||
};
|
||||
if (!allowed[edit.name]) throw Error("Unsupported manual design setting.");
|
||||
const [type, legacy] = allowed[edit.name]!;
|
||||
if (
|
||||
type === "dimension" &&
|
||||
!Object.hasOwn(MANUAL_TYPOGRAPHY, edit.name) &&
|
||||
(!/^\d+(?:\.\d+)?px$/.test(edit.value) ||
|
||||
parseFloat(edit.value) > (edit.name.startsWith("shape.") ? 999 : 64))
|
||||
)
|
||||
throw Error("Enter corners between 0 and 999 pixels, or padding between 0 and 64 pixels.");
|
||||
const typography = MANUAL_TYPOGRAPHY[edit.name as keyof typeof MANUAL_TYPOGRAPHY];
|
||||
if (typography) {
|
||||
const numeric = Number(typography.unit ? edit.value.replace(/px$/, "") : edit.value);
|
||||
if (
|
||||
!Number.isFinite(numeric) ||
|
||||
numeric < typography.min ||
|
||||
numeric > typography.max ||
|
||||
(typography.unit && !/^-?\d+(?:\.\d+)?px$/.test(edit.value))
|
||||
)
|
||||
throw Error(`${typography.label} must be between ${typography.min} and ${typography.max}.`);
|
||||
}
|
||||
set(system.tokens, edit.name, edit.value, type, legacy);
|
||||
if (edit.name.startsWith("type.")) typographyRecipes(system, edit.name);
|
||||
// Existing mode overrides must not silently win over a shared foundation edit.
|
||||
for (const layer of Object.values(system.modes ?? {}))
|
||||
if (layer[edit.name]) set(layer, edit.name, edit.value, type, legacy);
|
||||
if (edit.name === "shape.panel")
|
||||
set(system.tokens, "shape.base", edit.value, "dimension", "radius");
|
||||
}
|
||||
const errors = validateDesignSystem(system);
|
||||
if (errors.length) throw Error(errors.map((e) => e.message).join("\n"));
|
||||
return system;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { CORE_DESIGN_SYSTEMS, type DesignSystem, type TokenMap } from "@wrnexus/design-system";
|
||||
|
||||
type Palette = [string, string, string, string, string, string, string, string];
|
||||
function palette(tokens: TokenMap, values: Palette) {
|
||||
const [bg, surface, muted, text, subtle, border, primary, contrast] = values;
|
||||
const colors: Record<string, string> = {
|
||||
bg,
|
||||
background: bg,
|
||||
surface,
|
||||
"surface-2": muted,
|
||||
text,
|
||||
foreground: text,
|
||||
"text-muted": subtle,
|
||||
border,
|
||||
primary,
|
||||
"primary-hover": primary,
|
||||
"primary-active": primary,
|
||||
"primary-contrast": contrast,
|
||||
"primary-text": primary,
|
||||
"primary-soft": muted,
|
||||
"surface-raised": surface,
|
||||
"surface-muted": muted,
|
||||
"surface-soft": muted,
|
||||
"text-subtle": subtle,
|
||||
"border-strong": border,
|
||||
"primary-muted": muted,
|
||||
muted: subtle,
|
||||
focus: primary,
|
||||
"input-background": surface,
|
||||
"input-border": border,
|
||||
"code-background": muted,
|
||||
"code-text": text,
|
||||
"code-border": border,
|
||||
};
|
||||
for (const [name, value] of Object.entries(colors))
|
||||
tokens[`color.${name}`] = {
|
||||
type: "color",
|
||||
legacy: tokens[`color.${name}`]?.legacy ?? `color-${name}`,
|
||||
value,
|
||||
};
|
||||
}
|
||||
function direction(
|
||||
id: string,
|
||||
name: string,
|
||||
light: Palette,
|
||||
dark: Palette,
|
||||
radius: string,
|
||||
font: string,
|
||||
shadow = "none",
|
||||
mode = "light",
|
||||
): DesignSystem {
|
||||
const system = structuredClone(CORE_DESIGN_SYSTEMS.soft);
|
||||
system.id = id;
|
||||
system.name = name;
|
||||
system.defaultMode = mode;
|
||||
palette(system.tokens, mode === "dark" ? dark : light);
|
||||
palette(system.modes!.light!, light);
|
||||
palette(system.modes!.dark!, dark);
|
||||
for (const name of ["shape.control", "shape.panel", "shape.dialog", "shape.base"])
|
||||
system.tokens[name] = { ...system.tokens[name]!, value: radius };
|
||||
for (const name of ["type.body", "type.heading"])
|
||||
system.tokens[name] = { ...system.tokens[name]!, value: font };
|
||||
system.tokens["elevation.panel"] = { ...system.tokens["elevation.panel"]!, value: shadow };
|
||||
return system;
|
||||
}
|
||||
const sans = '"Helvetica Neue", Arial, sans-serif';
|
||||
const serif = 'Georgia, "Times New Roman", serif';
|
||||
const mono = '"Courier New", monospace';
|
||||
|
||||
/** Studio choices extend the original three without changing the engine's core preset API. */
|
||||
export const STUDIO_PRESETS: Record<string, DesignSystem> = {
|
||||
...CORE_DESIGN_SYSTEMS,
|
||||
swiss: direction(
|
||||
"swiss",
|
||||
"Swiss / Blue",
|
||||
["#FFFFFF", "#FFFFFF", "#F7F7F8", "#101114", "#53565E", "#C9CBD1", "#002FA7", "#FFFFFF"],
|
||||
["#101114", "#181A20", "#252832", "#F7F7F8", "#BABEC9", "#51596B", "#91B2FF", "#101114"],
|
||||
"0",
|
||||
sans,
|
||||
),
|
||||
forest: direction(
|
||||
"forest",
|
||||
"Forest / Sage",
|
||||
["#E5EBDD", "#F4F6EE", "#D5DFC8", "#233827", "#4C604C", "#9BAE91", "#355E40", "#FFFFFF"],
|
||||
["#20291C", "#2C3726", "#394530", "#E8DCC7", "#BDC5A5", "#718060", "#B7D092", "#20291C"],
|
||||
"10px",
|
||||
sans,
|
||||
),
|
||||
ocean: direction(
|
||||
"ocean",
|
||||
"Ocean / Teal",
|
||||
["#F0FAFC", "#FFFFFF", "#DDF0F4", "#103846", "#42636E", "#99C4CE", "#00677B", "#FFFFFF"],
|
||||
["#091F29", "#10303D", "#194452", "#E4F5FA", "#A3C5CE", "#477783", "#60D5DF", "#091F29"],
|
||||
"8px",
|
||||
sans,
|
||||
),
|
||||
rose: direction(
|
||||
"rose",
|
||||
"Rose / Rounded",
|
||||
["#FFF3F5", "#FFFFFF", "#FADFE7", "#421E2C", "#76505F", "#D9A4B5", "#A32957", "#FFFFFF"],
|
||||
["#29131E", "#3D202F", "#512C3D", "#FFEAF1", "#DBB2C2", "#955A71", "#FF9BBC", "#29131E"],
|
||||
"20px",
|
||||
sans,
|
||||
),
|
||||
violet: direction(
|
||||
"violet",
|
||||
"Violet / Studio",
|
||||
["#F5F3FF", "#FFFFFF", "#E7E1FA", "#282044", "#625775", "#B9ADD4", "#6336AD", "#FFFFFF"],
|
||||
["#1B142C", "#2A203E", "#3B2B54", "#F3EDFF", "#C2B2D8", "#7C649E", "#C8ABFF", "#1B142C"],
|
||||
"12px",
|
||||
sans,
|
||||
"0 8px 24px #00000018",
|
||||
),
|
||||
ember: direction(
|
||||
"ember",
|
||||
"Ember / Terracotta",
|
||||
["#F5E3D5", "#FFF9F3", "#EBCDB7", "#48271D", "#775342", "#C59C81", "#A34525", "#FFFFFF"],
|
||||
["#2B1B15", "#3B2820", "#50362B", "#E8DCC7", "#CEB094", "#9C7057", "#F4AE80", "#2B1B15"],
|
||||
"6px",
|
||||
sans,
|
||||
),
|
||||
editorial: direction(
|
||||
"editorial",
|
||||
"Editorial / Ink",
|
||||
["#FFFFFF", "#FFFFFF", "#F0F0F0", "#171717", "#555555", "#A3A3A3", "#171717", "#FFFFFF"],
|
||||
["#101010", "#1C1C1C", "#2B2B2B", "#FAFAFA", "#BCBCBC", "#747474", "#FAFAFA", "#101010"],
|
||||
"0",
|
||||
serif,
|
||||
),
|
||||
terminal: direction(
|
||||
"terminal",
|
||||
"Terminal / Green",
|
||||
["#FFFFFF", "#FFFFFF", "#E4F7EB", "#001C0D", "#375D44", "#609B77", "#00642C", "#FFFFFF"],
|
||||
["#000000", "#0B0C0A", "#102318", "#E1FFE9", "#9DCDA9", "#3A7550", "#00E676", "#000000"],
|
||||
"0",
|
||||
mono,
|
||||
"none",
|
||||
"dark",
|
||||
),
|
||||
brutalist: direction(
|
||||
"brutalist",
|
||||
"Brutalist / Primary",
|
||||
["#FFFFFF", "#FFFFFF", "#F0F0F0", "#000000", "#333333", "#000000", "#0000FF", "#FFFFFF"],
|
||||
["#000000", "#000000", "#202020", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFF00", "#000000"],
|
||||
"0",
|
||||
sans,
|
||||
"2px 2px 0 #000000",
|
||||
),
|
||||
contrast: direction(
|
||||
"contrast",
|
||||
"Contrast / Clear",
|
||||
["#FFFFFF", "#FFFFFF", "#EDF1F6", "#101828", "#344054", "#667085", "#173EA5", "#FFFFFF"],
|
||||
["#0D1117", "#161D29", "#253044", "#FFFFFF", "#D0D8E8", "#8999B2", "#A5C8FF", "#0D1117"],
|
||||
"8px",
|
||||
sans,
|
||||
),
|
||||
};
|
||||
|
||||
// Neutral editorial status colors retain textual and icon cues without stray hues.
|
||||
for (const tokens of [
|
||||
STUDIO_PRESETS.editorial!.tokens,
|
||||
STUDIO_PRESETS.editorial!.modes!.light!,
|
||||
STUDIO_PRESETS.editorial!.modes!.dark!,
|
||||
]) {
|
||||
for (const name of ["secondary", "success", "warning", "danger", "info"]) {
|
||||
for (const suffix of ["", "-hover", "-active", "-text"])
|
||||
tokens[`color.${name}${suffix}`] = {
|
||||
type: "color",
|
||||
value: tokens["color.primary"]!.value,
|
||||
legacy: `color-${name}${suffix}`,
|
||||
};
|
||||
tokens[`color.${name}-soft`] = {
|
||||
type: "color",
|
||||
value: tokens["color.surface-2"]!.value,
|
||||
legacy: `color-${name}-soft`,
|
||||
};
|
||||
tokens[`color.${name}-contrast`] = {
|
||||
type: "color",
|
||||
value: tokens["color.primary-contrast"]!.value,
|
||||
legacy: `color-${name}-contrast`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,53 @@
|
||||
/** The sandbox can request a bounded text copy, never read clipboard contents. */
|
||||
export async function handlePreviewClipboard(
|
||||
event: Pick<MessageEvent, "source" | "data">,
|
||||
frames: Pick<HTMLIFrameElement, "contentWindow" | "src">[],
|
||||
writeText: (text: string) => Promise<void>,
|
||||
activated = true,
|
||||
) {
|
||||
const data = event.data;
|
||||
const frame = frames.find((frame) => frame.contentWindow === event.source);
|
||||
if (
|
||||
!frame ||
|
||||
!data ||
|
||||
data.type !== "wrn-studio-copy" ||
|
||||
typeof data.id !== "string" ||
|
||||
!/^[a-z0-9-]{1,80}$/.test(data.id) ||
|
||||
data.path !== new URL(frame.src).pathname ||
|
||||
typeof data.text !== "string" ||
|
||||
data.text.length > 16384
|
||||
)
|
||||
return false;
|
||||
let ok = false;
|
||||
try {
|
||||
if (!activated) throw Error("Copy requires a user gesture.");
|
||||
await writeText(data.text);
|
||||
ok = true;
|
||||
} catch {
|
||||
/* The control reports failure and keeps the value selectable. */
|
||||
}
|
||||
frame.contentWindow?.postMessage({ type: "wrn-studio-copy-result", id: data.id, ok }, "*");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Runs only in the generated trusted preview, with an opaque sandbox origin. */
|
||||
export const previewClipboardRuntime = `
|
||||
if (parent !== window) {
|
||||
const copies = new Map();
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== parent || event.data?.type !== 'wrn-studio-copy-result') return;
|
||||
const pending = copies.get(event.data.id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer); copies.delete(event.data.id);
|
||||
event.data.ok ? pending.resolve() : pending.reject(new Error('Clipboard copy was denied'));
|
||||
});
|
||||
window.wrnClipboard = {writeText(text) {
|
||||
return new Promise((resolve,reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
const timer = setTimeout(() => { copies.delete(id); reject(new Error('Clipboard copy timed out')); }, 4000);
|
||||
copies.set(id, {resolve,reject,timer});
|
||||
parent.postMessage({type:'wrn-studio-copy',id,path:location.pathname,text:String(text)}, '*');
|
||||
});
|
||||
}};
|
||||
}
|
||||
`;
|
||||
@@ -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,14 @@
|
||||
import { previewClipboardRuntime } from "./preview-clipboard.ts";
|
||||
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";
|
||||
@@ -12,21 +16,37 @@ import { uiCss, uiComponentPath } from "@wrnexus/ui/registry";
|
||||
import { resolveThemeConfig, renderThemeCss } from "@wrnexus/styles/theme";
|
||||
import {
|
||||
COMPONENT_CONTRACTS,
|
||||
resolveDesignSystem,
|
||||
type DesignSystem,
|
||||
type ResolveOptions,
|
||||
} 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(
|
||||
@@ -49,6 +69,31 @@ async function component(name: string, props: Record<string, unknown> = {}) {
|
||||
return String((await modules.get(name)!).render(props));
|
||||
}
|
||||
|
||||
function typographyCss(system: DesignSystem, options: ResolveOptions) {
|
||||
const tokens = resolveDesignSystem(system, options).tokens;
|
||||
return (
|
||||
[
|
||||
["body", "body"],
|
||||
["h1,h2,h3,h4,h5,h6", "heading"],
|
||||
["code,pre,kbd,samp", "code"],
|
||||
] as const
|
||||
)
|
||||
.map(([selector, role]) => {
|
||||
const rules = [
|
||||
["font-family", `type.${role}`],
|
||||
["font-size", `type.${role}-size`],
|
||||
["font-weight", `type.${role}-weight`],
|
||||
["line-height", `type.${role}-leading`],
|
||||
["letter-spacing", `type.${role}-tracking`],
|
||||
]
|
||||
.filter(([, name]) => tokens[name!])
|
||||
.map(([property, name]) => `${property}:${tokens[name!]!.value};`)
|
||||
.join("");
|
||||
return `${selector}{${rules}}`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function previewDocument(
|
||||
system: DesignSystem,
|
||||
options: ResolveOptions & { content?: string; page?: number; direction?: string } = {},
|
||||
@@ -60,15 +105,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 +184,34 @@ 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 +
|
||||
previewClipboardRuntime +
|
||||
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}${typographyCss(system, options)}.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}.preview-action,.showcase-action{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border:1px solid var(--wrn-color-primary);border-radius:var(--wrn-radius-sm);background:var(--wrn-color-primary);color:var(--wrn-color-primary-contrast);font:inherit;font-size:14px;font-weight:600;cursor:pointer}.preview-action:focus-visible,.showcase-action:focus-visible{outline:2px solid var(--wrn-color-focus);outline-offset:3px}.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="Footer"],[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('This is a sample notification.',{title:'Preview notification',duration:3500});});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 +234,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' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src data:; 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)}${typographyCss(system, options)}</style>`,
|
||||
componentRecipeCss: theme.recipeCss,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function createStudioHandler(
|
||||
...headers,
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"content-security-policy":
|
||||
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'",
|
||||
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'",
|
||||
},
|
||||
});
|
||||
return async (request: Request): Promise<Response> => {
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { manualHtml, manualCss } from "./manual-editor.ts";
|
||||
import { STUDIO_PRESETS } from "./presets.ts";
|
||||
import { guidanceHtml, guidanceCss } from "./guidance-shell.ts";
|
||||
export const studioHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WrNexus Design Studio</title><link rel="stylesheet" href="/studio.css"><script type="module" src="/studio.js"></script></head><body>
|
||||
<header><a class="brand" href="/">WrNexus <strong>Design Studio</strong></a><span id="save-state" role="status">Local workspace</span><button id="undo">Undo</button><button id="redo">Redo</button><button id="export">Export JSON</button><button id="review" class="primary">Review changes</button></header>
|
||||
<main><aside><p class="eyebrow">Workspace</p><h1>Your system.<br>Your components.</h1><p class="intro">Edit one design system and preview it on the existing component library.</p><label>Start from a preset<select id="preset"><option value="">Choose a preset</option><option value="square">Square / Industrial</option><option value="soft">Soft / Organic</option><option value="angular">Angular / Lo-Fi</option></select></label><div class="row"><button id="new">Create new</button><button id="duplicate">Duplicate</button></div><label>System name<input id="system-name"></label><label>System ID<input id="system-id"></label>
|
||||
<nav aria-label="Editors"><button data-tab="guided">Guided design</button><button data-tab="foundations" aria-current="page">Foundations</button><button data-tab="components">Components</button><button data-tab="source">Source & import</button><button data-tab="history">History</button><button data-tab="review">Review & apply</button></nav><p class="footnote">Local drafts stay in this browser. Export a file to keep a portable copy.</p></aside>
|
||||
<section class="workspace"><div class="workspace-title"><div><p class="eyebrow">Live authoring</p><h2 id="editor-title">Foundations</h2></div><span id="validation-state" class="badge">Checking</span></div><div id="diagnostics" role="status" aria-live="polite"></div>
|
||||
<main><aside><p class="eyebrow">Workspace</p><h1>Your system.<br>Your components.</h1><p class="intro">Edit one design system and preview it on the existing component library.</p><label>Start from a preset<select id="preset"><option value="">Choose a preset</option>${Object.entries(
|
||||
STUDIO_PRESETS,
|
||||
)
|
||||
.map(([id, system]) => `<option value="${id}">${system.name}</option>`)
|
||||
.join(
|
||||
"",
|
||||
)}</select></label><div class="row"><button id="new">Create new</button><button id="duplicate">Duplicate</button></div><label>System name<input id="system-name"></label><label>System ID<input id="system-id"></label>
|
||||
<nav aria-label="Editors"><button data-tab="manual" aria-current="page">Manual design</button><button data-tab="guided">Guided design</button><button data-tab="foundations">Foundations</button><button data-tab="components">Components</button><button data-tab="source">Source & import</button><button data-tab="history">History</button><button data-tab="review">Review & apply</button></nav><p class="footnote">Local drafts stay in this browser. Export a file to keep a portable copy.</p></aside>
|
||||
<section class="workspace"><div class="workspace-title"><div><p class="eyebrow">Live authoring</p><h2 id="editor-title">Manual design</h2></div><span id="validation-state" class="badge">Checking</span></div><div id="diagnostics" role="status" aria-live="polite"></div>
|
||||
${manualHtml}
|
||||
${guidanceHtml}
|
||||
<section id="foundations" class="editor"><div class="row"><label>Search tokens<input id="token-search" type="search" placeholder="Color, shape, typography…"></label><label>Edit layer<select id="token-layer"></select></label></div><p class="help">Aliases use @token.name. The resolved value and its source appear below each field.</p><div id="tokens" class="token-grid"></div><details><summary>Add a token, mode or density</summary><div class="row"><label>Token name<input id="add-token-name" placeholder="space.section"></label><label>Type<select id="add-token-type"></select></label><label>Value<input id="add-token-value" placeholder="24px"></label><button id="add-token">Add token</button></div><div class="row"><label>New layer name<input id="add-layer-name" placeholder="compact"></label><button id="add-mode">Add mode</button><button id="add-density">Add density</button></div></details></section>
|
||||
<section id="foundations" class="editor" hidden><div class="row"><label>Search tokens<input id="token-search" type="search" placeholder="Color, shape, typography…"></label><label>Edit layer<select id="token-layer"></select></label></div><p class="help">Aliases use @token.name. The resolved value and its source appear below each field.</p><div id="tokens" class="token-grid"></div><details><summary>Add a token, mode or density</summary><div class="row"><label>Token name<input id="add-token-name" placeholder="space.section"></label><label>Type<select id="add-token-type"></select></label><label>Value<input id="add-token-value" placeholder="24px"></label><button id="add-token">Add token</button></div><div class="row"><label>New layer name<input id="add-layer-name" placeholder="compact"></label><button id="add-mode">Add mode</button><button id="add-density">Add density</button></div></details></section>
|
||||
<section id="components" class="editor" hidden><div class="row"><label>Find a component<input type="search" id="component-search" placeholder="Search 104 components"></label><label>Component<select id="component"></select></label></div><div id="contract"></div><div class="row"><label>Part<select id="part"></select></label><label>Property<select id="property"></select></label></div><div class="row"><label>Recipe value<input id="recipe-value" placeholder="@shape.control or 8px"></label><button id="set-recipe">Set default</button><button id="remove-recipe">Remove default</button></div><label>Complete component recipe JSON<textarea id="recipe-source" rows="12" spellcheck="false"></textarea></label><button id="save-recipe">Save component recipe</button><p class="help">Recipe JSON supports the contract’s variants, compound conditions and states. All edits pass through engine validation.</p></section>
|
||||
<section id="source" class="editor" hidden><label>Canonical design system JSON<textarea id="source-text" rows="22" spellcheck="false"></textarea></label><button id="save-source">Update draft</button><button id="download-draft">Export raw draft</button><details open><summary>Import a design</summary><label>Format<select id="import-format"><option value="canonical">Canonical design system JSON</option><option value="legacy">WrNexus theme JSON</option><option value="tokens">Exchange tokens: literal scalar / alias subset</option></select></label><label>Choose a local JSON file<input id="import-file" type="file" accept=".json,application/json"></label><label>Or paste JSON<textarea id="import-text" rows="7" spellcheck="false"></textarea></label><button id="inspect-import">Inspect import</button><pre id="import-report"></pre><button id="accept-import" disabled>Use imported design</button></details></section>
|
||||
<section id="history" class="editor" hidden><p>Up to 25 local revisions, including incomplete source edits. Undo and redo also restore invalid drafts.</p><ol id="revisions"></ol></section>
|
||||
<section id="review-panel" class="editor" hidden><h3>Review before applying</h3><p id="target"></p><p>Apply writes a validated system, compiled CSS and theme configuration to the configured local application folder. Existing application configuration is preserved. Import the generated theme configuration to use it.</p><pre id="changes"></pre><div class="row"><button id="apply" class="primary">Apply to local folder</button><button id="rollback">Rollback last apply</button></div><h3>Portable exports</h3><div class="row"><button data-export="canonical">Canonical JSON</button><button data-export="css">Compiled CSS</button><button data-export="theme">Theme config</button><button data-export="tokens">Exchange tokens</button><button data-export="report">Compatibility report</button></div><p class="help">CSS and theme configuration are alternative integration paths. Publication and deployment are separate.</p><pre id="integration"></pre></section>
|
||||
</section><section class="preview"><div class="preview-heading"><div><p class="eyebrow">Same content, different decisions</p><h2>Preview</h2></div><button id="refresh-preview">Refresh</button></div><div class="preview-controls"><label>Mode<select id="preview-mode"></select></label><label>Density<select id="preview-density"></select></label><label>Width<select id="preview-width"><option value="100%">Responsive</option><option value="390px">390 px</option><option value="768px">768 px</option><option value="1280px">1280 px</option></select></label><label>Direction<select id="preview-direction"><option value="ltr">LTR</option><option value="rtl">RTL</option></select></label><label>Content<select id="preview-content"><option value="composed">Composed page</option><option value="catalog">Component gallery</option></select></label><label>Compare<select id="compare"><option value="">No comparison</option><option value="applied">Last applied</option><option value="square">Square</option><option value="soft">Soft</option><option value="angular">Angular</option></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>`;
|
||||
</section><section class="preview"><div class="preview-heading"><div><p class="eyebrow">Same content, different decisions</p><h2>Preview</h2></div><div class="row"><button id="show-draft" hidden>Show current draft</button><button id="refresh-preview">Refresh</button></div></div><div class="preview-controls"><label>Mode<select id="preview-mode"></select></label><label>Density<select id="preview-density"></select></label><label>Width<select id="preview-width"><option value="100%">Responsive</option><option value="390px">390 px</option><option value="768px">768 px</option><option value="1280px">1280 px</option></select></label><label>Direction<select id="preview-direction"><option value="ltr">LTR</option><option value="rtl">RTL</option></select></label><label>Content<select id="preview-content"><option value="composed">Composed page</option><option value="catalog">Component gallery</option></select></label><label>Compare<select id="compare"><option value="">No comparison</option><option value="applied">Last applied</option>${Object.entries(
|
||||
STUDIO_PRESETS,
|
||||
)
|
||||
.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 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
|
||||
export const studioCss = `${manualCss}${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
Line truncated
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { handlePreviewClipboard } from "../src/preview-clipboard.ts";
|
||||
|
||||
test("clipboard broker accepts only current preview frames and bounded user-initiated text", async () => {
|
||||
const results: unknown[] = [];
|
||||
const source = { postMessage: (value: unknown) => results.push(value) } as unknown as Window;
|
||||
const frames = [{ contentWindow: source, src: "https://example.test/preview/current" }];
|
||||
const event = {
|
||||
source,
|
||||
data: {
|
||||
type: "wrn-studio-copy",
|
||||
id: "copy-1",
|
||||
path: "/preview/current",
|
||||
text: "npm install @wrnexus/ui",
|
||||
},
|
||||
};
|
||||
const copied: string[] = [];
|
||||
const write = async (text: string) => {
|
||||
copied.push(text);
|
||||
};
|
||||
expect(await handlePreviewClipboard(event, frames, write)).toBe(true);
|
||||
expect(copied).toEqual(["npm install @wrnexus/ui"]);
|
||||
expect(results).toEqual([{ type: "wrn-studio-copy-result", id: "copy-1", ok: true }]);
|
||||
expect(await handlePreviewClipboard({ ...event, source: null }, frames, write)).toBe(false);
|
||||
expect(
|
||||
await handlePreviewClipboard(
|
||||
{ ...event, data: { ...event.data, path: "/preview/old" } },
|
||||
frames,
|
||||
write,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await handlePreviewClipboard(
|
||||
{ ...event, data: { ...event.data, text: "x".repeat(16385) } },
|
||||
frames,
|
||||
write,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(await handlePreviewClipboard(event, frames, write, false)).toBe(true);
|
||||
expect(copied).toHaveLength(1);
|
||||
expect(results.at(-1)).toEqual({ type: "wrn-studio-copy-result", id: "copy-1", ok: false });
|
||||
});
|
||||
|
||||
test("clipboard denial reports failure rather than claiming a copy succeeded", async () => {
|
||||
let result: any;
|
||||
const source = {
|
||||
postMessage: (value: unknown) => {
|
||||
result = value;
|
||||
},
|
||||
} as unknown as Window;
|
||||
await handlePreviewClipboard(
|
||||
{
|
||||
source,
|
||||
data: { type: "wrn-studio-copy", id: "copy-2", path: "/preview/current", text: "test" },
|
||||
},
|
||||
[{ contentWindow: source, src: "https://example.test/preview/current" }],
|
||||
async () => {
|
||||
throw Error("denied");
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generationResponse, requestGeneration } from "../src/generation-transport.ts";
|
||||
|
||||
test("HTML gateway failures produce a clear diagnostic rather than a JSON syntax error", async () => {
|
||||
for (const status of [502, 503, 504])
|
||||
await expect(
|
||||
generationResponse(new Response("<html>Gateway error</html>", { status })),
|
||||
).rejects.toThrow(`HTTP ${status}`);
|
||||
await expect(
|
||||
generationResponse(new Response("<html>private proxy text</html>", { status: 504 })),
|
||||
).rejects.not.toThrow("private proxy text");
|
||||
});
|
||||
test("generation uses short job polls and preserves legacy response compatibility", async () => {
|
||||
const batch = { alternatives: [1, 2, 3], diagnostics: [] };
|
||||
let calls = 0;
|
||||
const transport = (async (_url: unknown, init?: RequestInit) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
expect(new Headers(init?.headers).get("prefer")).toBe("respond-async");
|
||||
return Response.json(
|
||||
{ job: { id: "11111111-1111-4111-8111-111111111111" } },
|
||||
{ status: 202 },
|
||||
);
|
||||
}
|
||||
expect(init?.method).toBeUndefined();
|
||||
return Response.json({ status: "complete", result: batch });
|
||||
}) as unknown as typeof fetch;
|
||||
expect(
|
||||
await requestGeneration(
|
||||
new URL("https://example.test/design-studio/api/generate"),
|
||||
{},
|
||||
new AbortController().signal,
|
||||
transport,
|
||||
),
|
||||
).toEqual(batch);
|
||||
expect(calls).toBe(2);
|
||||
expect(
|
||||
await requestGeneration(
|
||||
new URL("https://example.test/api/generate"),
|
||||
{},
|
||||
new AbortController().signal,
|
||||
(async () => Response.json(batch)) as unknown as typeof fetch,
|
||||
),
|
||||
).toEqual(batch);
|
||||
});
|
||||
test("cancelling a pending job sends cancellation without replacing a draft", async () => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
const transport = (async (_url: unknown, init?: RequestInit) => {
|
||||
if (init?.method === "DELETE") {
|
||||
cancelled = true;
|
||||
return Response.json({ status: "cancelled" });
|
||||
}
|
||||
controller.abort();
|
||||
return Response.json({ job: { id: "11111111-1111-4111-8111-111111111111" } }, { status: 202 });
|
||||
}) as unknown as typeof fetch;
|
||||
await expect(
|
||||
requestGeneration(
|
||||
new URL("https://example.test/api/generate"),
|
||||
{},
|
||||
controller.signal,
|
||||
transport,
|
||||
),
|
||||
).rejects.toBeDefined();
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
@@ -202,6 +202,51 @@ test("provider adapter uses bounded patches, compact capabilities and one repair
|
||||
expect(context).toContain("Repair these diagnostics");
|
||||
});
|
||||
|
||||
test("empty drafts require flat typed tokens and reject nested color groups before repair", async () => {
|
||||
const system = {
|
||||
schemaVersion: 1 as const,
|
||||
id: "empty",
|
||||
name: "Empty",
|
||||
version: "1.0.0",
|
||||
tokens: {},
|
||||
};
|
||||
let calls = 0;
|
||||
const batch = await generateDesigns(await request("directions", [], system), {
|
||||
provider: {
|
||||
name: "fixture",
|
||||
async generate(_prompt, options) {
|
||||
calls++;
|
||||
expect(options!.system).toContain("Token maps are FLAT");
|
||||
expect(options!.system).toContain("COMPLETE token object");
|
||||
return {
|
||||
provider: "fixture",
|
||||
value: JSON.stringify({
|
||||
alternatives: ["#002FA7", "#00642C", "#A32957"].map((color, index) => ({
|
||||
name: `Direction ${index}`,
|
||||
rationale: "A distinct brand color.",
|
||||
patch: [
|
||||
calls === 1
|
||||
? set("/tokens/color", { primary: { type: "color", value: color } })
|
||||
: set("/tokens/color.primary", {
|
||||
type: "color",
|
||||
value: color,
|
||||
legacy: "color-primary",
|
||||
}),
|
||||
],
|
||||
})),
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
expect(batch.alternatives).toHaveLength(3);
|
||||
expect(batch.diagnostics).toEqual([]);
|
||||
for (const alternative of batch.alternatives)
|
||||
expect(validateDesignSystem(alternative.system)).toEqual([]);
|
||||
expect(system.tokens).toEqual({});
|
||||
});
|
||||
|
||||
test("provider invalid parts and lock violations cannot survive bounded repair", async () => {
|
||||
let calls = 0;
|
||||
const provider: GenerationProvider = {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
validateDesignSystem,
|
||||
compileDesignSystem,
|
||||
} from "@wrnexus/design-system";
|
||||
import { MANUAL_TYPOGRAPHY, MANUAL_FONTS, manualDesign } from "../src/manual.ts";
|
||||
import { StudioSession } from "../src/session.ts";
|
||||
import { checkDecisionLocks } from "../src/decisions.ts";
|
||||
|
||||
test("manual colors update only their mode and maintain readable contrast and legacy bindings", () => {
|
||||
const source = CORE_DESIGN_SYSTEMS.soft;
|
||||
const original = JSON.stringify(source);
|
||||
const next = manualDesign(source, {
|
||||
kind: "color",
|
||||
mode: "light",
|
||||
name: "primary",
|
||||
value: "#000000",
|
||||
});
|
||||
expect(next.modes!.light!["color.primary"]!.value).toBe("#000000");
|
||||
expect(next.modes!.light!["color.primary-contrast"]!.value).toBe("#FFFFFF");
|
||||
expect(next.modes!.light!["color.focus"]!.legacy).toBe("color-focus");
|
||||
expect(next.modes!.dark).toEqual(source.modes!.dark);
|
||||
expect(JSON.stringify(source)).toBe(original);
|
||||
expect(validateDesignSystem(next)).toEqual([]);
|
||||
expect(() => compileDesignSystem(next, { mode: "light" })).not.toThrow();
|
||||
expect(() =>
|
||||
manualDesign(source, {
|
||||
kind: "color",
|
||||
mode: "light",
|
||||
name: "primary",
|
||||
value: "red; display:none",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
test("manual foundations are recoverable and decision locks still detect changes", () => {
|
||||
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft);
|
||||
const before = JSON.stringify(session.valid);
|
||||
const next = manualDesign(session.valid, { kind: "token", name: "shape.control", value: "8px" });
|
||||
expect(checkDecisionLocks(session.valid, next, ["/tokens/shape.control"])).not.toHaveLength(0);
|
||||
session.replace(next, "Manual shape");
|
||||
expect(session.valid.tokens["shape.control"]!.value).toBe("8px");
|
||||
session.undo();
|
||||
expect(JSON.stringify(session.valid)).toBe(before);
|
||||
session.redo();
|
||||
expect(session.valid.tokens["shape.control"]!.value).toBe("8px");
|
||||
expect(() =>
|
||||
manualDesign(next, { kind: "token", name: "shape.control", value: "-2px" }),
|
||||
).toThrow();
|
||||
});
|
||||
test("manual typography, spacing, surfaces and semantic colors validate for all core themes", () => {
|
||||
for (const original of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
let system = structuredClone(original);
|
||||
for (const [name, value] of [
|
||||
["type.body", "Arial, sans-serif"],
|
||||
["type.heading", "Georgia, serif"],
|
||||
["space.panel", "24px"],
|
||||
["shape.panel", "12px"],
|
||||
["elevation.panel", "none"],
|
||||
])
|
||||
system = manualDesign(system, { kind: "token", name: name!, value: value! });
|
||||
for (const mode of ["light", "dark"])
|
||||
for (const name of ["secondary", "success", "warning", "danger", "info"] as const)
|
||||
system = manualDesign(system, { kind: "color", mode, name, value: "#00677B" });
|
||||
expect(validateDesignSystem(system)).toEqual([]);
|
||||
expect(() => compileDesignSystem(system, { mode: "dark" })).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("manual button styles, tilt, pill shapes, default mode and advanced colors compile", () => {
|
||||
let system = manualDesign(CORE_DESIGN_SYSTEMS.soft, { kind: "buttonStyle", value: "outline" });
|
||||
system = manualDesign(system, { kind: "tilt", value: "none" });
|
||||
system = manualDesign(system, { kind: "token", name: "shape.control", value: "999px" });
|
||||
system = manualDesign(system, { kind: "mode", value: "dark" });
|
||||
system = manualDesign(system, {
|
||||
kind: "color",
|
||||
mode: "dark",
|
||||
name: "primary-contrast",
|
||||
value: "#FFFFFF",
|
||||
});
|
||||
expect(system.defaultMode).toBe("dark");
|
||||
expect(system.recipes!.Button!.variants!.at(-1)!.parts.control!.backgroundColor).toBe(
|
||||
"transparent",
|
||||
);
|
||||
expect(system.recipes!.Button!.base!.control!.rotate).toBe("0deg");
|
||||
expect(() => compileDesignSystem(system)).not.toThrow();
|
||||
});
|
||||
|
||||
test("manual form saves one atomic revision and rejects locked changes without corrupting drafts", async () => {
|
||||
const { Window } = await import("happy-dom");
|
||||
const { manualHtml, setupManual } = await import("../src/manual-editor.ts");
|
||||
const win = new Window();
|
||||
const names = ["document", "HTMLInputElement", "Option"] as const;
|
||||
const previous = Object.fromEntries(names.map((name) => [name, (globalThis as any)[name]]));
|
||||
try {
|
||||
(globalThis as any).document = win.document;
|
||||
(globalThis as any).HTMLInputElement = win.HTMLInputElement;
|
||||
(globalThis as any).Option = (win as any).Option;
|
||||
win.document.body.innerHTML =
|
||||
manualHtml + '<select id="preview-mode"><option value="light">light</option></select>';
|
||||
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft);
|
||||
let notice = "";
|
||||
const editor = setupManual(session, {
|
||||
changed: async () => {
|
||||
editor.render();
|
||||
},
|
||||
message: (text) => {
|
||||
notice = text;
|
||||
},
|
||||
mode: () => {},
|
||||
});
|
||||
editor.render();
|
||||
const input = win.document.querySelector('[data-manual-token="shape.control"]') as any;
|
||||
input.value = "12";
|
||||
(win.document.querySelector('select[data-manual-token="type.heading"]') as any).value =
|
||||
"google:Inter";
|
||||
(win.document.querySelector('[data-manual-token="type.body-size"]') as any).value = "18";
|
||||
(win.document.querySelector('[data-manual-token="type.body-leading"]') as any).value = "1.8";
|
||||
(win.document.querySelector('input[type=text][data-manual-color="primary"]') as any).value =
|
||||
"#6336AD";
|
||||
(win.document.querySelector("[data-manual-apply]") as any).click();
|
||||
await Bun.sleep(10);
|
||||
expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
|
||||
expect(session.valid.modes!.light!["color.primary"]!.value).toBe("#6336AD");
|
||||
expect(session.valid.tokens["type.heading"]!.value).toBe('"Inter", sans-serif');
|
||||
expect(session.valid.googleFonts).toEqual(["Inter"]);
|
||||
expect(session.valid.tokens["type.body-size"]!.value).toBe("18px");
|
||||
expect(session.valid.tokens["type.body-leading"]!.value).toBe("1.8");
|
||||
expect(session.history).toHaveLength(2);
|
||||
session.setLocks(["/tokens/shape.control"]);
|
||||
editor.render();
|
||||
(win.document.querySelector('[data-manual-token="shape.control"]') as any).value = "24";
|
||||
(win.document.querySelector("[data-manual-apply]") as any).click();
|
||||
await Bun.sleep(10);
|
||||
expect(notice).toContain("locked decision");
|
||||
expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
|
||||
expect(session.diagnostics).toEqual([]);
|
||||
} finally {
|
||||
for (const name of names) (globalThis as any)[name] = previous[name];
|
||||
await win.happyDOM.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("manual typography controls persist in exported recipes and reject invalid ranges", () => {
|
||||
for (const original of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
let system = structuredClone(original);
|
||||
for (const [name, setting] of Object.entries(MANUAL_TYPOGRAPHY))
|
||||
system = manualDesign(system, {
|
||||
kind: "token",
|
||||
name,
|
||||
value: String(setting.initial) + setting.unit,
|
||||
});
|
||||
system = manualDesign(system, {
|
||||
kind: "token",
|
||||
name: "type.code",
|
||||
value: MANUAL_FONTS.consolas,
|
||||
});
|
||||
expect(validateDesignSystem(system)).toEqual([]);
|
||||
const css = compileDesignSystem(system).css;
|
||||
expect(css).toContain("font-size:16px");
|
||||
expect(css).toContain("font-weight:700");
|
||||
expect(css).toContain("line-height:1.5");
|
||||
expect(JSON.parse(JSON.stringify(system)).tokens["type.code"].value).toBe(
|
||||
MANUAL_FONTS.consolas,
|
||||
);
|
||||
expect(() =>
|
||||
manualDesign(system, { kind: "token", name: "type.body-size", value: "200px" }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
manualDesign(system, { kind: "token", name: "type.body-leading", value: "0" }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
manualDesign(system, { kind: "token", name: "type.heading", value: "serif; color:red" }),
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("Google Fonts survive JSON export, compile first, and reject injected URLs", () => {
|
||||
const source = CORE_DESIGN_SYSTEMS.soft;
|
||||
const next = manualDesign(source, {
|
||||
kind: "googleFont",
|
||||
name: "type.heading",
|
||||
value: "Playfair Display",
|
||||
});
|
||||
expect(source.googleFonts).toBeUndefined();
|
||||
const portable = JSON.parse(JSON.stringify(next));
|
||||
expect(validateDesignSystem(portable)).toEqual([]);
|
||||
const compiled = compileDesignSystem(portable);
|
||||
expect(
|
||||
compiled.css.startsWith(
|
||||
'@import url("https://fonts.googleapis.com/css2?family=Playfair%20Display:wght@400;700&display=swap");',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(compiled.recipeCss.startsWith("@import")).toBe(true);
|
||||
expect(compiled.css.match(/@import/g)).toHaveLength(1);
|
||||
expect(
|
||||
validateDesignSystem({ ...next, googleFonts: ['Inter\";}@import url(https://evil.test)'] }),
|
||||
).not.toHaveLength(0);
|
||||
const changed = manualDesign(next, { kind: "googleFont", name: "type.heading", value: "Lora" });
|
||||
expect(changed.googleFonts).toEqual(["Lora"]);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
validateDesignSystem,
|
||||
compileDesignSystem,
|
||||
} from "@wrnexus/design-system";
|
||||
import { STUDIO_PRESETS } from "../src/presets.ts";
|
||||
import { studioHtml } from "../src/shell.ts";
|
||||
import { StudioSession } from "../src/session.ts";
|
||||
|
||||
test("thirteen distinct Studio presets validate and compile both modes without changing core presets", () => {
|
||||
expect(Object.keys(STUDIO_PRESETS)).toHaveLength(13);
|
||||
expect(Object.keys(CORE_DESIGN_SYSTEMS)).toEqual(["square", "soft", "angular"]);
|
||||
const signatures = new Set<string>();
|
||||
for (const [id, system] of Object.entries(STUDIO_PRESETS)) {
|
||||
expect(system.id).toBe(id);
|
||||
expect(validateDesignSystem(system)).toEqual([]);
|
||||
expect(studioHtml).toContain(`<option value="${id}">${system.name}</option>`);
|
||||
signatures.add(JSON.stringify([system.tokens, system.modes]));
|
||||
for (const mode of ["light", "dark"])
|
||||
expect(() => compileDesignSystem(system, { mode })).not.toThrow();
|
||||
}
|
||||
expect(signatures.size).toBe(13);
|
||||
});
|
||||
|
||||
test("choosing a new preset stays undoable and never mutates the catalog", () => {
|
||||
const session = new StudioSession(STUDIO_PRESETS.square);
|
||||
const original = JSON.stringify(STUDIO_PRESETS.swiss);
|
||||
session.replace(STUDIO_PRESETS.swiss!, "Selected Swiss");
|
||||
session.edit(JSON.stringify({ ...session.valid, name: "My Swiss" }));
|
||||
expect(JSON.stringify(STUDIO_PRESETS.swiss)).toBe(original);
|
||||
session.undo();
|
||||
session.undo();
|
||||
expect(session.valid.id).toBe("square");
|
||||
});
|
||||
|
||||
test("revised directions stay distinct and Editorial status tokens remain monochrome", () => {
|
||||
const organic = ["soft", "forest", "ember"].map((id) => STUDIO_PRESETS[id]!);
|
||||
expect(new Set(organic.map((s) => s.modes!.light!["color.bg"]!.value)).size).toBe(3);
|
||||
expect(new Set(organic.map((s) => s.tokens["shape.control"]!.value)).size).toBe(3);
|
||||
for (const tokens of [
|
||||
STUDIO_PRESETS.editorial!.tokens,
|
||||
...Object.values(STUDIO_PRESETS.editorial!.modes!),
|
||||
]) {
|
||||
for (const name of ["success", "warning", "danger", "info"]) {
|
||||
const color = String(tokens[`color.${name}`]!.value);
|
||||
expect(color).toMatch(/^#([0-9a-f]{2})\1\1$/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { CORE_DESIGN_SYSTEMS } from "../../design-system/src/index.ts";
|
||||
import { resolveThemeConfig } from "../../styles/src/theme.ts";
|
||||
import { designRevision } from "../src/decisions.ts";
|
||||
import { DEFAULT_DESIGN_BRIEF } from "../src/generation.ts";
|
||||
import { STUDIO_PRESETS } from "../src/presets.ts";
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrn-studio-test-"));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -139,7 +140,7 @@ test("server serves a browser bundle, rejects cross-origin, invalid, oversized a
|
||||
});
|
||||
|
||||
test("all presets generate isolated real component previews with light/dark and responsive/RTL controls", async () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS))
|
||||
for (const system of Object.values(STUDIO_PRESETS))
|
||||
for (const mode of ["light", "dark"]) {
|
||||
const result = await send("/api/preview", { system, mode, direction: "rtl" });
|
||||
expect(result.status).toBe(200);
|
||||
@@ -167,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.4",
|
||||
"description": "Typed, deterministic design tokens and legacy WrNexus theme integration.",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -23,18 +23,35 @@ export function catalogRecipes(): Record<string, ComponentRecipe> {
|
||||
const circle = ["circle", "round"].includes(axes.shape?.default ?? "");
|
||||
const semanticGeometry =
|
||||
/thumb|track|spinner|progress|marker|port$|status|dot|star|srOnly/i.test(part);
|
||||
// Joined surfaces share the outer component's corners. Rounding each
|
||||
// child creates seams, scalloped panels and rounded splitter rails.
|
||||
const joined = (
|
||||
{
|
||||
StatsBar: ["item", "content"],
|
||||
AuthSplitLayout: ["content"],
|
||||
Collapse: ["trigger", "panel", "content"],
|
||||
InputNumber: ["button", "content", "input"],
|
||||
LayoutSplitter: ["handle"],
|
||||
} as Record<string, string[]>
|
||||
)[name]?.includes(part);
|
||||
if (
|
||||
descriptor.properties.includes("borderRadius") &&
|
||||
!roundedFalse &&
|
||||
!circle &&
|
||||
!semanticGeometry
|
||||
!semanticGeometry &&
|
||||
!joined
|
||||
) {
|
||||
styles.borderRadius = ref(
|
||||
descriptor.profile === "control" ? "shape.control" : "shape.panel",
|
||||
);
|
||||
}
|
||||
// Only existing surfaces receive elevation. Layout wrappers and provider hosts are untouched.
|
||||
if (descriptor.profile === "surface" && descriptor.properties.includes("boxShadow"))
|
||||
if (
|
||||
descriptor.profile === "surface" &&
|
||||
descriptor.properties.includes("boxShadow") &&
|
||||
!joined &&
|
||||
!(name === "PreferenceSwitcher" && part === "menu")
|
||||
)
|
||||
styles.boxShadow = ref("elevation.panel");
|
||||
if (Object.keys(styles).length) base[part] = styles;
|
||||
}
|
||||
|
||||
@@ -58,9 +58,20 @@ export function compileDesignSystem(
|
||||
compileComponentRecipes(modeResolved, `${selector}[data-theme="${mode}"]`, options.scope),
|
||||
);
|
||||
}
|
||||
const recipeCss = recipeBlocks.filter(Boolean).join("\n");
|
||||
const fontImports = (resolved.googleFonts ?? [])
|
||||
.map(
|
||||
(family) =>
|
||||
`@import url("https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;700&display=swap");`,
|
||||
)
|
||||
.join("\n");
|
||||
const bodyRecipes = recipeBlocks.filter(Boolean).join("\n");
|
||||
const recipeCss = [fontImports, bodyRecipes].filter(Boolean).join("\n");
|
||||
return {
|
||||
css: blocks.join("\n") + "\n" + (recipeCss ? recipeCss + "\n" : ""),
|
||||
css:
|
||||
(fontImports ? fontImports + "\n" : "") +
|
||||
blocks.join("\n") +
|
||||
"\n" +
|
||||
(bodyRecipes ? bodyRecipes + "\n" : ""),
|
||||
recipeCss,
|
||||
resolved,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Shared immutable property profiles keep catalog metadata small and explicit. */
|
||||
export const CATALOG_PART_PROPERTIES = {
|
||||
text: ["color", "fontFamily", "fontSize", "fontWeight", "letterSpacing"],
|
||||
text: ["color", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing"],
|
||||
layout: ["color", "fontFamily", "gap", "paddingBlock", "paddingInline"],
|
||||
surface: [
|
||||
"backgroundColor",
|
||||
@@ -25,6 +25,7 @@ export const CATALOG_PART_PROPERTIES = {
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"outlineColor",
|
||||
"paddingBlock",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -53,7 +53,7 @@ function recipes(angular: boolean, outlined: boolean): Record<string, ComponentR
|
||||
paddingInline: ref("space.control"),
|
||||
boxShadow: ref("elevation.panel"),
|
||||
fontFamily: ref("type.body"),
|
||||
...(angular ? { rotate: "-2deg" } : {}),
|
||||
...(angular ? { rotate: "-0.5deg" } : {}),
|
||||
},
|
||||
},
|
||||
variants: [
|
||||
@@ -82,9 +82,9 @@ 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" } : {}) },
|
||||
panel: { ...surface, ...(angular ? { rotate: "0.25deg" } : {}) },
|
||||
body: { paddingInline: ref("space.panel"), paddingBlock: ref("space.panel") },
|
||||
heading: { fontFamily: ref("type.heading") },
|
||||
}),
|
||||
@@ -132,20 +132,20 @@ function preset(id: string, name: string, soft: boolean, angular: boolean): Desi
|
||||
}
|
||||
: angular
|
||||
? {
|
||||
bg: "#E8E0C0",
|
||||
background: "#E8E0C0",
|
||||
surface: "#EDE4CF",
|
||||
"surface-2": "#E8E0C0",
|
||||
bg: "#F3ECD7",
|
||||
background: "#F3ECD7",
|
||||
surface: "#FFFBEF",
|
||||
"surface-2": "#E8DFC6",
|
||||
text: "#232320",
|
||||
foreground: "#232320",
|
||||
"text-muted": "#535146",
|
||||
border: "#535146",
|
||||
primary: "#8F2448",
|
||||
"primary-hover": "#701C39",
|
||||
"primary-active": "#59172D",
|
||||
border: "#B9AC8D",
|
||||
primary: "#314B6B",
|
||||
"primary-hover": "#243B57",
|
||||
"primary-active": "#1C2F46",
|
||||
"primary-contrast": "#FFFFFF",
|
||||
"primary-text": "#701C39",
|
||||
"primary-soft": "#E8E0C0",
|
||||
"primary-text": "#243B57",
|
||||
"primary-soft": "#E8DFC6",
|
||||
}
|
||||
: {
|
||||
bg: "#FFFFFF",
|
||||
@@ -190,11 +190,11 @@ function preset(id: string, name: string, soft: boolean, angular: boolean): Desi
|
||||
foreground: "#EDE4CF",
|
||||
"text-muted": "#D0C7B2",
|
||||
border: "#B0A78D",
|
||||
primary: "#FF9FC6",
|
||||
"primary-hover": "#FFC0DA",
|
||||
"primary-active": "#FFD2E5",
|
||||
primary: "#B9CFE8",
|
||||
"primary-hover": "#CEE0F5",
|
||||
"primary-active": "#DDEBFA",
|
||||
"primary-contrast": "#25221E",
|
||||
"primary-text": "#FF9FC6",
|
||||
"primary-text": "#B9CFE8",
|
||||
"primary-soft": "#454032",
|
||||
}
|
||||
: {
|
||||
@@ -224,7 +224,7 @@ function preset(id: string, name: string, soft: boolean, angular: boolean): Desi
|
||||
"control.height": token("dimension", soft ? "2.75rem" : "2.5rem", "control-height"),
|
||||
"type.body": token("fontFamily", body, "font-sans"),
|
||||
"type.heading": token("fontFamily", heading),
|
||||
"elevation.panel": token("shadow", angular ? "3px 3px 0 #535146" : "none", "shadow-1"),
|
||||
"elevation.panel": token("shadow", angular ? "2px 2px 0 #535146" : "none", "shadow-1"),
|
||||
...colors(dark),
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -57,6 +57,7 @@ export const RECIPE_PROPERTIES: Record<string, TokenType | "borderStyle" | "angl
|
||||
fontFamily: "fontFamily",
|
||||
fontSize: "dimension",
|
||||
fontWeight: "fontWeight",
|
||||
lineHeight: "number",
|
||||
letterSpacing: "dimension",
|
||||
outlineColor: "color",
|
||||
opacity: "number",
|
||||
@@ -74,6 +75,7 @@ function literal(property: string, value: unknown): boolean {
|
||||
Math.abs(parseFloat(value)) <= 8
|
||||
);
|
||||
if (!type || !validTokenValue(type, value) || isRecord(value)) return false;
|
||||
if (property === "lineHeight") return Number(value) >= 1 && Number(value) <= 3;
|
||||
if (property === "opacity") return Number(value) >= 0 && Number(value) <= 1;
|
||||
if (
|
||||
[
|
||||
|
||||
@@ -110,6 +110,9 @@ export function resolveDesignSystem(
|
||||
version: system.version,
|
||||
...(mode ? { mode } : {}),
|
||||
...(options.density ? { density: options.density } : {}),
|
||||
...(layers.some((layer) => layer.googleFonts)
|
||||
? { googleFonts: [...new Set(layers.flatMap((layer) => layer.googleFonts ?? []))] }
|
||||
: {}),
|
||||
modes,
|
||||
tokens,
|
||||
recipes,
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface DesignSystem {
|
||||
densities?: Record<string, TokenMap>;
|
||||
defaultMode?: string;
|
||||
recipes?: Record<string, ComponentRecipe>;
|
||||
/** Google Fonts families loaded with regular and bold weights and display=swap. */
|
||||
googleFonts?: string[];
|
||||
}
|
||||
export interface DesignDiagnostic {
|
||||
code: string;
|
||||
@@ -77,6 +79,7 @@ export interface ResolvedDesignSystem {
|
||||
modes: string[];
|
||||
tokens: Record<string, ResolvedToken>;
|
||||
recipes: Record<string, ComponentRecipe>;
|
||||
googleFonts?: string[];
|
||||
}
|
||||
export interface LegacyTheme {
|
||||
default?: string;
|
||||
|
||||
@@ -127,6 +127,7 @@ export function validateDesignSystem(input: unknown): DesignDiagnostic[] {
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"googleFonts",
|
||||
"extends",
|
||||
"tokens",
|
||||
"modes",
|
||||
@@ -156,6 +157,22 @@ export function validateDesignSystem(input: unknown): DesignDiagnostic[] {
|
||||
(typeof input.defaultMode !== "string" || !validName(input.defaultMode))
|
||||
)
|
||||
error("WRN-DS-NAME", "defaultMode", "Invalid mode name.");
|
||||
if (
|
||||
input.googleFonts !== undefined &&
|
||||
(!Array.isArray(input.googleFonts) ||
|
||||
input.googleFonts.length > 10 ||
|
||||
input.googleFonts.some(
|
||||
(name: unknown) =>
|
||||
typeof name !== "string" ||
|
||||
!/^[A-Za-z][A-Za-z0-9 ]{0,79}$/.test(name) ||
|
||||
name.trim() !== name,
|
||||
))
|
||||
)
|
||||
error(
|
||||
"WRN-DS-SCHEMA",
|
||||
"googleFonts",
|
||||
"Choose up to ten Google Font family names using letters, numbers and spaces.",
|
||||
);
|
||||
const map = (value: unknown, path: string) => {
|
||||
if (!isRecord(value)) {
|
||||
error("WRN-DS-SCHEMA", path, "Expected a token map.");
|
||||
|
||||
@@ -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 } },
|
||||
@@ -97,3 +108,20 @@ test("mode-dependent recipe values retain mode rules while identical values are
|
||||
expect(css).toContain("9px");
|
||||
expect(css).not.toContain('[data-theme="light"]');
|
||||
});
|
||||
|
||||
test("joined component parts keep their authored geometry instead of receiving independent rounding", () => {
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
for (const [name, parts] of Object.entries({
|
||||
StatsBar: ["item", "content"],
|
||||
AuthSplitLayout: ["content"],
|
||||
Collapse: ["trigger", "panel", "content"],
|
||||
InputNumber: ["button", "input", "content"],
|
||||
LayoutSplitter: ["handle"],
|
||||
})) {
|
||||
for (const part of parts) {
|
||||
expect(system.recipes![name]!.base?.[part]?.borderRadius).toBeUndefined();
|
||||
expect(system.recipes![name]!.base?.[part]?.boxShadow).toBeUndefined();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.83",
|
||||
"version": "0.8.84",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -338,7 +338,7 @@ component AdvancedSelect {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wrn-next__select-dropdown {fixed ? 'wrn-next__select-dropdown--fixed' : ''}"
|
||||
data-wrn-anchored="true" class="wrn-next__select-dropdown {fixed ? 'wrn-next__select-dropdown--fixed' : ''}"
|
||||
data-show="open"
|
||||
style="{open ? '' : 'display: none'}"
|
||||
data-wrn-owner="AdvancedSelect" data-wrn-part="selectDropdown" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
|
||||
@@ -40,6 +40,9 @@ component Clipboard {
|
||||
}
|
||||
status = "copying"
|
||||
output.copy({ value: value, sourceEvent: sourceEvent })
|
||||
if (window.wrnClipboard && window.wrnClipboard.writeText) {
|
||||
return window.wrnClipboard.writeText(value).then(copySucceeded).catch(copyFailed)
|
||||
}
|
||||
if (!window.navigator.clipboard || !window.navigator.clipboard.writeText) {
|
||||
copyFailed(new Error("Clipboard API is unavailable"))
|
||||
return
|
||||
|
||||
@@ -293,7 +293,7 @@ component ComboBox {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wrn-next__select-dropdown {fixed ? 'wrn-next__select-dropdown--fixed' : ''}"
|
||||
data-wrn-anchored="true" class="wrn-next__select-dropdown {fixed ? 'wrn-next__select-dropdown--fixed' : ''}"
|
||||
data-show="shouldShowDropdown()"
|
||||
style="{shouldShowDropdown() ? '' : 'display: none'}"
|
||||
data-wrn-owner="ComboBox" data-wrn-part="selectDropdown" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
|
||||
@@ -959,7 +959,7 @@ component DataTable {
|
||||
slot so it stays reactive -- a prop would be fixed at the
|
||||
value the dropdown was first rendered with. -->
|
||||
<Dropdown
|
||||
class="wrn-table__page-dropdown"
|
||||
data-wrn-anchored="true" class="wrn-table__page-dropdown"
|
||||
width="sm"
|
||||
size="sm"
|
||||
placement="top-end"
|
||||
|
||||
@@ -157,7 +157,7 @@ size: string = "default"
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--date-picker {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}" data-expanded="{expanded}" @keydown="calendarKeydown(event)" data-wrn-owner="DatePicker" data-wrn-part="root" data-wrn-component="DatePicker" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">
|
||||
<div class="wrn-next__field-heading" data-wrn-owner="DatePicker" data-wrn-part="heading" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><label for="{id || name}" data-wrn-owner="DatePicker" data-wrn-part="label" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint" data-wrn-owner="DatePicker" data-wrn-part="hint" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{cornerHint}</span>{/if}</div>
|
||||
<div class="wrn-next__picker-control" data-wrn-owner="DatePicker" data-wrn-part="pickerControl" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><input id="{id || name}" class="wrn-next__sr-only wrn-next__picker-value" type="text" name="{name}" value="{currentValue}" required="{required}" readonly aria-hidden="true" tabindex="-1" @invalid="handleInvalid(event)" data-wrn-owner="DatePicker" data-wrn-part="srOnly" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"/><button type="button" class="wrn-next__date-trigger" aria-label="{label}" disabled="{disabled || readonly}" aria-haspopup="dialog" aria-expanded="{expanded}" @click="openCalendar(event)" @focus="handleFocus(event)" @blur="handleBlur(event)" data-wrn-owner="DatePicker" data-wrn-part="dateTrigger" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><span>{displayValue()}</span><i class="icon-[lucide--calendar-days]" aria-hidden="true"></i></button>{#if currentValue && !readonly && !disabled}<button type="button" class="wrn-next__picker-clear" aria-label="Clear date" @click="clearDate(event)" data-wrn-owner="DatePicker" data-wrn-part="pickerClear" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">×</button>{/if}</div>
|
||||
<button type="button" class="wrn-picker-dismiss" aria-label="Close calendar" data-show="expanded" @click="finishDate(event)" data-wrn-owner="DatePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"></button><div data-show="expanded" class="wrn-next__calendar" role="dialog" aria-label="{label}" data-wrn-owner="DatePicker" data-wrn-part="calendar" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><div class="wrn-next__date-selectors" data-wrn-owner="DatePicker" data-wrn-part="dateSelectors" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><label data-wrn-owner="DatePicker" data-wrn-part="label" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">Month<select value="{selectedMonth}" @change="updateDate('month', event.currentTarget.value, event)" data-wrn-owner="DatePicker" data-wrn-part="control" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each months as month}<option value="{month.value}" selected="{month.value === selectedMonth}">{month.label}</option>{/each}</select></label><label data-wrn-owner="DatePicker" data-wrn-part="label" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">Year<select value="{selectedYear}" @change="updateDate('year', event.currentTarget.value, event)" data-wrn-owner="DatePicker" data-wrn-part="control" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each calendarYears() as calendarYear}<option value="{calendarYear}" selected="{String(calendarYear) === selectedYear}" data-text="calendarYear"></option>{/each}</select></label></div><div class="wrn-next__calendar-weekdays" aria-hidden="true" data-wrn-owner="DatePicker" data-wrn-part="calendarWeekdays" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each weekdays() as weekday}<span>{weekday}</span>{/each}</div><div class="wrn-next__calendar-grid" data-wrn-owner="DatePicker" data-wrn-part="calendarGrid" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each calendarDays() as day}<button type="button" data-value="{day.value}" data-today="{day.today}" style="grid-column: {day.column}" disabled="{day.disabled}" aria-label="{day.label}" aria-pressed="{day.value === currentValue.slice(0, 10)}" @click="selectDay(event)" data-wrn-owner="DatePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{day.day}</button>{/each}</div>{#if type === "datetime-local"}<TimePicker label="Time" value="{selectedTime}" @change="selectedTime = payload.value; currentValue = (currentValue ? currentValue.slice(0, 10) : selectedYear + '-' + selectedMonth + '-01') + 'T' + selectedTime; output.change({ value: currentValue, name: name })" />{/if}<button type="button" class="wrn-next__date-done" disabled="{!currentValue}" @click="finishDate(event)" data-wrn-owner="DatePicker" data-wrn-part="dateDone" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">Done</button></div>
|
||||
<button type="button" class="wrn-picker-dismiss" aria-label="Close calendar" data-show="expanded" @click="finishDate(event)" data-wrn-owner="DatePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"></button><div data-show="expanded" data-wrn-anchored="true" class="wrn-next__calendar" role="dialog" aria-label="{label}" data-wrn-owner="DatePicker" data-wrn-part="calendar" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><div class="wrn-next__date-selectors" data-wrn-owner="DatePicker" data-wrn-part="dateSelectors" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><label data-wrn-owner="DatePicker" data-wrn-part="label" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">Month<select value="{selectedMonth}" @change="updateDate('month', event.currentTarget.value, event)" data-wrn-owner="DatePicker" data-wrn-part="control" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each months as month}<option value="{month.value}" selected="{month.value === selectedMonth}">{month.label}</option>{/each}</select></label><label data-wrn-owner="DatePicker" data-wrn-part="label" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">Year<select value="{selectedYear}" @change="updateDate('year', event.currentTarget.value, event)" data-wrn-owner="DatePicker" data-wrn-part="control" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each calendarYears() as calendarYear}<option value="{calendarYear}" selected="{String(calendarYear) === selectedYear}" data-text="calendarYear"></option>{/each}</select></label></div><div class="wrn-next__calendar-weekdays" aria-hidden="true" data-wrn-owner="DatePicker" data-wrn-part="calendarWeekdays" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each weekdays() as weekday}<span>{weekday}</span>{/each}</div><div class="wrn-next__calendar-grid" data-wrn-owner="DatePicker" data-wrn-part="calendarGrid" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each calendarDays() as day}<button type="button" data-value="{day.value}" data-today="{day.today}" style="grid-column: {day.column}" disabled="{day.disabled}" aria-label="{day.label}" aria-pressed="{day.value === currentValue.slice(0, 10)}" @click="selectDay(event)" data-wrn-owner="DatePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{day.day}</button>{/each}</div>{#if type === "datetime-local"}<TimePicker label="Time" value="{selectedTime}" @change="selectedTime = payload.value; currentValue = (currentValue ? currentValue.slice(0, 10) : selectedYear + '-' + selectedMonth + '-01') + 'T' + selectedTime; output.change({ value: currentValue, name: name })" />{/if}<button type="button" class="wrn-next__date-done" disabled="{!currentValue}" @click="finishDate(event)" data-wrn-owner="DatePicker" data-wrn-part="dateDone" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">Done</button></div>
|
||||
{#if helperText}<small class="wrn-next__field-help" data-wrn-owner="DatePicker" data-wrn-part="help" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{helperText}</small>{/if}<small class="wrn-next__field-error" data-error="{name}" data-wrn-owner="DatePicker" data-wrn-part="error" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{error}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -47,10 +47,7 @@ component Footer {
|
||||
<div
|
||||
class="wrn-footer__pre"
|
||||
data-wrn-owner="Footer" data-wrn-part="pre" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
<slot
|
||||
name="pre-footer"
|
||||
>
|
||||
</slot>
|
||||
<slot name="pre-footer"></slot>
|
||||
</div>
|
||||
|
||||
{#if items.length > 0}
|
||||
@@ -260,10 +257,7 @@ component Footer {
|
||||
<div
|
||||
class="wrn-footer__post"
|
||||
data-wrn-owner="Footer" data-wrn-part="post" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
<slot
|
||||
name="post-footer"
|
||||
>
|
||||
</slot>
|
||||
<slot name="post-footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -273,10 +267,7 @@ component Footer {
|
||||
<div
|
||||
class="wrn-footer__copyright-left"
|
||||
data-wrn-owner="Footer" data-wrn-part="copyrightLeft" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
<slot
|
||||
name="copyright-left"
|
||||
>
|
||||
</slot>
|
||||
<slot name="copyright-left"></slot>
|
||||
</div>
|
||||
|
||||
{#if copyright}
|
||||
@@ -290,10 +281,7 @@ component Footer {
|
||||
<div
|
||||
class="wrn-footer__copyright-right"
|
||||
data-wrn-owner="Footer" data-wrn-part="copyrightRight" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
<slot
|
||||
name="copyright-right"
|
||||
>
|
||||
</slot>
|
||||
<slot name="copyright-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -366,9 +354,11 @@ component Footer {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wrn-footer { container: wrn-footer / inline-size; }
|
||||
|
||||
.wrn-footer__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(13rem, 1.2fr) minmax(0, 3fr) minmax(13rem, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: min(100%, 90rem);
|
||||
margin-inline: auto;
|
||||
padding: clamp(2rem, 5vw, 4rem) clamp(1rem, 4vw, 3rem);
|
||||
@@ -380,6 +370,8 @@ component Footer {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.wrn-footer__pre:has(> slot:empty),
|
||||
.wrn-footer__post:has(> slot:empty),
|
||||
.wrn-footer__pre:empty,
|
||||
.wrn-footer__post:empty,
|
||||
.wrn-footer__pre:not(:has(*)),
|
||||
@@ -408,6 +400,7 @@ component Footer {
|
||||
.wrn-footer__links {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--wrn-footer-columns, 3), minmax(0, 1fr));
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
@@ -435,6 +428,8 @@ component Footer {
|
||||
.wrn-footer__link {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -469,7 +464,7 @@ component Footer {
|
||||
}
|
||||
|
||||
.wrn-footer__bottom--slots:not(
|
||||
:has(.wrn-footer__copyright-left > *, .wrn-footer__copyright-right > *)
|
||||
:has(.wrn-footer__copyright-left > slot:not(:empty), .wrn-footer__copyright-right > slot:not(:empty))
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
@@ -513,6 +508,8 @@ component Footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wrn-footer__copyright-left:has(> slot:empty),
|
||||
.wrn-footer__copyright-right:has(> slot:empty),
|
||||
.wrn-footer__copyright-left:empty,
|
||||
.wrn-footer__copyright-right:empty,
|
||||
.wrn-footer__copyright-left:not(:has(*)),
|
||||
@@ -525,7 +522,7 @@ component Footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.wrn-footer__links {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
}
|
||||
.wrn-footer__bottom--slots {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
@@ -566,5 +563,13 @@ component Footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@container wrn-footer (max-width: 700px) {
|
||||
.wrn-footer__links { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.wrn-footer__bottom { grid-template-columns: 1fr; gap: 1rem; }
|
||||
.wrn-footer__copyright { grid-column: 1; white-space: normal; }
|
||||
}
|
||||
@container wrn-footer (max-width: 400px) {
|
||||
.wrn-footer__links { grid-template-columns: 1fr; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,9 @@ component List {
|
||||
}
|
||||
.wrn-list__description { margin-top: 0.25rem; color: var(--wrn-color-text-muted); font-size: 0.875rem; line-height: 1.5rem; }
|
||||
.wrn-list__items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wrn-list__items:is([data-variant="default"], [data-variant="divided"]) > .wrn-list__item + .wrn-list__item {
|
||||
|
||||
@@ -225,7 +225,7 @@ size: string = "default"
|
||||
<span data-t="{translationKey(item, 'label')}">{item.label}</span>
|
||||
<span class="wrn-navbar__chevron" aria-hidden="true" data-wrn-owner="Navbar" data-wrn-part="chevron" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}"></span>
|
||||
</button>
|
||||
<div class="wrn-navbar__panel wrn-navbar__panel--columns-{item.columns || 1}" data-wrn-owner="Navbar" data-wrn-part="panel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
<div data-wrn-anchored="true" class="wrn-navbar__panel wrn-navbar__panel--columns-{item.columns || 1}" data-wrn-owner="Navbar" data-wrn-part="panel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
{#if item.description}<p class="wrn-navbar__panel-intro" data-t="{translationKey(item, 'description')}" data-wrn-owner="Navbar" data-wrn-part="panelIntro" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">{item.description}</p>{/if}
|
||||
{#each dropdownItems(item) as child}
|
||||
<div class="wrn-navbar__group" data-wrn-owner="Navbar" data-wrn-part="group" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}">
|
||||
|
||||
@@ -312,9 +312,10 @@ component PortalDashboard {
|
||||
}
|
||||
|
||||
.wrn-portal-dashboard__list a {
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0;
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
|
||||
.wrn-portal-dashboard__status {
|
||||
@@ -326,6 +327,8 @@ component PortalDashboard {
|
||||
}
|
||||
|
||||
.wrn-portal-dashboard__list em {
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.7rem;
|
||||
font-style: normal;
|
||||
|
||||
@@ -50,7 +50,7 @@ size: string = "default"
|
||||
<span class="{themeIcon}" aria-hidden="true"></span>
|
||||
{#if !iconOnly && !compact}<span>{themeLabel}</span>{/if}
|
||||
</button>
|
||||
<div class="wrn-preferences__panel" data-wrn-owner="PreferenceSwitcher" data-wrn-part="panel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
<div data-wrn-anchored="true" class="wrn-preferences__panel" data-wrn-owner="PreferenceSwitcher" data-wrn-part="panel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
<strong>{themeLabel}</strong>
|
||||
<div class="wrn-preferences__options" data-wrn-owner="PreferenceSwitcher" data-wrn-part="options" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
<button type="button" data-wrn-theme-set="light" @click="choose('theme', 'light')" data-wrn-owner="PreferenceSwitcher" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}"><span class="icon-[lucide--sun]" aria-hidden="true"></span>Light</button>
|
||||
@@ -64,7 +64,7 @@ size: string = "default"
|
||||
<span class="{colorIcon}" aria-hidden="true"></span>
|
||||
{#if !iconOnly && !compact}<span>{colorLabel}</span>{/if}
|
||||
</button>
|
||||
<div class="wrn-preferences__panel wrn-preferences__panel--colors" data-wrn-owner="PreferenceSwitcher" data-wrn-part="panel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
<div data-wrn-anchored="true" class="wrn-preferences__panel wrn-preferences__panel--colors" data-wrn-owner="PreferenceSwitcher" data-wrn-part="panel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
<strong>{colorLabel}</strong>
|
||||
<div class="wrn-preferences__swatches" data-wrn-owner="PreferenceSwitcher" data-wrn-part="swatches" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
{#each colors as item}
|
||||
@@ -80,7 +80,7 @@ size: string = "default"
|
||||
{#if showCurrentLanguage}<span class="wrn-preferences__current-language" aria-hidden="true" data-wrn-owner="PreferenceSwitcher" data-wrn-part="currentLanguage" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">EN</span>{/if}
|
||||
{#if !iconOnly && !compact}<span>{languageLabel}</span>{/if}
|
||||
</button>
|
||||
<div class="wrn-preferences__panel wrn-preferences__panel--language" data-wrn-owner="PreferenceSwitcher" data-wrn-part="panel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
<div data-wrn-anchored="true" class="wrn-preferences__panel wrn-preferences__panel--language" data-wrn-owner="PreferenceSwitcher" data-wrn-part="panel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
<strong>{languageLabel}</strong>
|
||||
{#each languages as item}
|
||||
<button type="button" data-wrn-lang-set="{item.value}" lang="{item.value}" @click="choose('language', item.value)" data-wrn-owner="PreferenceSwitcher" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-compact="{compact}">
|
||||
@@ -117,6 +117,8 @@ size: string = "default"
|
||||
color: var(--wrn-color-muted);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
background: var(--wrn-color-surface-2);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.wrn-preferences__menu > [data-wrn-menu-toggle]:hover,
|
||||
@@ -185,21 +187,31 @@ size: string = "default"
|
||||
padding: 0.5rem 0.65rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--wrn-radius-sm, 0.55rem);
|
||||
font-family: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface-2);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wrn-preferences__panel button:focus-visible {
|
||||
outline: 2px solid var(--wrn-color-focus, var(--wrn-color-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-preferences__panel button:hover {
|
||||
outline: 2px solid color-mix(in srgb, var(--wrn-color-primary) 40%, transparent);
|
||||
border-color: var(--wrn-color-border);
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
|
||||
.wrn-preferences__panel button[aria-pressed="true"] {
|
||||
color: var(--wrn-color-primary);
|
||||
background: color-mix(in srgb, var(--wrn-color-primary) 12%, var(--wrn-color-surface));
|
||||
outline: 1px solid color-mix(in srgb, var(--wrn-color-primary) 55%, transparent);
|
||||
border-color: color-mix(in srgb, var(--wrn-color-primary) 55%, transparent);
|
||||
}
|
||||
|
||||
.wrn-preferences__swatches {
|
||||
|
||||
@@ -58,7 +58,7 @@ size: string = "default"
|
||||
<input id="{id || name}" class="wrn-next__sr-only wrn-next__time-value" type="text" name="{name}" value="{currentValue}" required="{required}" readonly aria-hidden="true" tabindex="-1" @invalid="handleInvalid(event)" data-wrn-owner="TimePicker" data-wrn-part="srOnly" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"/>
|
||||
<button type="button" class="wrn-next__time-trigger" disabled="{disabled || readonly}" aria-haspopup="dialog" aria-expanded="{expanded}" @click="openPicker(event)" @focus="handleFocus(event)" @blur="handleBlur(event)" data-wrn-owner="TimePicker" data-wrn-part="timeTrigger" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><span>{currentValue || placeholder || "Select time"}</span><i class="{icon}" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<button type="button" class="wrn-time-dismiss" aria-label="Close time picker" data-show="expanded" @click="closePicker(event)" data-wrn-owner="TimePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"></button><div data-show="expanded" class="wrn-next__time-panel" role="dialog" aria-label="{label}" data-wrn-owner="TimePicker" data-wrn-part="timePanel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><div><strong>Hour</strong><div class="wrn-next__time-options" data-wrn-owner="TimePicker" data-wrn-part="timeOptions" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each hours as hour}<button type="button" data-part="hour" data-value="{hour}" aria-pressed="{!!currentValue && String(hour) === selectedHour}" @click="selectTimePart(event)" data-wrn-owner="TimePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{hour}</button>{/each}</div></div><div><strong>Minute</strong><div class="wrn-next__time-options" data-wrn-owner="TimePicker" data-wrn-part="timeOptions" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each minutes as minute}<button type="button" data-part="minute" data-value="{minute}" aria-pressed="{!!currentValue && String(minute) === selectedMinute}" @click="selectTimePart(event)" data-wrn-owner="TimePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{minute}</button>{/each}</div></div><button type="button" class="wrn-next__time-done" @click="closePicker(event)" data-wrn-owner="TimePicker" data-wrn-part="timeDone" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">Done</button></div>
|
||||
<button type="button" class="wrn-time-dismiss" aria-label="Close time picker" data-show="expanded" @click="closePicker(event)" data-wrn-owner="TimePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"></button><div data-show="expanded" data-wrn-anchored="true" class="wrn-next__time-panel" role="dialog" aria-label="{label}" data-wrn-owner="TimePicker" data-wrn-part="timePanel" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}"><div><strong>Hour</strong><div class="wrn-next__time-options" data-wrn-owner="TimePicker" data-wrn-part="timeOptions" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each hours as hour}<button type="button" data-part="hour" data-value="{hour}" aria-pressed="{!!currentValue && String(hour) === selectedHour}" @click="selectTimePart(event)" data-wrn-owner="TimePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{hour}</button>{/each}</div></div><div><strong>Minute</strong><div class="wrn-next__time-options" data-wrn-owner="TimePicker" data-wrn-part="timeOptions" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{#each minutes as minute}<button type="button" data-part="minute" data-value="{minute}" aria-pressed="{!!currentValue && String(minute) === selectedMinute}" @click="selectTimePart(event)" data-wrn-owner="TimePicker" data-wrn-part="button" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{minute}</button>{/each}</div></div><button type="button" class="wrn-next__time-done" @click="closePicker(event)" data-wrn-owner="TimePicker" data-wrn-part="timeDone" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">Done</button></div>
|
||||
{#if helperText}<small class="wrn-next__field-help" data-wrn-owner="TimePicker" data-wrn-part="help" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{helperText}</small>{/if}
|
||||
<small class="wrn-next__field-error" data-error="{name}" data-wrn-owner="TimePicker" data-wrn-part="error" data-wrn-axis-size="{size}" data-wrn-axis-color="{color}" data-wrn-axis-variant="{variant}">{error}</small>
|
||||
</div>
|
||||
|
||||
@@ -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.122",
|
||||
"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];
|
||||
|
||||
@@ -246,3 +246,16 @@ describe("account shell UI regressions", () => {
|
||||
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-right\s*\{\s*grid-column: 3;/);
|
||||
});
|
||||
});
|
||||
|
||||
test("floating selection and preference panels opt into shared boundary handling", async () => {
|
||||
for (const name of [
|
||||
"PreferenceSwitcher",
|
||||
"ComboBox",
|
||||
"AdvancedSelect",
|
||||
"TimePicker",
|
||||
"DataTable",
|
||||
"Navbar",
|
||||
]) {
|
||||
expect(readFileSync(uiComponentPath(name), "utf8")).toContain('data-wrn-anchored="true"');
|
||||
}
|
||||
});
|
||||
@@ -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