Compare commits

..
13 Commits
Author SHA1 Message Date
Clintchiz d5244eab15 feat(studio): choose and load Google Fonts in manual design
Quality / quality (ubuntu-latest) (push) Failing after 10m7s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 22:06:46 +05:30
Clintchiz 7ef475119a feat(studio): expand manual typography controls and theme recipes
Quality / quality (ubuntu-latest) (push) Failing after 6m43s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 21:17:50 +05:30
Clintchiz e0298ac471 test(studio): type the manual editor DOM fixture
Quality / quality (ubuntu-latest) (push) Failing after 1s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 21:01:33 +05:30
Clintchiz ae35ac6714 feat(studio): add offline manual theme designer
Quality / quality (ubuntu-latest) (push) Failing after 6m40s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 20:58:12 +05:30
Clintchiz 5259668006 chore(studio): sync async generation package lock
Quality / quality (ubuntu-latest) (push) Failing after 2s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 20:08:34 +05:30
Clintchiz 3539dff45c fix(studio): poll background generation and handle gateway failures
Quality / quality (ubuntu-latest) (push) Failing after 10m12s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 20:08:06 +05:30
Clintchiz bb40dffee8 fix(studio): refine preset surfaces and repair gallery interactions
Quality / quality (ubuntu-latest) (push) Failing after 11m2s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 19:10:44 +05:30
Clintchiz f642a84c58 style(test): format popup boundary regressions
Quality / quality (ubuntu-latest) (push) Failing after 6m33s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 17:53:33 +05:30
Clintchiz 7c1fcd19d4 fix(ui): keep floating menus inside viewport and clipping containers
Quality / quality (ubuntu-latest) (push) Failing after 4s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 17:51:15 +05:30
Clintchiz 9520651330 fix(studio): render complete interactive gallery previews
Quality / quality (ubuntu-latest) (push) Failing after 10m12s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 17:40:50 +05:30
Clintchiz c765d76a06 feat(studio): add thirteen presets and preview reviewed proposals
Quality / quality (ubuntu-latest) (push) Failing after 10m52s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 14:51:03 +05:30
Clintchiz ba752e556d fix(studio): display configured generation budgets
Quality / quality (ubuntu-latest) (push) Failing after 10m4s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 14:27:16 +05:30
Clintchiz 65d9c844d6 docs: record verified Studio Linux preview patch publication
Quality / quality (ubuntu-latest) (push) Failing after 2s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-10-19 14:08:24 +05:30
65 changed files with 7793 additions and 151 deletions

No files matched your search

