feat(storybook): generate Radix UI components for React stories (#971)

* refactor(dom-css): share the generated component's markup and bindings

The component model now holds the element tree every framework writes: each layer's behaviour part, design tag, state class, and attributes, and the root's typed bindings (the two-way value, disabled, and each variant prop as a data attribute). The Vue generator only maps parts to Reka components and writes each binding its way, so another framework is a mapping table and a printer.

* feat(emit): print TSX modules with namespaced tags and spreads

jsx.printModule prints an es template filled with JSX as one TSX module, quoting every string the same way. Dotted tags such as Switch.Root print as JSX member expressions, and jsx.spread passes an object on as attributes. fill now fills holes inside the annotation it keeps on a filled identifier, which left $Props unfilled in a parameter's type.

* feat(storybook): generate Radix UI components for React stories

With --framework react, a component set whose behaviour is a Button, Switch, Checkbox, Toggle, or Collapsible exports as a component on the radix-ui package with its state styles in a CSS module. Its props extend the Radix root's, so checked, defaultChecked, onCheckedChange, and disabled come from Radix, and each other variant property is a prop set as a data attribute. Its stories use Radix's uncontrolled value so a play function can operate it. The browser spec now runs the same Switch and Collapsible checks for Vue and React.

* refactor(storybook): let generators say how stories import their component

The stories printer named each framework to know its Storybook package, how to import the component, and which prop sets its value. A generator now returns its files with the import path, whether the export is named, and the value's arg, and the Storybook packages come from the one table static stories use, so the printer is framework-neutral and exportStorybook looks generators up instead of branching.

* fix(storybook): keep a caller's class on React roots and run stories' play functions

The generated class came after the spread props, so a className passed to a generated React component was dropped. The root now joins its classes with the caller's. The browser spec mounts each framework's Default story and runs its exported play function with Storybook's own within and userEvent, checking the switch ends up on, and checks a caller's class stays on the root. emit shares one isNode guard.
This commit is contained in:
Danila Poyarkov 2026-10-08 10:13:52 +00:00 committed by GitHub
parent 7e9e293305
commit e17f74991b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1025 additions and 293 deletions

View file

@ -69,7 +69,7 @@
- Swap the component behind an instance with `instance.swapComponent(component)` in the plugin API, as in Figma.
- Detach an instance from its component with `detachInstance()` in the plugin API, as in Figma, from scripts run through `eval`.
- Run scripts written for Figma's dynamic-page mode that call `figma.getNodeByIdAsync()` or `getMainComponentAsync()`; both resolve to the same nodes as their synchronous forms.
- Export components to Storybook with `openpencil export -f storybook`: one CSF3 story file per component set or component for React, Vue, or HTML, with a story and `select` controls per variant (a component with a behaviour gets boolean `checked`, `pressed`, `open`, and `disabled` controls instead, and each interaction state stays a story; with `--framework vue`, a Button, Switch, Checkbox, Toggle, or Collapsible becomes a Reka UI component whose variants are compiled into state styles, with stories whose play function operates it), a design image per variant, and an `openpencil://` link that opens the variant in OpenPencil. `--watch` re-exports on every save and removes stories of deleted components, and `--beside` writes each document's stories next to it, for many documents at once (#727).
- Export components to Storybook with `openpencil export -f storybook`: one CSF3 story file per component set or component for React, Vue, or HTML, with a story and `select` controls per variant (a component with a behaviour gets boolean `checked`, `pressed`, `open`, and `disabled` controls instead, and each interaction state stays a story; with `--framework vue` or `--framework react`, a Button, Switch, Checkbox, Toggle, or Collapsible becomes a Reka UI or Radix UI component whose variants are compiled into state styles, with stories whose play function operates it), a design image per variant, and an `openpencil://` link that opens the variant in OpenPencil. `--watch` re-exports on every save and removes stories of deleted components, and `--beside` writes each document's stories next to it, for many documents at once (#727).
- Export HTML and Tailwind JSX from the app's export options and through the IO registry, and export Tailwind JSX from the CLI with `-f tailwind-jsx` (`-f jsx --style tailwind` still works). HTML export of a single layer now includes the layer itself, as other formats do.
- Choose PPTX in the Export panel's format list, alongside PNG, JPG, WEBP, SVG, and PDF.
- Let AI and MCP agents verify and replay their edits with diff tools: `diff_visual` returns a pixel diff of two rendered nodes with the changed region, and `diff_apply` applies a `diff_create` or `diff_show` patch, including moved, added, and removed children, only when every node still matches it. The built-in AI chat enables `diff_create`, `diff_jsx`, and `diff_visual` by default.

165
bun.lock
View file

@ -123,6 +123,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/utils": "^8.71.0",
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
"@vitejs/plugin-react": "^6.1.2",
"@vitejs/plugin-vue": "^6.0.9",
"@vue/compiler-core": "^3.5.43",
"@wdio/cli": "9.30.1",
@ -149,6 +150,9 @@
"oxlint-tsgolint": "^0.16.0",
"portless": "0.15.5",
"publint": "0.3.20",
"radix-ui": "^1.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"sherif": "1.12.0",
"steiger": "^0.5.13",
"storybook": "^10.6.1",
@ -304,6 +308,11 @@
"@types/bun": "^1.3.14",
"@types/css-tree": "^3.2.0",
"@types/cssesc": "^3.0.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.2.0",
"radix-ui": "^1.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"reka-ui": "^2.10.5",
"storybook": "^10.6.1",
"tsdown": "^0.22.14",
@ -1204,6 +1213,8 @@
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@floating-ui/vue": ["@floating-ui/vue@1.1.11", "", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@floating-ui/utils": "^0.2.11", "vue-demi": ">=0.13.0" } }, "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw=="],
@ -1778,6 +1789,126 @@
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.16", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I+T/Hc6SY3WbFVZ9Ne1o0DJbpjQpTpLIBZcVzYiHCQ9z1yGUd/G88u5SKyM6eJ223txAOS7bp7bb9k0Ocfkjwg=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collapsible": "1.1.21", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2fmc6COuKLq2k3P7JZrOt9Zf8HT9nTQhHN5V+A3ih7ftqoyDcoEUrPoLq8ngr7QwTd7hpQ2xoUKWtvy1Xw9RAQ=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dialog": "1.2.0", "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dRvB59HhcjIWcCPKzK5Px1yQ8pIULQQrxqgyepCqin7tNXi/xvmC86WkyMOKhuCbjz3+v9Q6oE7/tUN5NQ9dCg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nXPI1tzJsnQw8oXvB2r49rO9TIbbJ8VFeaWDiBsqY8+iwO5xb7WjUsrXDagjAgPKTdhW8WODQnAltUhBx8I+sg=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w2u6oiTzb7WkWwV3ZClX/llYYlTbilUOkHIKkwb+PWWytQ/IdEVRNLIChjbcXy/mkqcKKM3wEhrOilJW+uPHDg=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6AizCpbU3Zngojp9vQak8Lt4AXwdKJ2lkRYAfpoETraT+qKVNeRqytiADzneHetFEB0Wu4+qeSfmZEq0PiVDmQ=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gNOS2lLrpQxNLWHhCX0sayfVRiyB5gQp7nQIzpl+PM1Hkab52utsKtD27alAr78uqhpvX51kwqDp71hM5hNSwg=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IzkusZHCZBnED5skW+b+syl8d+e3+zelvxZ2Ed9lfawqEBfa5osPj53sTJ+q/uNmGAj9e7wMYgyzwPu/luWJ4Q=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-menu": "2.1.25", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-s8ABBkEuAOyhDub0Y99f8lVRlVsd28mUnOHtuqGg8LuHnefkmPN5rYzA2g8MAPLjAZMisnDHhN4dBHUM7ZF7Rg=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.2.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.25", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.25", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw=="],
"@radix-ui/react-form": ["@radix-ui/react-form@0.2.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-label": "2.1.16", "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ELeC+bQUHXEjU51svw11DynjGFlaUjo5QOUCr5hptvLLtBiqWtva2O3IWdjrFWQwitw6k6KpHHEyI61/kCVeg=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0w/tZHRo1fv4WsfcyaOdi5eDftFA8wQkr8WCOOuxw7fx9S2ERfshQioM269aFZsmyAHd0X1LWoZ2BQTlA/JYUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2vTQj72YFmeoTxWKJEMbO/9uQt6zzLgbzi0+nWBffq49oo6KhIDP/D+fA2im0wsxBCAX7QfaF3xgsYBVdDbNFQ=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.25", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.25", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.25", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JIQiKcXsgl6BtwcMe7BMLvCGH2p9Czgcmxl6Ie9Zoi+MW1mmitkcm5We01m/ndLYVI8PpRJnM/RcLV0eOCxaJQ=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.3.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hZqTqDwc8Z5aI4rGIyPqCSJ2JoSNorqX/F1/gqNHkZzWJO6JuYzL7hn5sWOZJjpkfT+cbxWWRbKVU/IeiOM9ag=="],
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.17", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+6jmOqjSNBr7F1uSc7HSDqD3XL54A2qA99I9rIH4JoOD/jDScw8tUmDz6HCAkjytbrE/cdL66i+QkeDyZ0WNyw=="],
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-is-hydrated": "0.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+SWSgf3HRpE4zUira+p6t5KUR473PSqewtQgj9qzl3znZPRWPCGycwXNTfHwkiRTD3tEXwNIXJg0i1NTteiULw=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.2.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.5", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-y92/9iIdG1FyNvow0Y0qP5WJZ7oRTAKDVKCy9kJnhZfItwIKYtt0KKQeOhBKfXz0ETizduKwDRbrQHrTQScs5A=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.18", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.11", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.11", "", { "dependencies": { "@radix-ui/react-slot": "1.4.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.17", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-A+RfMARd9u+UaVbLQDmysGlAGuaFQrF+uxF3v+7+xU8tkKoYxtCSs+6HHnIKJVgam2sGZ7c0hLRPWSnmnFyGJQ=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MaOOZ1TlaYoOeSDnHeVIONss0rp5ngOTY5HJY+Xkq16PAopnj21iU9r9m0O/77cBYu8Ksaq3BcjlaOdpbfbzzg=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.3.0", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1WSuAXaahfeLmFX/dfYo/ftg1filMfaWjVye2d40VlgtWMMq/NOVocfukZEXkG+dwrAoNI+qpo9bjhNSJ/XC8g=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.8", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.12", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Tz9xOpV9Rbca5WTk7pzmfAECV3zb++uScT9mP3Px7ziAhH7SlK53b0uwMw8VubPhRK4x7uDKCy5fwevT8I0fAg=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vugPjGQBXbJVVkftKqv/BVeZ108SyRGT3FR1oa14az6qyLgX8jPhnZYHgdSB7Gw8UJusqwb9eaK6mluOnsn/Vg=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.5.0", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZOBLK7KZvGL02V+4VwVbj8NYM4vPrAtyZdrgHSv+AMS9VL+5yAdnQF3DZT4Y8cbCp1Yn4xdp92orvez8fJa77w=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.4.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rr+zuoAeHWK3m6H4HgjOUiu3VsrQ/9JkjwY/uMnTgHJGPe9WNzDP/MOuIV7S+65p42DIxjGL0hAHktqBq82+2A=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GmVH+P3wx1ZPMuH7WajQzygZIbqgnsb5h8z4Ow3sQMuDqgcqISSBNom6MLVNJvfrkuJTtqfXc3ZolHjX+w0CHg=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XR9pDEz9CH0ez98zPXQZOpF46anvbarkoOpjXpPSX7gm21vEl5Tzoe1PSQd3EY1OTOHac4lNq+TPx/2Z7jObdQ=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l7FQHtuZjUJ0vKLdhKu5LzZ8Jw6K60QEC1tC22QNLuXN2PkydSoqEhKTCqwxBFZJ52NTCjzudDvl9sUsZeUbPw=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-toggle": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O+Sjiv64DfnByHYtnKEXmn63uUskU/X61vjf69X1v8JFn/ZvuNNLdJTBfYurVTSDv4/RJnJGtGgb/uvuS/rRJw=="],
"@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-separator": "1.1.16", "@radix-ui/react-toggle-group": "1.1.20" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5gyzL6B5/zjBwU3ATBWAVZ4xXQC5F8E9CB4BmYC1UvZ08pN2OJQoooeIwJtZZ/2rz4sTiQmbGfdDvmU73wRJCQ=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.3.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4HM4b1Cft1CBXCGDRDG3S/fh8GedBuVOMNh145wMIfTbVqcuv+tyj03nZqYgFevWJMYo58H0s+8P6s6NaUHWtA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.5", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA=="],
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/iPLVHRBzKWvekN+5xaDmXw80f+nleYYceomVORrGjuJe0LF2SCbSH4f/LG4hu32q92QSuPZx4hmSACYBVBfzQ=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
"@realfavicongenerator/generate-favicon": ["@realfavicongenerator/generate-favicon@0.9.0", "", { "dependencies": { "@svgdotjs/svg.js": "^3.2.0" } }, "sha512-Fez/4q+w02OWL3daQ71rDmTRymgbeT457sz/6kQL+lJ9yZf0EWziKIDbSBJ5145JaJuqSkDk7eaoz/migAw3HA=="],
"@realfavicongenerator/image-adapter-node": ["@realfavicongenerator/image-adapter-node@0.9.0", "", { "dependencies": { "@realfavicongenerator/generate-favicon": "^0.9.0", "@svgdotjs/svg.js": "^3.2.0", "sharp": "^0.34.5", "svgdom": "^0.1.19" } }, "sha512-MAemmKAPMRqf/7QnWmJV2GBjny6QejZhE8z0cQiaDVlZJy39pCocZ/fFqIqH2tDS0GMReaAIP+vSD1FA3j5ufw=="],
@ -2134,7 +2265,9 @@
"@types/rbush": ["@types/rbush@4.0.0", "", {}, "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/readable-stream": ["@types/readable-stream@4.0.24", "", { "dependencies": { "@types/node": "*" } }, "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg=="],
@ -2204,6 +2337,8 @@
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.9", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@ -2754,6 +2889,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"detective-amd": ["detective-amd@6.1.0", "", { "dependencies": { "ast-module-types": "^6.0.1", "escodegen": "^2.1.0", "get-amd-module-type": "^6.0.2", "node-source-walk": "^7.0.1" }, "bin": { "detective-amd": "bin/cli.js" } }, "sha512-fmI6LGMvotqd49QaA3ZYw+q0aGp2yXmMjzIuY6fH9j9YFIXY/73yDhMwhX9cPbhWd+AH06NH1Di/LKOuCH0Ubg=="],
"detective-cjs": ["detective-cjs@6.1.1", "", { "dependencies": { "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" } }, "sha512-pSh7mkCKEtLlmANqLu3KDFS3NV8Hx41jy/JF1/gAWOgU+Uo5QTkeI1tWNP4dWGo4L0E9j18Ez9EPsTleautKqA=="],
@ -3066,6 +3203,8 @@
"get-iterator": ["get-iterator@2.0.1", "", {}, "sha512-7HuY/hebu4gryTDT7O/XY/fvY9wRByEGdK6QOa4of8npTcv0+NS6frFKABcf6S9EBAsveTuKTsZQQBFMMNILIg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="],
"get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="],
@ -3986,6 +4125,8 @@
"race-signal": ["race-signal@1.1.3", "", {}, "sha512-Mt2NznMgepLfORijhQMncE26IhkmjEphig+/1fKC0OtaKwys/gpvpmswSjoN01SS+VO951mj0L4VIDXdXsjnfA=="],
"radix-ui": ["radix-ui@1.7.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-accessible-icon": "1.1.16", "@radix-ui/react-accordion": "1.2.21", "@radix-ui/react-alert-dialog": "1.1.24", "@radix-ui/react-arrow": "1.1.16", "@radix-ui/react-aspect-ratio": "1.1.16", "@radix-ui/react-avatar": "1.2.7", "@radix-ui/react-checkbox": "1.3.12", "@radix-ui/react-collapsible": "1.1.21", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-context-menu": "2.3.8", "@radix-ui/react-dialog": "1.2.0", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-dropdown-menu": "2.1.25", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-form": "0.2.0", "@radix-ui/react-hover-card": "1.1.24", "@radix-ui/react-label": "2.1.16", "@radix-ui/react-menu": "2.1.25", "@radix-ui/react-menubar": "1.1.25", "@radix-ui/react-navigation-menu": "1.3.0", "@radix-ui/react-one-time-password-field": "0.1.17", "@radix-ui/react-password-toggle-field": "0.1.12", "@radix-ui/react-popover": "1.2.0", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-progress": "1.1.17", "@radix-ui/react-radio-group": "1.4.8", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-scroll-area": "1.3.0", "@radix-ui/react-select": "2.3.8", "@radix-ui/react-separator": "1.1.16", "@radix-ui/react-slider": "1.5.0", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-switch": "1.3.8", "@radix-ui/react-tabs": "1.1.22", "@radix-ui/react-toast": "1.2.24", "@radix-ui/react-toggle": "1.1.19", "@radix-ui/react-toggle-group": "1.1.20", "@radix-ui/react-toolbar": "1.1.20", "@radix-ui/react-tooltip": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-escape-keydown": "1.1.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-size": "1.1.5", "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-x+z5TV2v6u0QpAQ4dQfNMcvtIdSQ1Op2oqBEyfDXjNldRZbMTP+hcjr5Iie31bep81Axeioa+70Ww7pZDzTEQQ=="],
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
@ -4002,9 +4143,9 @@
"re2js": ["re2js@1.4.0", "", {}, "sha512-KTOIcZTSOpOxbu3i0+T6mFQ6tkxXKlTxfcMFs1trQbsMnG84qNq+DjXr8Afu+FEFjvF1NNlldpC7roPyazFI8g=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
@ -4012,6 +4153,12 @@
"react-is-19": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"read-pkg": ["read-pkg@8.1.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.1", "normalize-package-data": "^6.0.0", "parse-json": "^7.0.0", "type-fest": "^4.2.0" } }, "sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ=="],
"read-pkg-up": ["read-pkg-up@10.1.0", "", { "dependencies": { "find-up": "^6.3.0", "read-pkg": "^8.1.0", "type-fest": "^4.2.0" } }, "sha512-aNtBq4jR8NawpKJQldrQcSW9y/d+KWH4v24HWkHljOZ7H0av+YTGANBzRh9A5pw7v/bLVsLVPpOhJ7gHNVy8lA=="],
@ -4136,7 +4283,7 @@
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
@ -4534,6 +4681,10 @@
"urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"userhome": ["userhome@1.0.1", "", {}, "sha512-5cnLm4gseXjAclKowC4IjByaGsjtAoV6PrOQOljplNB54ReUYJP8HdAFq2muHinSDAh09PPX/uXDPfdxRHvuSA=="],
@ -4848,6 +4999,10 @@
"@rollup/plugin-terser/serialize-javascript": ["serialize-javascript@7.1.0", "", {}, "sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw=="],
"@storybook/addon-docs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"@storybook/addon-docs/react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"@storybook/vue3/@vue/language-core": ["@vue/language-core@3.3.12", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA=="],
"@storybook/vue3/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
@ -5408,6 +5563,8 @@
"@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],

View file

@ -220,6 +220,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/utils": "^8.71.0",
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
"@vitejs/plugin-react": "^6.1.2",
"@vitejs/plugin-vue": "^6.0.9",
"@vue/compiler-core": "^3.5.43",
"@wdio/cli": "9.30.1",
@ -246,6 +247,9 @@
"oxlint-tsgolint": "^0.16.0",
"portless": "0.15.5",
"publint": "0.3.20",
"radix-ui": "^1.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"sherif": "1.12.0",
"steiger": "^0.5.13",
"storybook": "^10.6.1",

View file

@ -61,7 +61,7 @@ Jede Variante eines Komponentensatzes wird zu einer Story, und ihre Variantenmer
Die Stories rendern die Komponente wie `-f html` als HTML mit Inline-Stilen und benötigen daher keine OpenPencil-Laufzeit; `--framework` (`react`, `vue` oder `html`) ändert nur den Wrapper und den Import von `Meta`/`StoryObj` aus `@storybook/react-vite`, `@storybook/vue3-vite` oder `@storybook/html-vite`. Text verwendet die Schriftfamilien des Dokuments, die Storybook selbst laden muss. Text-, Boolean- und Instanztausch-Eigenschaften werden noch nicht exportiert.
Mit `--framework vue` wird ein Komponentensatz, dessen Verhalten ein Button, Switch, Checkbox, Toggle oder Collapsible ist, stattdessen zu einer echten Komponente: `<Name>.vue` auf Basis von Reka UI. Seine Varianten werden zu einem Scoped-Stylesheet kompiliert, das auf die von Reka gesetzten Zustände reagiert (`data-state`, `data-disabled`, Hover und Fokus), sein Wert ist ein `v-model` wie `checked` oder `open`, und jedes weitere Variantenmerkmal ist ein Prop, das die Komponente als `data-*`-Attribut setzt. Die Storys rendern die Komponente, und eine Play-Funktion klickt das Control an und prüft seinen Zustand; das Storybook-Projekt braucht daher `reka-ui`.
Mit `--framework vue` oder `--framework react` wird ein Komponentensatz, dessen Verhalten ein Button, Switch, Checkbox, Toggle oder Collapsible ist, stattdessen zu einer echten Komponente: `<Name>.vue` auf Basis von Reka UI oder `<Name>.tsx` mit `<Name>.module.css` auf Basis des Pakets `radix-ui` von Radix UI. Seine Varianten werden zu einem Stylesheet kompiliert, das auf die von Reka und Radix gesetzten Zustände reagiert (`data-state`, `data-disabled`, Hover und Fokus), und jedes weitere Variantenmerkmal ist ein Prop, das die Komponente als `data-*`-Attribut setzt. In Vue ist sein Wert ein `v-model` wie `checked` oder `open`; in React nimmt die Komponente die Props der Radix-Wurzel an, etwa `checked`, `defaultChecked` und `onCheckedChange`. Die Storys rendern die Komponente, und eine Play-Funktion klickt das Control an und prüft seinen Zustand; das Storybook-Projekt braucht daher `reka-ui` oder `radix-ui`.
Die Stories enthalten `parameters.design`-Einträge für [`@storybook/addon-designs`](https://github.com/storybookjs/addon-designs):

View file

@ -61,7 +61,7 @@ Cada variante de un conjunto de componentes se convierte en una historia y sus p
Las historias muestran el componente como HTML con estilos en línea, igual que `-f html`, así que no necesitan el runtime de OpenPencil; `--framework` (`react`, `vue` o `html`) solo cambia el contenedor y la importación de `Meta`/`StoryObj` desde `@storybook/react-vite`, `@storybook/vue3-vite` o `@storybook/html-vite`. El texto usa las familias tipográficas del documento, que Storybook debe cargar por su cuenta. Las propiedades de texto, booleanas y de intercambio de instancia aún no se exportan.
Con `--framework vue`, un conjunto de componentes cuyo comportamiento es un Button, Switch, Checkbox, Toggle o Collapsible se convierte en un componente real: `<Name>.vue` construido sobre Reka UI. Sus variantes se compilan en una hoja de estilos con ámbito que responde a los estados que fija Reka (`data-state`, `data-disabled`, hover y foco), su valor es un `v-model` como `checked` u `open`, y cada otra propiedad de variante es una prop que el componente fija como atributo `data-*`. Sus historias renderizan el componente y una función play hace clic en el control y comprueba su estado, por lo que el proyecto de Storybook necesita `reka-ui`.
Con `--framework vue` o `--framework react`, un conjunto de componentes cuyo comportamiento es un Button, Switch, Checkbox, Toggle o Collapsible se convierte en un componente real: `<Name>.vue` construido sobre Reka UI, o `<Name>.tsx` con `<Name>.module.css` construido sobre el paquete `radix-ui` de Radix UI. Sus variantes se compilan en una hoja de estilos que responde a los estados que fijan Reka y Radix (`data-state`, `data-disabled`, hover y foco), y cada otra propiedad de variante es una prop que el componente fija como atributo `data-*`. En Vue su valor es un `v-model` como `checked` u `open`; en React el componente acepta las props de la raíz de Radix, como `checked`, `defaultChecked` y `onCheckedChange`. Sus historias renderizan el componente y una función play hace clic en el control y comprueba su estado, por lo que el proyecto de Storybook necesita `reka-ui` o `radix-ui`.
Las historias incluyen entradas `parameters.design` para [`@storybook/addon-designs`](https://github.com/storybookjs/addon-designs):

View file

@ -55,7 +55,7 @@ Ogni variante di un set di componenti diventa una storia e le sue proprietà di
Le storie renderizzano il componente come HTML con stili inline, come `-f html`, quindi non richiedono il runtime di OpenPencil; `--framework` (`react`, `vue` o `html`) cambia solo il wrapper e l’import di `Meta`/`StoryObj` da `@storybook/react-vite`, `@storybook/vue3-vite` o `@storybook/html-vite`. Il testo usa le famiglie di font del documento, che Storybook deve caricare per conto proprio. Le proprietà di testo, booleane e di sostituzione dell’istanza non vengono ancora esportate.
Con `--framework vue`, un set di componenti il cui comportamento è un Button, Switch, Checkbox, Toggle o Collapsible diventa invece un componente vero: `<Name>.vue` basato su Reka UI. Le sue varianti sono compilate in un foglio di stile con scope che segue gli stati impostati da Reka (`data-state`, `data-disabled`, hover e focus), il suo valore è un `v-model` come `checked` o `open`, e ogni altra proprietà di variante è una prop che il componente imposta come attributo `data-*`. Le sue storie renderizzano il componente e una funzione play fa clic sul controllo e ne verifica lo stato, quindi il progetto Storybook ha bisogno di `reka-ui`.
Con `--framework vue` o `--framework react`, un set di componenti il cui comportamento è un Button, Switch, Checkbox, Toggle o Collapsible diventa invece un componente vero: `<Name>.vue` basato su Reka UI, oppure `<Name>.tsx` con `<Name>.module.css` basato sul pacchetto `radix-ui` di Radix UI. Le sue varianti sono compilate in un foglio di stile che segue gli stati impostati da Reka e Radix (`data-state`, `data-disabled`, hover e focus), e ogni altra proprietà di variante è una prop che il componente imposta come attributo `data-*`. In Vue il suo valore è un `v-model` come `checked` o `open`; in React il componente accetta le prop della radice di Radix, come `checked`, `defaultChecked` e `onCheckedChange`. Le sue storie renderizzano il componente e una funzione play fa clic sul controllo e ne verifica lo stato, quindi il progetto Storybook ha bisogno di `reka-ui` o `radix-ui`.
Le storie includono voci `parameters.design` per [`@storybook/addon-designs`](https://github.com/storybookjs/addon-designs):

View file

@ -144,7 +144,7 @@ Each variant of a component set becomes a story, and its variant properties beco
Stories render the component as HTML with inline styles, like `-f html`, so they need no OpenPencil runtime; `--framework` (`react`, `vue`, or `html`) only changes the wrapper and the `Meta`/`StoryObj` import from `@storybook/react-vite`, `@storybook/vue3-vite`, or `@storybook/html-vite`. Text uses the document's font families, which Storybook has to load itself. Text, boolean, and instance-swap properties are not exported yet.
With `--framework vue`, a component set whose behaviour is a Button, Switch, Checkbox, Toggle, or Collapsible becomes a real component instead: `<Name>.vue` built on Reka UI. Its variants are compiled into a scoped stylesheet keyed on the states Reka sets (`data-state`, `data-disabled`, hover, and focus), its value is a `v-model` such as `checked` or `open`, and every other variant property is a prop the component sets as a `data-*` attribute. Its stories render the component, and a play function clicks the control and checks its state, so the Storybook project needs `reka-ui` installed.
With `--framework vue` or `--framework react`, a component set whose behaviour is a Button, Switch, Checkbox, Toggle, or Collapsible becomes a real component instead: `<Name>.vue` built on Reka UI, or `<Name>.tsx` with a `<Name>.module.css` built on Radix UI's `radix-ui` package. Its variants are compiled into a stylesheet keyed on the states Reka and Radix set (`data-state`, `data-disabled`, hover, and focus), and every other variant property is a prop the component sets as a `data-*` attribute. In Vue its value is a `v-model` such as `checked` or `open`; in React the component takes the Radix root's props, such as `checked`, `defaultChecked`, and `onCheckedChange`. Its stories render the component, and a play function clicks the control and checks its state, so the Storybook project needs `reka-ui` or `radix-ui` installed.
Stories carry `parameters.design` entries for [`@storybook/addon-designs`](https://github.com/storybookjs/addon-designs):

View file

@ -75,6 +75,11 @@
"@types/bun": "^1.3.14",
"@types/css-tree": "^3.2.0",
"@types/cssesc": "^3.0.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.2.0",
"radix-ui": "^1.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"reka-ui": "^2.10.5",
"storybook": "^10.6.1",
"tsdown": "^0.22.14",

View file

@ -2,6 +2,7 @@ import { behaviourArgs } from '#dom-css/behaviours/args'
import { BUTTON_RESET } from '#dom-css/behaviours/reset'
import { allElements } from '#dom-css/behaviours/states/layers'
import { stateStyles } from '#dom-css/behaviours/states/model'
import { layerClassNames, propAttribute } from '#dom-css/behaviours/states/names'
import type { StateElement, StateStyles } from '#dom-css/behaviours/states/types'
import { camelCase } from 'es-toolkit/string'
@ -32,21 +33,82 @@ export interface VariantProp {
default: string
}
/**
* What the control's root binds, which each framework writes in its own idiom: the two-way
* value (`v-model`, or Radix's `checked` and `onCheckedChange`), `disabled`, and a variant
* property the component sets as a `data-*` attribute from its prop.
*/
export type ComponentBinding =
| { type: 'model'; name: string }
| { type: 'disabled' }
| { type: 'prop'; prop: VariantProp; attribute: string }
/** A layer of the generated markup, before a framework picks its element or component. */
export interface ComponentElement {
type: 'element'
/** The behaviour part the layer draws, `root` for the control itself, or null. */
part: string | null
/** The element the design projects to, used when no library component draws the part. */
tag: string
/** The readable class the state styles select it by. */
className: string
/** Attributes the design sets, such as an image's `src` or its own `class`. */
attrs: Record<string, string>
/** On the root only. */
bindings: ComponentBinding[]
children: ComponentNode[]
}
export type ComponentNode = ComponentElement | { type: 'text'; value: string }
/** One component generated from a component set with a behaviour. */
export interface ComponentModel {
/** The component's identifier and file name. */
name: string
kind: GeneratedKind
styles: StateStyles
/** Each layer's part by the behaviour's part id; the root is `root`. */
parts: Map<StateElement, string>
/** The boolean the component binds with `v-model` or `onChange`: `checked`, `pressed`, `open`. */
/** The markup every framework writes, with each layer's part and the root's bindings. */
tree: ComponentElement
/** The boolean the component binds two ways: `checked`, `pressed`, or `open`. */
model: string | null
/** Whether the set draws a disabled state, so the component takes `disabled`. */
disabled: boolean
props: VariantProp[]
}
function componentTree(
node: StateElement,
parts: Map<StateElement, string>,
classes: Map<StateElement, string>,
bindings: ComponentBinding[]
): ComponentElement {
const part = parts.get(node) ?? null
return {
type: 'element',
part,
tag: node.tagName,
className: classes.get(node) ?? '',
attrs: node.attrs,
bindings: part === 'root' ? bindings : [],
children: node.children.map((child) =>
child.type === 'text'
? { type: 'text', value: child.text }
: componentTree(child, parts, classes, [])
)
}
}
/** A generated component's files, and what its stories need to know about it. */
export interface GeneratedComponent {
files: { path: string; content: string }[]
/** How stories import it: its path next to them, and whether it's a named export. */
entry: { path: string; named: boolean }
/** The prop a story sets the value with, if the component has one. */
valueArg: string | null
}
export type ComponentGenerator = (component: ComponentModel) => Promise<GeneratedComponent>
/** Parts each kind renders as a native button, which the reset clears for the design. */
const BUTTON_PARTS: Record<GeneratedKind, readonly string[]> = {
button: ['root'],
@ -106,14 +168,24 @@ export function componentModel(graph: SceneGraph, set: SceneNode): ComponentMode
options: definition.variantOptions ?? [],
default: definition.defaultValue
}))
const disabled =
booleans.some((arg) => arg.name === 'disabled') || args.states?.disabled !== undefined
const bindings: ComponentBinding[] = [
...(model ? [{ type: 'model' as const, name: model }] : []),
...(disabled ? [{ type: 'disabled' as const }] : []),
...props.map((prop) => ({
type: 'prop' as const,
prop,
attribute: propAttribute(prop.property)
}))
]
return {
name: identifierName(set.name, 'Component'),
kind: behaviour.kind,
styles,
parts,
tree: componentTree(styles.root, parts, layerClassNames(styles), bindings),
model,
disabled:
booleans.some((arg) => arg.name === 'disabled') || args.states?.disabled !== undefined,
disabled,
props
}
}

View file

@ -0,0 +1,202 @@
import { stateStylesToCSS } from '#dom-css/behaviours/states/css'
import dedent from 'dedent'
import { omit } from 'es-toolkit/object'
import { es, jsx } from '@open-pencil/emit'
import type {
ComponentElement,
ComponentGenerator,
ComponentModel,
ComponentNode,
GeneratedKind
} from './model'
/**
* The Radix primitive a kind renders, from the unified `radix-ui` package, and the component
* of it each part uses. Its root takes the value two ways (`checked` and `onCheckedChange`,
* `pressed`, `open`) and `disabled` itself, so the generated props extend the root's.
*/
const RADIX: Record<GeneratedKind, { namespace: string; parts: Record<string, string> } | null> = {
button: null,
switch: { namespace: 'Switch', parts: { root: 'Root', thumb: 'Thumb' } },
checkbox: { namespace: 'Checkbox', parts: { root: 'Root', indicator: 'Indicator' } },
toggle: { namespace: 'Toggle', parts: { root: 'Root' } },
collapsible: {
namespace: 'Collapsible',
parts: { root: 'Root', trigger: 'Trigger', content: 'Content' }
}
}
const PRIMITIVE = (namespace: string) => `${namespace}Primitive`
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
/** `styles.thumb`, or `styles['switch-thumb']` for a class that isn't an identifier. */
function moduleClass(className: string): es.SyntaxNode {
return {
type: 'MemberExpression',
object: es.identifier('styles'),
property: IDENTIFIER.test(className) ? es.identifier(className) : es.string(className),
computed: !IDENTIFIER.test(className),
optional: false
}
}
/** Several classes on one element, skipping a caller's that is absent. */
const CLASS_NAMES = es.parseExpression("$classes.filter(Boolean).join(' ')")
/** A Radix primitive's part, a native button for a root without one, or the design's tag. */
function tagOf(node: ComponentElement, kind: GeneratedKind): string {
const radix = RADIX[kind]
const part = node.part ? radix?.parts[node.part] : undefined
if (radix && part) return `${PRIMITIVE(radix.namespace)}.${part}`
return node.part === 'root' ? 'button' : node.tag
}
/** `props.disabled || undefined`, so a native button sets `data-disabled` only while disabled. */
const DISABLED_FLAG = es.parseExpression('props.disabled || undefined')
function element(node: ComponentNode, kind: GeneratedKind, depth: number): es.SyntaxNode {
if (node.type === 'text') return jsx.text(node.value)
const radix = RADIX[kind]
const part = node.part ? radix?.parts[node.part] : undefined
const root = node.part === 'root'
const native = root && !part
// The design's own classes, the state styles' module class, and on the root the caller's,
// which the spread would otherwise lose to the generated one.
const classes = [
...(node.attrs.class ? [es.string(node.attrs.class)] : []),
moduleClass(node.className),
...(root ? [es.parseExpression('props.className')] : [])
]
const only = classes.length === 1 ? classes.at(0) : undefined
const className = only ?? es.fill(CLASS_NAMES, { $classes: es.array(classes) })
const attributes = [
...(native ? [jsx.attribute('type', jsx.stringValue('button'))] : []),
// The root passes the caller's props on, such as `checked` or `onClick`.
...(root ? [jsx.spread(es.identifier('props'))] : []),
...Object.entries(omit(node.attrs, ['class'])).map(([name, value]) =>
jsx.attribute(name, jsx.stringValue(value))
),
jsx.attribute('className', jsx.container(className)),
...node.bindings.flatMap((binding) => {
if (binding.type === 'prop')
return [jsx.attribute(binding.attribute, jsx.container(es.identifier(binding.prop.name)))]
// Radix sets `data-disabled` on its own roots; a native button needs it for the styles.
if (binding.type === 'disabled' && native)
return [jsx.attribute('data-disabled', jsx.container(DISABLED_FLAG))]
return []
})
]
const inline = node.children.length === 1 && node.children[0]?.type === 'text'
return jsx.element(
tagOf(node, kind),
attributes,
node.children.map((child) => element(child, kind, depth + 1)),
depth,
inline
)
}
const MODULE = es.parseModule(dedent`
import type { ComponentProps } from 'react'
import styles from '$styles'
export type $Props = $Type
export function $Name($params: $Props) {
return $body
}
`)
const RADIX_IMPORT = es.parseModule(`import { $Namespace as $Primitive } from 'radix-ui'`)
/** The root's own props, extended with the component's variant props when it has any. */
function propsType(component: ComponentModel): es.SyntaxNode {
const radix = RADIX[component.kind]
const base = radix
? es.fill(es.parseType('ComponentProps<typeof $Root>'), {
$Root: {
type: 'TSQualifiedName',
left: es.identifier(PRIMITIVE(radix.namespace)),
right: es.identifier('Root')
}
})
: es.parseType("ComponentProps<'button'>")
if (component.props.length === 0) return base
return {
type: 'TSIntersectionType',
types: [
base,
es.objectType(
component.props.map((prop) => [prop.name, es.stringUnionType(prop.options), true])
)
]
}
}
/** `{ size = 'Small', ...props }`: variant props with their defaults, the rest passed on. */
function parameters(component: ComponentModel): es.SyntaxNode {
if (component.props.length === 0) return es.identifier('props')
return {
type: 'ObjectPattern',
properties: [
...component.props.map((prop) => ({
type: 'Property',
kind: 'init',
key: es.identifier(prop.name),
value: {
type: 'AssignmentPattern',
left: es.identifier(prop.name),
right: es.string(prop.default)
},
computed: false,
method: false,
shorthand: true
})),
{ type: 'RestElement', argument: es.identifier('props') }
]
}
}
/**
* A component as a React component on Radix UI: its parts as Radix primitives from the
* `radix-ui` package, its variants' state styles in a CSS module, and props that extend the
* Radix root's with its other variant properties.
*/
export const reactComponent: ComponentGenerator = async (component) => {
const radix = RADIX[component.kind]
const stylesPath = `${component.name}.module.css`
const imports = radix
? es.fill(RADIX_IMPORT, {
$Namespace: es.identifier(radix.namespace),
$Primitive: es.identifier(PRIMITIVE(radix.namespace))
}).body
: []
const [typeImport, stylesImport, ...rest] = es.fill(MODULE, {
$styles: es.string(`./${stylesPath}`),
$Props: es.identifier(`${component.name}Props`),
$Type: propsType(component),
$Name: es.identifier(component.name),
$params: parameters(component),
$body: element(component.tree, component.kind, 1)
}).body
const program = {
type: 'Program',
sourceType: 'module',
body: [typeImport, ...imports, stylesImport, ...rest]
}
const { css } = await stateStylesToCSS(component.styles)
const model = component.model
return {
files: [
{ path: `${component.name}.tsx`, content: `${jsx.printModule(program)}\n` },
{ path: stylesPath, content: css }
],
entry: { path: `./${component.name}`, named: true },
// Radix's uncontrolled value, so a story's control and its play function can both change it.
valueArg: model ? `default${model.charAt(0).toUpperCase()}${model.slice(1)}` : null
}
}

View file

@ -1,11 +1,16 @@
import { stateStylesToCSS } from '#dom-css/behaviours/states/css'
import { layerClassNames, propAttribute } from '#dom-css/behaviours/states/names'
import type { StateElement, StateNode } from '#dom-css/behaviours/states/types'
import { compact } from 'es-toolkit/array'
import { omit } from 'es-toolkit/object'
import { es, vue } from '@open-pencil/emit'
import type { ComponentModel, GeneratedKind } from './model'
import type {
ComponentBinding,
ComponentGenerator,
ComponentModel,
ComponentNode,
GeneratedKind
} from './model'
/** The Reka UI components a kind renders, by part, and the prop its model binds. */
const REKA: Record<
@ -34,45 +39,37 @@ const identifier = (name: string) => es.identifier(name)
/** `disabled || undefined`, so a native button sets `data-disabled` only while disabled. */
const DISABLED_FLAG = es.parseExpression('disabled || undefined')
function rootAttributes(component: ComponentModel, native: boolean): vue.VueAttribute[] {
const reka = REKA[component.kind]
const model = component.model && reka.model
function bindingAttributes(
binding: ComponentBinding,
kind: GeneratedKind,
native: boolean
): vue.VueAttribute[] {
if (binding.type === 'model')
return [vue.model(identifier(binding.name), REKA[kind].model === 'open' ? 'open' : undefined)]
if (binding.type === 'prop') return [vue.bound(binding.attribute, identifier(binding.prop.name))]
// Reka sets `data-disabled` on its own roots; a native button needs it for the state styles.
return [
...(native ? [vue.attribute('type', 'button')] : []),
...(model
? [vue.model(identifier(component.model ?? ''), model === 'open' ? 'open' : undefined)]
: []),
...(component.disabled ? [vue.bound('disabled', identifier('disabled'))] : []),
// Reka sets `data-disabled` on its own roots; a native button needs it for the state styles.
...(component.disabled && native ? [vue.bound('data-disabled', DISABLED_FLAG)] : []),
...component.props.map((prop) => vue.bound(propAttribute(prop.property), identifier(prop.name)))
vue.bound('disabled', identifier('disabled')),
...(native ? [vue.bound('data-disabled', DISABLED_FLAG)] : [])
]
}
function templateNode(
node: StateNode,
component: ComponentModel,
classes: Map<StateElement, string>,
used: Set<string>
): vue.VueNode {
if (node.type === 'text') return vue.text(node.text)
const part = component.parts.get(node)
const reka = part ? REKA[component.kind].parts[part] : undefined
function templateNode(node: ComponentNode, kind: GeneratedKind, used: Set<string>): vue.VueNode {
if (node.type === 'text') return vue.text(node.value)
const reka = node.part ? REKA[kind].parts[node.part] : undefined
if (reka) used.add(reka)
const native = part === 'root' && !reka
const className = compact([node.attrs.class, classes.get(node)]).join(' ')
const attributes = [
...Object.entries(node.attrs)
.filter(([name]) => name !== 'class')
.map(([name, value]) => vue.attribute(name, value)),
vue.attribute('class', className),
...(part === 'root' ? rootAttributes(component, native) : [])
]
const tag = reka ?? (native ? 'button' : node.tagName)
const native = node.part === 'root' && !reka
return vue.element(
tag,
attributes,
node.children.map((child) => templateNode(child, component, classes, used))
reka ?? (native ? 'button' : node.tag),
[
...(native ? [vue.attribute('type', 'button')] : []),
...Object.entries(omit(node.attrs, ['class'])).map(([name, value]) =>
vue.attribute(name, value)
),
vue.attribute('class', compact([node.attrs.class, node.className]).join(' ')),
...node.bindings.flatMap((binding) => bindingAttributes(binding, kind, native))
],
node.children.map((child) => templateNode(child, kind, used))
)
}
@ -128,10 +125,20 @@ function script(component: ComponentModel, used: Set<string>): es.SyntaxNode {
* scoped stylesheet, its parts as Reka components, and its props from the behaviour and its
* other variant properties.
*/
export async function vueComponent(component: ComponentModel): Promise<string> {
const classes = layerClassNames(component.styles)
export const vueComponent: ComponentGenerator = async (component) => {
const used = new Set<string>()
const template = templateNode(component.styles.root, component, classes, used)
const template = templateNode(component.tree, component.kind, used)
const { css } = await stateStylesToCSS(component.styles)
return vue.printComponent({ script: script(component, used), template, style: css })
const path = `${component.name}.vue`
return {
files: [
{
path,
content: vue.printComponent({ script: script(component, used), template, style: css })
}
],
entry: { path: `./${path}`, named: false },
// `v-model` binds the value, and its prop takes a story's initial value.
valueArg: component.model
}
}

View file

@ -3,18 +3,16 @@ import { compact } from 'es-toolkit/array'
import { es } from '@open-pencil/emit'
import type { ComponentModel, GeneratedKind } from '../components/model'
import type { ComponentModel, GeneratedComponent, GeneratedKind } from '../components/model'
import { claimName, identifierName } from './names'
/** Tells readers the file is generated; nothing parses it. */
const HEADER = '// Generated by OpenPencil. Re-export to update; edits are overwritten.'
const MODULE = es.parseModule(dedent`
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { Meta, StoryObj } from '$storybook'
import { expect } from 'storybook/test'
import $Component from '$path'
const meta = {
title: $title,
component: $Component,
@ -27,6 +25,9 @@ const MODULE = es.parseModule(dedent`
type Story = StoryObj<typeof meta>
`)
const IMPORT_DEFAULT = es.parseModule(`import $Component from '$path'`)
const IMPORT_NAMED = es.parseModule(`import { $Component } from '$path'`)
const STORY = es.parseModule(`export const $name: Story = $story`)
/**
@ -49,10 +50,11 @@ const PLAY = es.parseExpression(dedent`
`)
export interface ComponentStoriesData {
/** The Storybook renderer package the stories import their types from. */
storybook: string
title: string
component: ComponentModel
/** The component's import path next to the stories, such as `./Switch.vue`. */
path: string
generated: Pick<GeneratedComponent, 'entry' | 'valueArg'>
/** Design links for the whole file, as `parameters.design` entries. */
design: { name: string; type: string; url: string }[]
}
@ -60,18 +62,18 @@ export interface ComponentStoriesData {
const FALSE = es.parseExpression('false')
const TRUE = es.parseExpression('true')
function restArgs(component: ComponentModel): [string, es.SyntaxNode][] {
function restArgs(component: ComponentModel, model: string | null): [string, es.SyntaxNode][] {
return [
...(component.model ? [[component.model, FALSE] as [string, es.SyntaxNode]] : []),
...(model ? [[model, FALSE] as [string, es.SyntaxNode]] : []),
...(component.disabled ? [['disabled', FALSE] as [string, es.SyntaxNode]] : []),
...component.props.map((prop): [string, es.SyntaxNode] => [prop.name, es.string(prop.default)])
]
}
function argTypes(component: ComponentModel): es.SyntaxNode {
function argTypes(component: ComponentModel, model: string | null): es.SyntaxNode {
const boolean = es.object([['control', es.string('boolean')]])
return es.object([
...(component.model ? [[component.model, boolean] as [string, es.SyntaxNode]] : []),
...(model ? [[model, boolean] as [string, es.SyntaxNode]] : []),
...(component.disabled ? [['disabled', boolean] as [string, es.SyntaxNode]] : []),
...component.props.map((prop): [string, es.SyntaxNode] => [
prop.name,
@ -84,7 +86,10 @@ function argTypes(component: ComponentModel): es.SyntaxNode {
}
/** A story per state worth seeing: at rest, each boolean on, disabled, and each prop value. */
function stories(component: ComponentModel): { name: string; story: es.SyntaxNode }[] {
function stories(
component: ComponentModel,
model: string | null
): { name: string; story: es.SyntaxNode }[] {
const taken = new Set<string>()
const story = (label: string, args: [string, es.SyntaxNode][], play?: es.SyntaxNode) => ({
name: claimName(identifierName(label, 'Story'), taken),
@ -101,7 +106,7 @@ function stories(component: ComponentModel): { name: string; story: es.SyntaxNod
interaction &&
es.fill(PLAY, { $role: es.string(interaction.role), $state: es.string(interaction.state) })
),
...(component.model ? [story(component.model, [[component.model, TRUE]])] : []),
...(component.model && model ? [story(component.model, [[model, TRUE]])] : []),
...(component.disabled ? [story('Disabled', [['disabled', TRUE]])] : []),
...component.props.flatMap((prop) =>
prop.options
@ -111,7 +116,7 @@ function stories(component: ComponentModel): { name: string; story: es.SyntaxNod
]
}
/** CSF3 stories for a generated Vue component, with a play function that operates it. */
/** CSF3 stories for a generated component, with a play function that operates it. */
export function printComponentStories(data: ComponentStoriesData): string {
const { component } = data
const parameters =
@ -131,15 +136,20 @@ export function printComponentStories(data: ComponentStoriesData): string {
]
])
: es.OMIT
const { entry, valueArg: model } = data.generated
const body = es.fill(MODULE, {
$storybook: es.string(data.storybook),
$Component: es.identifier(component.name),
$path: es.string(data.path),
$title: es.string(data.title),
$parameters: parameters,
$args: es.object(restArgs(component)),
$argTypes: argTypes(component)
$args: es.object(restArgs(component, model)),
$argTypes: argTypes(component, model)
}).body
const exported = stories(component).flatMap(
const componentImport = es.fill(entry.named ? IMPORT_NAMED : IMPORT_DEFAULT, {
$Component: es.identifier(component.name),
$path: es.string(entry.path)
}).body
const exported = stories(component, model).flatMap(
(item) => es.fill(STORY, { $name: es.identifier(item.name), $story: item.story }).body
)
// Only a control with a play function uses `expect`.
@ -148,7 +158,7 @@ export function printComponentStories(data: ComponentStoriesData): string {
const module = es.printModule({
type: 'Program',
sourceType: 'module',
body: compact([...imports, ...rest, ...exported])
body: compact([...imports, ...componentImport, ...rest, ...exported])
})
return `${HEADER}\n${module}\n`
}

View file

@ -1,17 +1,29 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { ExportHTMLFile } from '../bundle'
import { componentModel } from '../components/model'
import { componentModel, type ComponentGenerator } from '../components/model'
import { reactComponent } from '../components/react'
import { vueComponent } from '../components/vue'
import { serializeHTML } from '../html'
import { sceneNodeToDesignDocument } from '../projection'
import { printComponentStories } from './component'
import { collectGroups, type StoryGroup } from './groups'
import { printStoryModule, type StoryDesign, type StorybookFramework } from './module'
import {
printStoryModule,
STORYBOOK_PACKAGES,
type StoryDesign,
type StorybookFramework
} from './module'
import { claimName, identifierName, storyId } from './names'
export { STORYBOOK_FRAMEWORKS, type StorybookFramework } from './module'
/** Frameworks whose stories render generated components; HTML stories stay static. */
const GENERATORS: Partial<Record<StorybookFramework, ComponentGenerator>> = {
vue: vueComponent,
react: reactComponent
}
export interface ExportStorybookOptions {
framework?: StorybookFramework
/** Limit the export to one page. Defaults to every page. */
@ -123,19 +135,22 @@ export async function exportStorybook(
})
if (options.pageId && page.id !== options.pageId) continue
const component = framework === 'vue' && group.set ? componentModel(graph, group.set) : null
if (component) {
const generate = GENERATORS[framework]
const component = generate && group.set ? componentModel(graph, group.set) : null
if (generate && component) {
// The component is imported by the file's name, so the two always match.
component.name = file
add(page, `${file}.vue`, await vueComponent(component))
const generated = await generate(component)
for (const item of generated.files) add(page, item.path, item.content)
const design = designLink({ linkPath: options.linkPath, uniqueNames }, group.linkNode)
add(
page,
`${file}.stories.ts`,
printComponentStories({
storybook: STORYBOOK_PACKAGES[framework],
title: group.title,
component,
path: `./${file}.vue`,
generated,
design: design.flatMap((entry) =>
entry.type === 'link' ? [{ name: 'OpenPencil', type: 'link', url: entry.url }] : []
)

View file

@ -71,29 +71,33 @@ const MODULE = es.parseModule(dedent`
const IMAGE = es.parseModule(`const $name = new URL($path, import.meta.url).href`)
const STORY = es.parseModule(`export const $name: Story = $story`)
const FRAMEWORKS: Record<
StorybookFramework,
{ storybook: string; imports: es.SyntaxNode[]; render: es.SyntaxNode }
> = {
react: {
storybook: '@storybook/react-vite',
imports: es.parseModule(`import { createElement } from 'react'`).body,
render: es.parseExpression(
`createElement('div', { dangerouslySetInnerHTML: { __html: variantHTML(args) } })`
)
},
vue: {
storybook: '@storybook/vue3-vite',
imports: es.parseModule(`import { h } from 'vue'`).body,
render: es.parseExpression(`{ setup: () => () => h('div', { innerHTML: variantHTML(args) }) }`)
},
html: {
storybook: '@storybook/html-vite',
imports: [],
render: es.parseExpression('variantHTML(args)')
}
/** The Storybook renderer package each framework's stories import their types from. */
export const STORYBOOK_PACKAGES: Record<StorybookFramework, string> = {
react: '@storybook/react-vite',
vue: '@storybook/vue3-vite',
html: '@storybook/html-vite'
}
const FRAMEWORKS: Record<StorybookFramework, { imports: es.SyntaxNode[]; render: es.SyntaxNode }> =
{
react: {
imports: es.parseModule(`import { createElement } from 'react'`).body,
render: es.parseExpression(
`createElement('div', { dangerouslySetInnerHTML: { __html: variantHTML(args) } })`
)
},
vue: {
imports: es.parseModule(`import { h } from 'vue'`).body,
render: es.parseExpression(
`{ setup: () => () => h('div', { innerHTML: variantHTML(args) }) }`
)
},
html: {
imports: [],
render: es.parseExpression('variantHTML(args)')
}
}
const imageName = (variant: number) => `design${variant}`
const BOOLEAN = es.parseType('boolean')
@ -214,7 +218,7 @@ function argsType(data: StoryModuleData): es.SyntaxNode {
export function printStoryModule(data: StoryModuleData): string {
const framework = FRAMEWORKS[data.framework]
const [storybookImport, ...body] = es.fill(MODULE, {
$storybook: es.string(framework.storybook),
$storybook: es.string(STORYBOOK_PACKAGES[data.framework]),
$Args: argsType(data),
$variants: es.object(
data.variants.map((variant) => [JSON.stringify(variant.values), es.string(variant.html)])

View file

@ -0,0 +1,85 @@
import { afterAll, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { buttonSet, collapsibleSet, switchSet } from '#dom-css-tests/behaviours/fixtures'
import { exportStorybook } from '#dom-css/index'
import { createElement, type ComponentType } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
/** The packages generated code imports, linked so it resolves them as an app would. */
const APP_PACKAGES = ['react', 'react-dom', 'radix-ui', 'storybook']
const output = await mkdtemp(join(tmpdir(), 'open-pencil-react-'))
await mkdir(join(output, 'node_modules'))
for (const name of APP_PACKAGES)
await symlink(
dirname(Bun.resolveSync(`${name}/package.json`, import.meta.dir)),
join(output, 'node_modules', name)
)
afterAll(() => rm(output, { recursive: true, force: true }))
type Props = Record<string, unknown>
/** Exports a fixture's set to React stories and loads the component and stories it writes. */
async function generate(fixture: ReturnType<typeof switchSet>) {
const files = await exportStorybook(fixture.graph, { framework: 'react' })
const name = fixture.set.name
// Bun caches a folder's listing once it imports from it, so each export gets its own.
const folder = await mkdtemp(join(output, `${name}-`))
for (const file of files) await writeFile(join(folder, file.path), file.content)
const module: Record<string, ComponentType<Props>> = await import(join(folder, `${name}.tsx`))
const stories: Record<string, { args?: Props; play?: unknown }> = await import(
join(folder, `${name}.stories.ts`)
)
const component = module[name]
if (!component) throw new Error(`${name}.tsx exports no ${name}`)
return { files, component, stories }
}
const render = (component: ComponentType<Props>, props: Props = {}) =>
renderToStaticMarkup(createElement(component, props))
describe('generated React components', () => {
test('a switch is a Radix switch whose state follows its props', async () => {
const { files, component } = await generate(switchSet())
expect(files.map((file) => file.path).sort()).toEqual([
'Switch.module.css',
'Switch.stories.ts',
'Switch.tsx'
])
const off = render(component)
expect(off).toContain('role="switch"')
expect(off).toContain('data-state="unchecked"')
expect(render(component, { defaultChecked: true })).toContain('data-state="checked"')
expect(render(component, { checked: true })).toContain('aria-checked="true"')
expect(render(component, { disabled: true })).toContain('data-disabled=""')
// Bun loads CSS modules as empty objects, so the generated classes are checked in the browser.
expect(render(component, { className: 'custom' })).toContain('class="custom"')
})
test('a collapsible opens its content', async () => {
const { component } = await generate(collapsibleSet())
expect(render(component)).toContain('data-state="closed"')
expect(render(component, { defaultOpen: true })).toMatch(/^<div data-state="open"/)
})
test('a button sets its other variant properties as data attributes', async () => {
const { component } = await generate(buttonSet())
const large = render(component, { size: 'Large' })
expect(large).toMatch(/^<button[^>]*type="button"/)
expect(large).toContain('data-size="Large"')
})
})
describe('generated React stories', () => {
test('set the uncontrolled value so the play function can change it', async () => {
const { component, stories } = await generate(switchSet())
const meta = stories.default as { component: unknown; args: Props }
expect(meta.component).toBe(component)
expect(meta.args).toEqual({ defaultChecked: false, disabled: false })
expect(stories.Checked?.args).toEqual({ defaultChecked: true })
expect(typeof stories.Default?.play).toBe('function')
})
})

View file

@ -15,7 +15,7 @@ const card = jsx.element(
jsx.printJSX(card) // <Card title={"Fish & chips"}>Hello</Card>
```
`jsx.stringValue` and `jsx.text` keep a value as plain JSX only when JSX reads it back unchanged; anything with quotes, entities, braces, angle brackets, backslashes, or line breaks becomes a string literal.
`jsx.stringValue` and `jsx.text` keep a value as plain JSX only when JSX reads it back unchanged; anything with quotes, entities, braces, angle brackets, backslashes, or line breaks becomes a string literal. `jsx.printModule` prints a whole TSX module, such as an `es` template filled with JSX, quoting every string the same way; dotted tags such as `Switch.Root` print as members of a namespace, and `jsx.spread` passes an object on as attributes.
`es` parses TypeScript templates, fills `$name` placeholders, and prints modules:

View file

@ -11,7 +11,8 @@ export interface SyntaxNode {
const TSParser = Parser.extend(tsPlugin())
function isNode(value: unknown): value is SyntaxNode {
/** Whether a value is a syntax node, as opposed to a primitive field of one. */
export function isNode(value: unknown): value is SyntaxNode {
return typeof value === 'object' && value !== null && 'type' in value
}
@ -67,12 +68,15 @@ export function fill<T extends SyntaxNode>(
const replacement = (node: SyntaxNode): SyntaxNode | undefined => {
const value = holeOf(node)
if (!value || value === OMIT) return undefined
// `$name: Story` keeps its annotation when the identifier is filled.
return node.typeAnnotation ? { ...value, typeAnnotation: node.typeAnnotation } : value
// `$name: Story` keeps its annotation when the identifier is filled, with its own holes filled.
const annotation = child(node, 'typeAnnotation')
return annotation
? { ...structuredClone(value), typeAnnotation: visit(annotation) }
: structuredClone(value)
}
const visit = (node: SyntaxNode): SyntaxNode => {
function visit(node: SyntaxNode): SyntaxNode {
const filled = replacement(node)
if (filled) return structuredClone(filled)
if (filled) return filled
const copy: SyntaxNode = { ...node }
for (const [key, value] of Object.entries(node)) {
if (Array.isArray(value))

View file

@ -1,7 +1,7 @@
import { print } from 'esrap'
import tsx from 'esrap/languages/tsx'
import type { SyntaxNode } from './estree'
import { isNode, type SyntaxNode } from './estree'
/**
* JSX attribute strings end at `"`, decode `&` entities, and keep backslashes literally,
@ -14,6 +14,15 @@ const LITERAL_TEXT = /^[^{}<>&\r\n]*$/
export const identifier = (name: string): SyntaxNode => ({ type: 'JSXIdentifier', name })
/** A tag name; a dotted one such as `Switch.Root` is a member of a namespace. */
function tagName(tag: string): SyntaxNode {
const [first = tag, ...rest] = tag.split('.')
return rest.reduce<SyntaxNode>(
(object, property) => ({ type: 'JSXMemberExpression', object, property: identifier(property) }),
identifier(first)
)
}
export const literal = (value: string | number | boolean): SyntaxNode => ({
type: 'Literal',
value
@ -31,6 +40,12 @@ export const whitespace = (value: string): SyntaxNode => ({ type: 'JSXText', val
export const stringValue = (value: string): SyntaxNode =>
LITERAL_ATTRIBUTE.test(value) ? literal(value) : container(literal(value))
/** `{...expression}`, passing an object's properties on as attributes. */
export const spread = (argument: SyntaxNode): SyntaxNode => ({
type: 'JSXSpreadAttribute',
argument
})
/** An attribute; a null value prints the bare name, as for `true`. */
export const attribute = (name: string, value: SyntaxNode | null): SyntaxNode => ({
type: 'JSXAttribute',
@ -64,7 +79,7 @@ export function element(
depth: number,
inline = false
): SyntaxNode {
const name = identifier(tag)
const name = tagName(tag)
const content = inline || children.length === 0 ? children : indented(children, depth)
return {
type: 'JSXElement',
@ -82,3 +97,23 @@ export function element(
export function printJSX(node: SyntaxNode): string {
return print(node, tsx({ quotes: 'double' }), { indent: ' ' }).code
}
/** A copy without strings' source spelling, so the printer quotes every string the same way. */
function withoutRawStrings(node: SyntaxNode): SyntaxNode {
const copy: SyntaxNode = { ...node }
if (node.type === 'Literal' && typeof node.value === 'string') delete copy.raw
for (const [key, value] of Object.entries(node)) {
if (Array.isArray(value))
copy[key] = value.map((item: unknown) => (isNode(item) ? withoutRawStrings(item) : item))
else if (isNode(value)) copy[key] = withoutRawStrings(value)
}
return copy
}
/**
* A TSX module, such as a component built with `es` templates and JSX from this module. Strings
* parsed from a template and strings built here print with the same double quotes.
*/
export function printModule(program: SyntaxNode): string {
return print(withoutRawStrings(program), tsx({ quotes: 'double' }), { indent: ' ' }).code
}

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { jsx } from '#emit/index'
import { es, jsx } from '#emit/index'
const card = (attributes: ReturnType<typeof jsx.attribute>[], children = [jsx.text('Hi')]) =>
jsx.printJSX(jsx.element('Card', attributes, children, 0, true))
@ -46,3 +46,41 @@ describe('JSX elements', () => {
)
})
})
describe('JSX modules', () => {
test('print namespaced tags, spreads, and JSX inside a TypeScript module', () => {
const body = jsx.element(
'Switch.Root',
[jsx.spread(es.identifier('props')), jsx.attribute('className', jsx.stringValue('switch'))],
[jsx.element('Switch.Thumb', [], [], 2)],
1
)
const module = es.fill(
es.parseModule('export function Toggle(props: Props) { return $body }'),
{
$body: body
}
)
expect(jsx.printModule(module)).toBe(
[
'export function Toggle(props: Props) {',
' return <Switch.Root {...props} className="switch">',
' <Switch.Thumb />',
' </Switch.Root>;',
'}'
].join('\n')
)
})
})
describe('filling templates', () => {
test('fill holes inside the annotation of a filled identifier', () => {
const module = es.fill(es.parseModule("import a from 'a'\nfunction f($params: $Props) {}"), {
$params: es.identifier('props'),
$Props: es.identifier('ButtonProps')
})
expect(jsx.printModule(module)).toBe(
['import a from "a";', '', 'function f(props: ButtonProps) {}'].join('\n')
)
})
})

View file

@ -0,0 +1,277 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import react from '@vitejs/plugin-react'
import vue from '@vitejs/plugin-vue'
import { createServer, type PluginOption, type ViteDevServer } from 'vite'
import { exportStorybook } from '@open-pencil/dom-css'
import {
emptyBehaviour,
SceneGraph,
withBehaviour,
type Behaviour,
type ComponentPropertyReference
} from '@open-pencil/scene-graph'
import { expect, test } from '../fixtures'
const fill = (r: number, g: number, b: number) => [
{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }
]
const slot = (propertyId: string): ComponentPropertyReference[] => [
{ propertyId, field: 'SLOT_CONTENT' }
]
/** A set with a variant per value of one property, drawn by `draw`, and a behaviour. */
function controlSet(
name: string,
property: { name: string; values: string[] },
behaviour: Behaviour,
draw: (graph: SceneGraph, variantId: string, value: string) => void
): SceneGraph {
const graph = new SceneGraph()
const set = graph.createNode('COMPONENT_SET', graph.getPages()[0].id, {
name,
componentPropertyDefinitions: [
{
id: 'value',
name: property.name,
type: 'VARIANT',
defaultValue: property.values[0] ?? '',
variantOptions: property.values
}
]
})
for (const value of property.values) {
const variant = graph.createNode('COMPONENT', set.id, {
name: `${property.name}=${value}`,
componentPropertyValues: { [property.name]: value },
width: 40,
height: 22
})
draw(graph, variant.id, value)
}
graph.updateNode(set.id, { pluginData: withBehaviour(set, behaviour) })
return graph
}
function switchGraph(): SceneGraph {
return controlSet(
'Switch',
{ name: 'State', values: ['Off', 'On'] },
{
...emptyBehaviour('switch'),
booleans: { value: { propertyId: 'value', on: 'On', off: 'Off' } },
parts: { thumb: 'thumb' }
},
(graph, variantId, value) => {
graph.updateNode(variantId, {
fills: value === 'On' ? fill(0.3, 0.27, 0.9) : fill(0.8, 0.84, 0.88)
})
graph.createNode('FRAME', variantId, {
name: 'Thumb',
componentPropertyReferences: slot('thumb'),
x: value === 'On' ? 20 : 2,
y: 2,
width: 18,
height: 18,
fills: fill(1, 1, 1)
})
}
)
}
function disclosureGraph(): SceneGraph {
return controlSet(
'Disclosure',
{ name: 'Open', values: ['No', 'Yes'] },
{
...emptyBehaviour('collapsible'),
booleans: { open: { propertyId: 'value', on: 'Yes', off: 'No' } },
parts: { trigger: 'trigger', content: 'content' }
},
(graph, variantId, value) => {
graph.createNode('FRAME', variantId, {
name: 'Trigger',
componentPropertyReferences: slot('trigger'),
width: 40,
height: 10
})
if (value === 'Yes')
graph.createNode('FRAME', variantId, {
name: 'Content',
componentPropertyReferences: slot('content'),
y: 12,
width: 40,
height: 10,
fills: fill(0.3, 0.27, 0.9)
})
}
)
}
type Framework = 'vue' | 'react'
/**
* Runs the stories' Default story in `#story` the way Storybook does: with its args, then its
* play function given Storybook's own `within` and `userEvent`. The outcome is recorded on
* `#story` as `data-played`.
*/
const PLAY_DEFAULT = [
"import { userEvent, within } from 'storybook/test'",
'export async function playDefault(story: HTMLElement, play?: (context: object) => Promise<void>) {',
' // React renders asynchronously; Storybook runs play only once the story is in the page.',
' while (!story.firstElementChild) await new Promise((resolve) => setTimeout(resolve, 10))',
' try {',
' await play?.({ canvasElement: story, canvas: within(story), userEvent })',
" story.dataset.played = 'yes'",
' } catch (error) {',
' story.dataset.played = String(error)',
' }',
'}'
].join('\n')
/**
* How each framework's app mounts the generated component with a caller's class, disabled,
* and as the stories' Default story, whose play function it then runs.
*/
const APPS: Record<
Framework,
{
entry: string
main: (component: string, stories: string) => string
plugin: () => PluginOption
}
> = {
vue: {
entry: 'main.ts',
main: (component, stories) =>
[
"import { createApp, h } from 'vue'",
`import Control from './${component}'`,
`import * as stories from './${stories}'`,
"import { playDefault } from './play'",
"createApp({ render: () => [h('div', { id: 'enabled' }, h(Control, { class: 'custom' })), h('div', { id: 'disabled' }, h(Control, { disabled: true }))] }).mount('#app')",
"const story = document.getElementById('story')",
'if (story) {',
' createApp({ render: () => h(Control, { ...stories.default.args, ...stories.Default.args }) }).mount(story)',
' void playDefault(story, stories.Default.play)',
'}'
].join('\n'),
plugin: () => vue()
},
react: {
entry: 'main.tsx',
main: (component, stories) =>
[
"import { createRoot } from 'react-dom/client'",
`import * as Module from './${component.replace(/\.tsx$/, '')}'`,
`import * as stories from './${stories}'`,
"import { playDefault } from './play'",
'const Control = Object.values(Module)[0] as (props: Record<string, unknown>) => JSX.Element',
"const app = document.getElementById('app')",
"const story = document.getElementById('story')",
'if (app && story) {',
' createRoot(app).render(<><div id="enabled"><Control className="custom" /></div><div id="disabled"><Control disabled /></div></>)',
' createRoot(story).render(<Control {...stories.default.args} {...stories.Default.args} />)',
' void playDefault(story, stories.Default.play)',
'}'
].join('\n'),
plugin: () => react()
}
}
/**
* Exports `graph` to stories for `framework`, mounts the generated component in a Vite app
* under the test's output folder, so it resolves `vue`, `reka-ui`, `react`, and `radix-ui`
* from the repository as an app would, and serves it.
*/
async function serveComponent(
graph: SceneGraph,
framework: Framework,
folder: string
): Promise<ViteDevServer> {
const files = await exportStorybook(graph, { framework })
const component = files.find((file) => /\.(vue|tsx)$/.test(file.path))
const stories = files.find((file) => file.path.endsWith('.stories.ts'))
if (!component || !stories) throw new Error('No component was generated')
await mkdir(folder, { recursive: true })
for (const file of files) await writeFile(join(folder, file.path), file.content)
const app = APPS[framework]
await writeFile(join(folder, 'play.ts'), PLAY_DEFAULT)
await writeFile(
join(folder, app.entry),
app.main(component.path, stories.path.replace(/\.ts$/, ''))
)
await writeFile(
join(folder, 'index.html'),
`<!doctype html><div id="app"></div><div id="story"></div><script type="module" src="/${app.entry}"></script>`
)
const server = await createServer({
root: folder,
configFile: false,
logLevel: 'silent',
plugins: [app.plugin()],
server: { port: 0, host: '127.0.0.1' }
})
await server.listen()
return server
}
for (const framework of ['vue', 'react'] as const) {
test.describe(`generated ${framework} components in a browser`, () => {
let server: ViteDevServer | undefined
test.afterEach(async () => {
await server?.close()
server = undefined
})
test('a switch toggles on click and its state styles move the thumb', async ({
page
}, info) => {
server = await serveComponent(switchGraph(), framework, info.outputPath('switch'))
await page.goto(server.resolvedUrls?.local[0] ?? '')
const control = page.locator('#enabled').getByRole('switch')
const thumb = page.locator('#enabled [class*="switch__thumb"]')
await expect(control).toHaveAttribute('data-state', 'unchecked')
await expect(thumb).toHaveCSS('left', '2px')
// A caller's class joins the generated one, which still styles the root.
await expect(control).toHaveClass(/custom/)
await expect(control).toHaveCSS('width', '40px')
await control.click()
await expect(control).toHaveAttribute('data-state', 'checked')
await expect(thumb).toHaveCSS('left', '20px')
await expect(control).toHaveCSS('background-color', 'rgb(77, 69, 230)')
const disabled = page.locator('#disabled').getByRole('switch')
await disabled.click({ force: true })
await expect(disabled).toHaveAttribute('data-state', 'unchecked')
})
test("the default story's play function turns the switch on", async ({ page }, info) => {
server = await serveComponent(switchGraph(), framework, info.outputPath('story'))
await page.goto(server.resolvedUrls?.local[0] ?? '')
await expect(page.locator('#story')).toHaveAttribute('data-played', 'yes')
await expect(page.locator('#story').getByRole('switch')).toHaveAttribute(
'data-state',
'checked'
)
})
test('a collapsible shows its content when its trigger opens it', async ({ page }, info) => {
server = await serveComponent(disclosureGraph(), framework, info.outputPath('disclosure'))
await page.goto(server.resolvedUrls?.local[0] ?? '')
const content = page.locator('#enabled [class*="disclosure__content"]')
await expect(content).toBeHidden()
await page.locator('#enabled').getByRole('button').click()
await expect(page.locator('#enabled > [data-state]').first()).toHaveAttribute(
'data-state',
'open'
)
await expect(content).toBeVisible()
await expect(content).toHaveCSS('top', '12px')
})
})
}

View file

@ -1,183 +0,0 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import vue from '@vitejs/plugin-vue'
import { createServer, type ViteDevServer } from 'vite'
import { exportStorybook } from '@open-pencil/dom-css'
import {
emptyBehaviour,
SceneGraph,
withBehaviour,
type Behaviour,
type ComponentPropertyReference
} from '@open-pencil/scene-graph'
import { expect, test } from '../fixtures'
const fill = (r: number, g: number, b: number) => [
{ type: 'SOLID' as const, color: { r, g, b, a: 1 }, opacity: 1, visible: true }
]
const slot = (propertyId: string): ComponentPropertyReference[] => [
{ propertyId, field: 'SLOT_CONTENT' }
]
/** A set with a variant per value of one property, drawn by `draw`, and a behaviour. */
function controlSet(
name: string,
property: { name: string; values: string[] },
behaviour: Behaviour,
draw: (graph: SceneGraph, variantId: string, value: string) => void
): SceneGraph {
const graph = new SceneGraph()
const set = graph.createNode('COMPONENT_SET', graph.getPages()[0].id, {
name,
componentPropertyDefinitions: [
{
id: 'value',
name: property.name,
type: 'VARIANT',
defaultValue: property.values[0] ?? '',
variantOptions: property.values
}
]
})
for (const value of property.values) {
const variant = graph.createNode('COMPONENT', set.id, {
name: `${property.name}=${value}`,
componentPropertyValues: { [property.name]: value },
width: 40,
height: 22
})
draw(graph, variant.id, value)
}
graph.updateNode(set.id, { pluginData: withBehaviour(set, behaviour) })
return graph
}
function switchGraph(): SceneGraph {
return controlSet(
'Switch',
{ name: 'State', values: ['Off', 'On'] },
{
...emptyBehaviour('switch'),
booleans: { value: { propertyId: 'value', on: 'On', off: 'Off' } },
parts: { thumb: 'thumb' }
},
(graph, variantId, value) => {
graph.updateNode(variantId, {
fills: value === 'On' ? fill(0.3, 0.27, 0.9) : fill(0.8, 0.84, 0.88)
})
graph.createNode('FRAME', variantId, {
name: 'Thumb',
componentPropertyReferences: slot('thumb'),
x: value === 'On' ? 20 : 2,
y: 2,
width: 18,
height: 18,
fills: fill(1, 1, 1)
})
}
)
}
function disclosureGraph(): SceneGraph {
return controlSet(
'Disclosure',
{ name: 'Open', values: ['No', 'Yes'] },
{
...emptyBehaviour('collapsible'),
booleans: { open: { propertyId: 'value', on: 'Yes', off: 'No' } },
parts: { trigger: 'trigger', content: 'content' }
},
(graph, variantId, value) => {
graph.createNode('FRAME', variantId, {
name: 'Trigger',
componentPropertyReferences: slot('trigger'),
width: 40,
height: 10
})
if (value === 'Yes')
graph.createNode('FRAME', variantId, {
name: 'Content',
componentPropertyReferences: slot('content'),
y: 12,
width: 40,
height: 10,
fills: fill(0.3, 0.27, 0.9)
})
}
)
}
/**
* Exports `graph` to Vue stories, mounts the generated component twice (as is, and disabled)
* in a Vite app under the test's output folder, so it resolves `vue` and `reka-ui` from the
* repository as an app would, and serves it.
*/
async function serveComponent(graph: SceneGraph, folder: string): Promise<ViteDevServer> {
const files = await exportStorybook(graph, { framework: 'vue' })
const component = files.find((file) => file.path.endsWith('.vue'))
if (!component) throw new Error('No component was generated')
await mkdir(folder, { recursive: true })
await writeFile(join(folder, component.path), component.content)
await writeFile(
join(folder, 'main.ts'),
[
"import { createApp, h } from 'vue'",
`import Control from './${component.path}'`,
"createApp({ render: () => [h('div', { id: 'enabled' }, h(Control)), h('div', { id: 'disabled' }, h(Control, { disabled: true }))] }).mount('#app')"
].join('\n')
)
await writeFile(
join(folder, 'index.html'),
'<!doctype html><div id="app"></div><script type="module" src="/main.ts"></script>'
)
const server = await createServer({
root: folder,
configFile: false,
logLevel: 'silent',
plugins: [vue()],
server: { port: 0, host: '127.0.0.1' }
})
await server.listen()
return server
}
test.describe('generated Vue components in a browser', () => {
let server: ViteDevServer | undefined
test.afterEach(async () => {
await server?.close()
server = undefined
})
test('a switch toggles on click and its state styles move the thumb', async ({ page }, info) => {
server = await serveComponent(switchGraph(), info.outputPath('switch'))
await page.goto(server.resolvedUrls?.local[0] ?? '')
const control = page.locator('#enabled').getByRole('switch')
const thumb = page.locator('#enabled .switch__thumb')
await expect(control).toHaveAttribute('data-state', 'unchecked')
await expect(thumb).toHaveCSS('left', '2px')
await control.click()
await expect(control).toHaveAttribute('data-state', 'checked')
await expect(thumb).toHaveCSS('left', '20px')
await expect(control).toHaveCSS('background-color', 'rgb(77, 69, 230)')
const disabled = page.locator('#disabled').getByRole('switch')
await disabled.click({ force: true })
await expect(disabled).toHaveAttribute('data-state', 'unchecked')
})
test('a collapsible shows its content when its trigger opens it', async ({ page }, info) => {
server = await serveComponent(disclosureGraph(), info.outputPath('disclosure'))
await page.goto(server.resolvedUrls?.local[0] ?? '')
const trigger = page.locator('#enabled .disclosure__trigger')
const content = page.locator('#enabled .disclosure__content')
await expect(content).toBeHidden()
await trigger.click()
await expect(page.locator('#enabled .disclosure')).toHaveAttribute('data-state', 'open')
await expect(content).toBeVisible()
await expect(content).toHaveCSS('top', '12px')
})
})