+27 -7
View File
@@ -354,7 +354,7 @@
},
"packages/compiler": {
"name": "@wrnexus/compiler",
"version": "0.8.44",
"version": "0.8.45",
"dependencies": {
"@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*",
@@ -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.
+13 -13
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.8.44",
"version": "0.8.45",
"type": "module",
"main": "src/index.ts",
"exports": {
+18 -8
View File
@@ -520,20 +520,30 @@ export function generateBrowserModule(ast: PageAst): string {
[...sharedState, ...sharedProps].filter((name) => identifierReferenced(source, name)),
);
}
const sharedCommit = `const values = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const pending = {};
for (const name of Object.keys(values)) {
if (!Object.is(values[name], committed[name])) { pending[name] = values[name]; committed[name] = values[name]; }
const usedCommitNames = new Set([...sharedState, ...sharedProps, ...functionNames]);
const commitName = (base: string) => {
let name = base;
while (usedCommitNames.has(name)) name += "_";
usedCommitNames.add(name);
return name;
};
const commitValues = commitName("__v"),
commitPending = commitName("__p"),
commitSnapshot = commitName("__c");
const sharedCommit = `const ${commitValues} = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const ${commitPending} = {};
for (const name of Object.keys(${commitValues})) {
if (!Object.is(${commitValues}[name], ${commitSnapshot}[name])) { ${commitPending}[name] = ${commitValues}[name]; ${commitSnapshot}[name] = ${commitValues}[name]; }
}
if (Object.keys(pending).length === 0) return;
const apply = () => { for (const name of Object.keys(pending)) context.state[name] = pending[name]; };
if (Object.keys(${commitPending}).length === 0) return;
const apply = () => { for (const name of Object.keys(${commitPending})) context.state[name] = ${commitPending}[name]; };
if (typeof context.batch === "function") context.batch(apply); else apply();`;
const restores = [...sharedState, ...sharedProps]
.map(
(name) =>
`${JSON.stringify(name)}:()=>{${
sharedState.includes(name)
? `${name} = context.state.${name}; committed.${name} = ${name};`
? `${name} = context.state.${name}; ${commitSnapshot}.${name} = ${name};`
: `${name} = context.props.${name};`
}}`,
)
@@ -588,7 +598,7 @@ function __wrnexusCreateClientFunctions(context) {
const props = context.props;
const refs = context.refs;
${apiBindings(ast)}
const committed = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const ${commitSnapshot} = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const __wrnexusCommit = () => { ${sharedCommit} };
const __wrnexusRestores = { ${restores} };
const __wrnexusRestoreKeys = ${JSON.stringify(restoreKeys)};
@@ -11,6 +11,42 @@ const source = `component FunctionDefaults {
}
view { <button @click="choose()">Choose</button> }
}`;
test("browser state named pending or values does not collide with commit bookkeeping", () => {
const source = `component CommitNames {
state pending = "ready"
state values = 1
state committed = false
state __v = 3
state __p = false
state __c = "safe"
functions {
shared function status() { return pending + values + __c + __v }
client function advance() { pending = "done"; values += 1; committed = true }
}
view { <button @click="advance()">Advance</button> }
}`;
const module = generateBrowserModule(parse(source));
const functions = new Function(
module.replace(/\bexport\s+(?=const|function)/g, "") + ";return __wrnexusClientFunctions;",
)();
const context = {
state: { pending: "ready", values: 1, committed: false, __v: 3, __p: false, __c: "safe" },
props: {},
batch: (apply: () => void) => apply(),
};
expect(functions.status(context)).toBe("ready1safe3");
functions.advance(context);
expect(context.state).toEqual({
pending: "done",
values: 2,
committed: true,
__v: 3,
__p: false,
__c: "safe",
});
expect(functions.status(context)).toBe("done2safe3");
});
test("browser functions retain defaults, prior-argument references and prop references", () => {
const module = generateBrowserModule(parse(source));
const functions = new Function(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.74",
"version": "0.8.75",
"type": "module",
"main": "src/index.ts",
"exports": {
+58 -9
View File
@@ -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();
});
+17
View File
@@ -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.
+3 -2
View File
@@ -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:*"
+74 -15
View File
@@ -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
+196
View File
@@ -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(
() => {},
);
}
}
}
+1 -1
View File
@@ -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();
+17 -10
View File
@@ -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();
+268
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 &amp; 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 };
}
+370
View File
@@ -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;
}
+183
View File
@@ -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) =>
({ "&quot;": '"', "&#39;": "'", "&lt;": "<", "&gt;": ">", "&amp;": "&" })[entity]!,
);
const visit = async (markup: string, depth = 0): Promise<string> => {
if (depth > 30) throw Error("Preview component composition exceeded its depth limit.");
for (const match of markup.matchAll(/data-wrn-hydration="([^:"]+):/g)) names.add(match[1]!);
for (const match of markup.matchAll(/data-wrn-(?:if|each)="(@[^"]+)"/g)) {
if (prepared.has(match[1]!)) continue;
prepared.add(match[1]!);
const definition = resolveControlTemplate(match[1]!);
for (const entry of Array.isArray(definition) ? definition : [definition])
if (entry && typeof entry === "object")
for (const key of ["body", "empty"])
if (typeof entry[key] === "string") entry[key] = await visit(entry[key], depth + 1);
}
const mounts = /<div\b([^>]*\bdata-component="([\w-]+)"[^>]*)>/g;
let output = "",
cursor = 0;
for (let match = mounts.exec(markup); match; match = mounts.exec(markup)) {
output += markup.slice(cursor, match.index);
const tags = /<\/?div\b[^>]*>/g;
tags.lastIndex = mounts.lastIndex;
let nesting = 1,
end = tags.lastIndex,
close = end;
for (let tag = tags.exec(markup); tag; tag = tags.exec(markup)) {
nesting += tag[0].startsWith("</") ? -1 : 1;
if (!nesting) {
close = tag.index;
end = tags.lastIndex;
break;
}
}
if (nesting) throw Error("Unbalanced component preview mount.");
const name = [...Object.keys(COMPONENT_CONTRACTS), "SelectStyles"].find(
(n) =>
n.toLowerCase().replace(/[-_]/g, "") === match[2]!.toLowerCase().replace(/[-_]/g, ""),
);
if (!name) throw Error(`Unknown nested preview component: ${match[2]}`);
const props: Record<string, string> = {};
for (const attr of match[1]!.matchAll(/([\w-]+)="([^"]*)"/g)) {
if (attr[1] === "data-component" || attr[1]!.startsWith("data-wrn-")) continue;
const value = decode(attr[2]!);
if (/^\{[\s\S]*\}$/.test(value)) {
try {
JSON.parse(value);
} catch {
continue;
}
}
props[attr[1]!] = value;
}
const inner = markup.slice(mounts.lastIndex, close);
const key = JSON.stringify([name, props, inner]);
if (!prototypes.has(key))
prototypes.set(key, previewSampleMarkup(await render(name, props), inner));
const bindings = [
...match[1]!.matchAll(/\s(data-wrn-(?:prop-bind-[\w-]+|loop-locals)="[^"]*")/g),
]
.map((m) => " " + m[1])
.join("");
const rendered = prototypes
.get(key)!
.replace(/(<[^>]*\bdata-scope="[^"]*")/, "$1" + bindings);
output += await visit(rendered, depth + 1);
cursor = end;
mounts.lastIndex = end;
}
return output + markup.slice(cursor);
};
return { body: await visit(body), names };
}
+80 -11
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!,
);
async function component(name: string, props: Record<string, unknown> = {}) {
if (!Object.hasOwn(COMPONENT_CONTRACTS, name)) throw Error("Unknown component.");
if (!Object.hasOwn(COMPONENT_CONTRACTS, name) && name !== "SelectStyles")
throw Error("Unknown component.");
if (!modules.has(name)) {
const sourcePath = uiComponentPath(name);
const sourcePath =
name === "SelectStyles"
? join(dirname(uiComponentPath("AdvancedSelect")), "../styles/SelectStyles.wrn")
: uiComponentPath(name);
const source = readFileSync(sourcePath, "utf8");
const ast = parse(source);
iconSources.set(ast.name, source);
const browser = generateBrowserModule(ast);
if (/^import\s/m.test(browser))
throw Error(`Preview controller has an unresolved import: ${name}`);
controllers.set(
ast.name,
`(()=>{${browser.replace(/\bexport\s+(?=(?:const|function)\b)/g, "")}\nreturn {bindClientScope,__wrnexusClientFunctions,__wrnexusImportedBindings};})()`,
);
const directory = join(tmpdir(), "wrnexus-design-studio");
mkdirSync(directory, { recursive: true });
const target = join(
@@ -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,
});
});
+1 -1
View File
@@ -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> => {
+21 -6
View File
@@ -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 = {
+201
View File
@@ -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);
}
}
});
+3 -2
View File
@@ -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 -1
View File
@@ -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",
+19 -2
View File
@@ -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;
}
+13 -2
View File
@@ -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
+18 -18
View File
@@ -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 {
+2
View File
@@ -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 (
[
+3
View File
@@ -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,
+3
View File
@@ -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;
+17
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.83",
"version": "0.8.84",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -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}">
+3
View File
@@ -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
+1 -1
View File
@@ -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}">
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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>
}
+1 -1
View File
@@ -73,7 +73,7 @@ component DeviceFrame {
border: 0.65rem solid var(--wrn-color-text);
border-radius: 2rem;
background: var(--wrn-color-background);
transition: width var(--wrn-motion-normal) var(--wrn-ease-standard);
transition: width var(--wrn-motion-base) var(--wrn-ease-standard);
}
.wrn-next--device-frame[data-device="tablet"] .wrn-next__device-shell {
+24 -19
View File
@@ -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; }
}
}
}
+3
View File
@@ -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 {
+1 -1
View File
@@ -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}">
+6 -3
View File
@@ -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;
+18 -6
View File
@@ -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 {
+1 -1
View File
@@ -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>
+2 -1
View File
@@ -52,7 +52,8 @@ component Switch {
height: 1.375rem;
margin: 0;
border: 1px solid color-mix(in srgb, var(--wrn-color-border-strong, var(--wrn-color-border)) 70%, transparent);
border-radius: 999px;
/* A switch track keeps its semantic pill geometry, including older themes. */
border-radius: 999px !important;
background: var(--wrn-color-surface-muted, var(--wrn-color-surface-2));
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
cursor: pointer;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.8.118",
"version": "0.8.122",
"private": true,
"type": "module",
"main": "src/index.ts",
+17
View File
@@ -1,5 +1,22 @@
import type { ChartConfiguration, ChartData, ChartType } from "chart.js";
/** Shorten only the painted tick. Dataset labels remain intact for tooltips and tables. */
export function chartTickLabel(label: unknown, limit: number): string {
const text = String(label ?? "");
if (text.length <= limit) return text;
const start = Math.ceil((limit - 1) / 2);
return text.slice(0, start) + "…" + text.slice(-(limit - 1 - start));
}
export function chartTooltipLines(label: unknown, limit: number): string[] {
const lines: string[] = [];
for (const part of String(label ?? "").split("\n")) {
if (!part.length) lines.push("");
for (let index = 0; index < part.length; index += limit)
lines.push(part.slice(index, index + limit));
}
return lines;
}
export interface ChartProps {
title?: string;
items?: unknown[];
+37 -1
View File
@@ -1,6 +1,12 @@
import Chart from "chart.js/auto";
import type { ChartConfiguration, Plugin } from "chart.js";
import { chartConfiguration, mergeOptions, type ChartProps } from "./chart-config";
import {
chartConfiguration,
mergeOptions,
chartTickLabel,
chartTooltipLines,
type ChartProps,
} from "./chart-config";
const selector = '[data-wrnexus-runtime~="chartjs"][data-chart-config]';
const mounted = new Map<
@@ -95,6 +101,36 @@ function configuration(root: HTMLElement, props: ChartProps): ChartConfiguration
},
config.options,
);
if (["bar", "line"].includes(config.type)) {
const axis = config.options!.indexAxis === "y" ? "y" : "x";
const interaction = config.options!.interaction;
if (interaction?.mode === "index" && !interaction.axis) interaction.axis = axis;
const scale = ((config.options!.scales ??= {})[axis] ??= {});
if (!scale.type || scale.type === "category") {
const ticks = (scale.ticks ??= {});
if (!ticks.callback)
ticks.callback = function (value) {
return chartTickLabel(
this.getLabelForValue(Number(value)),
this.chart.width < 480 ? 18 : 36,
);
};
}
}
const tooltip = config.options!.plugins && config.options!.plugins.tooltip;
if ((tooltip as unknown) !== false && (config.options!.plugins as unknown) !== false) {
const plugin = (config.options!.plugins ??= {});
const options = (plugin.tooltip ??= {});
const callbacks = (options.callbacks ??= {});
if (!callbacks.title)
callbacks.title = (items) =>
items.length
? chartTooltipLines(
items[0].label,
Math.max(12, Math.min(80, Math.floor((items[0].chart.width - 32) / 8))),
)
: [];
}
config.options!.onClick = (event, elements, chart) => {
if (!elements.length) return;
const { index, datasetIndex } = elements[0];
@@ -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"');
}
});
+15 -1
View File
@@ -1,11 +1,25 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { chartConfiguration, chartTypes } from "../src/chart-config";
import {
chartConfiguration,
chartTypes,
chartTickLabel,
chartTooltipLines,
} from "../src/chart-config";
import { renderComponent, mountHtml } from "../../test/src/index";
import { discoverPlugins, createPluginRunner } from "../../plugin/src/index";
import { resolve } from "node:path";
const source = readFileSync(new URL("../components/Chart.wrn", import.meta.url), "utf8");
test("compact category ticks preserve the complete tooltip text", () => {
const label = "2026-10-17T10:03:00 · completed · audit-four";
expect(chartTickLabel(label, 18)).toHaveLength(18);
expect(chartTickLabel(label, 18)).toContain("…");
expect(chartTickLabel("Short", 18)).toBe("Short");
const lines = chartTooltipLines(label, 18);
expect(lines.every((line) => line.length <= 18)).toBe(true);
expect(lines.join("")).toBe(label);
});
for (const type of chartTypes) {
test(`Chart.js ${type} preserves native datasets`, () => {
const data = {