diff --git a/.changeset/components-folder-zero-config.md b/.changeset/components-folder-zero-config.md
new file mode 100644
index 00000000..0ce8d799
--- /dev/null
+++ b/.changeset/components-folder-zero-config.md
@@ -0,0 +1,5 @@
+---
+'onsystem': patch
+---
+
+Zero config finds React Aria's Tailwind starter, or another flat folder of PascalCase components that wrap a primitives library (React Aria, Radix, Base UI, Headless UI, Ark UI), when it is copied into an app's `src/components/`, `components/` or `app/components/`, next to the app's `App.tsx`. Before, `inspect` found no component files, `check` checked the components as app code and said "No design-system components found: the component rules did not run", and `init --yes` stopped with "zero config found no design system". The same guard as for a flat `src/` applies: at least five such files, four in five of which import the library, so a folder of the app's own features is left alone. `inspect` says `detected src/components/ (7 components wrapping react-aria-components)`, the app's own `components/ui` is still read, and `init` writes the glob it found. Radix's icons (`@radix-ui/react-icons`) no longer count as a primitives library, for a flat `src/` too: five of an app's own components that each show a Radix icon were taken for a design system, and `check` skipped them as its files. The Claude Code hook's check of a monorepo root reads the same list.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed3fe063..752fd5b5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,10 @@ jobs:
- run: pnpm lint
- run: pnpm format:check
- run: pnpm typecheck
+ # The root tsconfig leaves the examples out: each has its own, so a dependency update that
+ # breaks one fails here.
+ - name: Typecheck the examples
+ run: pnpm --filter "./examples/*" typecheck
- run: pnpm build
- name: Smoke test the built server over stdio
run: pnpm smoke
diff --git a/docs/configuration.md b/docs/configuration.md
index 0efd8753..1fd0ff5b 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -9,7 +9,7 @@ Without a config file of its own, a project first takes on the config its design
1. **`components.json`** (shadcn/ui): the `ui` alias, resolved through tsconfig `paths` (`@/registry/new-york-v4/ui`) or a workspace package's `exports` (`@workspace/ui/components` in shadcn's monorepo templates), and `tailwind.css` for tokens. The folders `npx shadcn add` fills from the other registries it names are read too, as read-only design systems ([code from shadcn registries](#code-from-shadcn-registries)).
2. **The root is a design-system package** (when it has no `components.json`): its `package.json` `exports` lead to three or more component files (`"./button": "./src/components/button.tsx"` or `"./components/*": "./src/components/*.tsx"`). An export that points at a barrel (`"./components/button": "./components/button/index.ts"`, as `@calcom/ui` has them) stands for the `.tsx` files the barrel re-exports, through `export *`, `export { … } from` and imported bindings exported again. An export built to `dist/` is read from its source: the `entry` of a tsup, tsdown or Vite config (read as text, never run), else the same path under `src/`, and `.` from `src/index.*`, so Dub's `@dub/ui` (`"import": "./dist/index.mjs"`) is read from `src/index.tsx` and what it re-exports. Exported stylesheets that exist are read as tokens, else `src/globals.css` and the like.
3. **A dependency named like a design system** (`@acme/ui`, `@acme/ui-kit`, `@acme/design-system`, `acme-ui`) that resolves to workspace sources, through a `node_modules` link or the workspace's package globs (pnpm, npm, Yarn and Bun). It is read the same way, or through its own `components.json`, and added to what `components.json` found: Cal.com's app has `@coss/ui` in its `components.json` and imports `@calcom/ui` too, and both are read. Without a `components.json`, the app's own `components/ui` is kept alongside it. A package without `exports` that apps import by path, as Documenso imports `@documenso/ui/primitives/button`, is read from the files the app's code imports, each suggested with the specifier it uses, with a stylesheet imported from it as the theme.
-4. **A flat `src/` of components** that wrap a primitives library, the way React Aria's Tailwind starter ships them (`src/Button.tsx`, `src/Checkbox.tsx`). It takes all of: a dependency on `react-aria-components`, `react-aria`, Radix, Base UI, Headless UI or Ark UI; no app entry (`main.tsx`, `App.tsx`, `index.tsx`) or `app`, `pages` or `routes` folder in `src/`; and at least five PascalCase `.tsx` files at its top level, four in five of which import that library. An app's `src/` of pages and features does not qualify. A stylesheet such as `src/index.css` is read as the theme.
+4. **A flat `src/` of components** that wrap a primitives library, the way React Aria's Tailwind starter ships them (`src/Button.tsx`, `src/Checkbox.tsx`). It takes all of: a dependency on `react-aria-components`, `react-aria`, Radix's primitives (not its icons, `@radix-ui/react-icons`), Base UI, Headless UI or Ark UI; no app entry (`main.tsx`, `App.tsx`, `index.tsx`) or `app`, `pages` or `routes` folder in `src/`; and at least five PascalCase `.tsx` files at its top level, four in five of which import that library. An app's `src/` of pages and features does not qualify. A stylesheet such as `src/index.css` is read as the theme. The same components copied into an app, as React Aria's getting-started page suggests, are found the same way in the app's `src/components/`, `components/` or `app/components/`, with its `App.tsx` beside them (`detected src/components/ (7 components wrapping react-aria-components)`). The app's own `components/ui` is still read, and its stylesheets are the theme ([example](https://github.com/dgesteves/onsystem/tree/main/examples/react-aria)).
With more than one design system, the one the app's code imports most is the primary: its components win where two share a name (`Button`) and are the ones suggested for native elements, while the others are still known, so an import from either is checked against its own component. `inspect` shows them on its `detected` line, with their import counts: `primary: workspace package @calcom/ui (993 imports); also: components.json (ui: @coss/ui/components → …) (140 imports)`.
diff --git a/examples/react-aria/.gitignore b/examples/react-aria/.gitignore
new file mode 100644
index 00000000..3c3629e6
--- /dev/null
+++ b/examples/react-aria/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/examples/react-aria/LICENSE-react-aria b/examples/react-aria/LICENSE-react-aria
new file mode 100644
index 00000000..e3af6041
--- /dev/null
+++ b/examples/react-aria/LICENSE-react-aria
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2019 Adobe
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/examples/react-aria/README.md b/examples/react-aria/README.md
new file mode 100644
index 00000000..61cb9899
--- /dev/null
+++ b/examples/react-aria/README.md
@@ -0,0 +1,37 @@
+# react-aria
+
+An app with React Aria Components in `src/components/`, next to its `src/App.tsx`, where they land when you copy React Aria's [Tailwind starter](https://react-aria.adobe.com/getting-started) into an app. onsystem finds them with no config: a flat folder of PascalCase components that wrap `react-aria-components`. The components and their helpers in `src/components/` are adapted from the [React Aria Tailwind starter](https://github.com/adobe/react-spectrum/tree/main/starters/tailwind) by Adobe ([Apache-2.0](LICENSE-react-aria)), changed for this example: fewer components, styled with the tokens in `src/index.css`.
+
+| Path | What it is |
+| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/components/*.tsx` | `Button`, `Checkbox`, `Switch`, `TextField`, `Modal`, `Dialog` with its `DialogTitle`, and the field parts in `Field.tsx` (`Label`, `Input`, `Description`, `FieldError`) |
+| `src/index.css` | Tailwind v4 tokens in `@theme`: `bg-primary`, `text-muted-foreground`, `border-border` |
+| `src/App.tsx` | UI written against the design system. `check` reports nothing. |
+| `src/InviteForm.tsx` | UI written without it, with HTML's prop names where React Aria has its own (`checked` for `isSelected`). `check` reports 7 problems. |
+
+## Try it
+
+Copy the folder, install it and run onsystem, with Node.js 20.19 or later:
+
+```sh
+npx degit dgesteves/onsystem/examples/react-aria onsystem-react-aria
+cd onsystem-react-aria
+npm install
+npm run inspect # what onsystem read: components, props and tokens
+npm run check # check the pages (exits 1: InviteForm.tsx has errors)
+```
+
+The scripts run the latest onsystem release with npx. Finding the components in `src/components/` with no config needs onsystem 0.11.1 or later: `inspect` then prints `config (defaults)` and `detected src/components/ (7 components wrapping react-aria-components)`, and `check` reports the problems the table lists. An earlier release finds no components here, so `check` runs without the component rules. Install first: the props come from React Aria's types, so without them `check` cannot tell that `checked` should be `isSelected`.
+
+To have an agent write UI against it, set it up as the [shadcn-demo](https://github.com/dgesteves/onsystem/tree/main/examples/shadcn-demo#try-it) describes, from this folder.
+
+## Working on onsystem
+
+From the repository root, to run this checkout's build on the example:
+
+```sh
+pnpm install && pnpm build
+cd examples/react-aria
+node ../../dist/cli.js inspect
+node ../../dist/cli.js check . # exits 1: InviteForm.tsx has errors
+```
diff --git a/examples/react-aria/package.json b/examples/react-aria/package.json
new file mode 100644
index 00000000..35e3e577
--- /dev/null
+++ b/examples/react-aria/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "react-aria",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "React Aria Components, the way its Tailwind starter writes them, copied into an app's src/components, used to demo onsystem.",
+ "scripts": {
+ "inspect": "npx -y onsystem inspect",
+ "check": "npx -y onsystem check .",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "react": "^19.3.0",
+ "react-aria-components": "^1.20.0",
+ "react-dom": "^19.3.0",
+ "tailwind-merge": "^3.7.0",
+ "tailwind-variants": "^3.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~6.0.3"
+ }
+}
diff --git a/examples/react-aria/src/App.tsx b/examples/react-aria/src/App.tsx
new file mode 100644
index 00000000..1919f2b4
--- /dev/null
+++ b/examples/react-aria/src/App.tsx
@@ -0,0 +1,46 @@
+// UI written against the design system: check reports nothing.
+import { DialogTrigger } from "react-aria-components"
+
+import { Button } from "./components/Button"
+import { Checkbox } from "./components/Checkbox"
+import { Dialog, DialogTitle } from "./components/Dialog"
+import { Modal } from "./components/Modal"
+import { Switch } from "./components/Switch"
+import { TextField } from "./components/TextField"
+
+export function App() {
+ return (
+
+ )
+}
diff --git a/examples/react-aria/src/InviteForm.tsx b/examples/react-aria/src/InviteForm.tsx
new file mode 100644
index 00000000..6747e1a8
--- /dev/null
+++ b/examples/react-aria/src/InviteForm.tsx
@@ -0,0 +1,25 @@
+// What a coding agent wrote before it could see the design system.
+// `npm run check` (or the check_ui tool) reports every problem below.
+import { Button } from "./components/Button"
+import { Checkbox } from "./components/Checkbox"
+import { Switch } from "./components/Switch"
+import { TextField } from "./components/TextField"
+
+export function InviteForm({ sending }: { sending: boolean }) {
+ return (
+
+ )
+}
diff --git a/examples/react-aria/src/components/Button.tsx b/examples/react-aria/src/components/Button.tsx
new file mode 100644
index 00000000..98c7286e
--- /dev/null
+++ b/examples/react-aria/src/components/Button.tsx
@@ -0,0 +1,51 @@
+"use client"
+// Adapted from React Aria's Tailwind starter (Apache-2.0, see ../../LICENSE-react-aria), changed for this example.
+import {
+ Button as RACButton,
+ composeRenderProps,
+ type ButtonProps as RACButtonProps,
+} from "react-aria-components"
+import { tv } from "tailwind-variants"
+
+import { focusRing } from "./utils"
+
+export interface ButtonProps extends RACButtonProps {
+ variant?: "primary" | "secondary" | "destructive" | "quiet"
+}
+
+const button = tv({
+ extend: focusRing,
+ base: "cursor-default rounded-lg px-5 py-2 text-center text-sm transition",
+ variants: {
+ variant: {
+ primary: "bg-primary text-primary-foreground hover:bg-primary-hover",
+ secondary:
+ "border border-border bg-background text-foreground hover:bg-muted",
+ destructive:
+ "bg-destructive text-primary-foreground hover:bg-destructive-hover",
+ quiet: "bg-transparent text-foreground hover:bg-muted",
+ },
+ isDisabled: {
+ true: "opacity-50",
+ },
+ },
+ defaultVariants: {
+ variant: "primary",
+ },
+})
+
+/**
+ * Triggers an action. Use `variant="destructive"` for irreversible actions,
+ * and `isDisabled` and `onPress` (React Aria's names) rather than `disabled`
+ * and `onClick`.
+ */
+export function Button(props: ButtonProps) {
+ return (
+
+ button({ ...renderProps, variant: props.variant, className })
+ )}
+ />
+ )
+}
diff --git a/examples/react-aria/src/components/Checkbox.tsx b/examples/react-aria/src/components/Checkbox.tsx
new file mode 100644
index 00000000..8c62eb0f
--- /dev/null
+++ b/examples/react-aria/src/components/Checkbox.tsx
@@ -0,0 +1,67 @@
+"use client"
+// Adapted from React Aria's Tailwind starter (Apache-2.0, see ../../LICENSE-react-aria), changed for this example.
+import {
+ Checkbox as RACCheckbox,
+ composeRenderProps,
+ type CheckboxProps,
+} from "react-aria-components"
+import { tv } from "tailwind-variants"
+
+import { composeTailwindRenderProps, focusRing } from "./utils"
+
+const box = tv({
+ extend: focusRing,
+ base: "flex size-5 shrink-0 items-center justify-center rounded border-2 transition",
+ variants: {
+ isSelected: {
+ false: "border-border bg-background",
+ true: "border-transparent bg-primary text-primary-foreground",
+ },
+ isInvalid: {
+ true: "border-destructive",
+ },
+ isDisabled: {
+ true: "opacity-50",
+ },
+ },
+})
+
+/** A checkbox with its label. Use `isSelected` or `defaultSelected`, not `checked`. */
+export function Checkbox(props: CheckboxProps) {
+ return (
+
+ {composeRenderProps(
+ props.children,
+ (children, { isSelected, isInvalid, isDisabled, isFocusVisible }) => (
+ <>
+
+ {isSelected && (
+
+
+
+ )}
+
+ {children}
+ >
+ )
+ )}
+
+ )
+}
diff --git a/examples/react-aria/src/components/Dialog.tsx b/examples/react-aria/src/components/Dialog.tsx
new file mode 100644
index 00000000..1da208e8
--- /dev/null
+++ b/examples/react-aria/src/components/Dialog.tsx
@@ -0,0 +1,33 @@
+"use client"
+// Adapted from React Aria's Tailwind starter (Apache-2.0, see ../../LICENSE-react-aria), changed for this example.
+import {
+ Dialog as RACDialog,
+ Heading,
+ type DialogProps,
+ type HeadingProps,
+} from "react-aria-components"
+import { twMerge } from "tailwind-merge"
+
+/** A dialog's content, inside a `` and a ``. */
+export function Dialog(props: DialogProps) {
+ return (
+
+ )
+}
+
+/** The dialog's title, which also names it for assistive technology. */
+export function DialogTitle(props: HeadingProps) {
+ return (
+
+ )
+}
diff --git a/examples/react-aria/src/components/Field.tsx b/examples/react-aria/src/components/Field.tsx
new file mode 100644
index 00000000..5cbc345c
--- /dev/null
+++ b/examples/react-aria/src/components/Field.tsx
@@ -0,0 +1,75 @@
+"use client"
+// Adapted from React Aria's Tailwind starter (Apache-2.0, see ../../LICENSE-react-aria), changed for this example.
+import {
+ composeRenderProps,
+ FieldError as RACFieldError,
+ Input as RACInput,
+ Label as RACLabel,
+ Text,
+ type FieldErrorProps,
+ type InputProps,
+ type LabelProps,
+ type TextProps,
+} from "react-aria-components"
+import { twMerge } from "tailwind-merge"
+import { tv } from "tailwind-variants"
+
+import { composeTailwindRenderProps, focusRing } from "./utils"
+
+export function Label(props: LabelProps) {
+ return (
+
+ )
+}
+
+export function Description(props: TextProps) {
+ return (
+
+ )
+}
+
+export function FieldError(props: FieldErrorProps) {
+ return (
+
+ )
+}
+
+const input = tv({
+ extend: focusRing,
+ base: "rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground",
+ variants: {
+ isInvalid: {
+ true: "border-destructive",
+ },
+ isDisabled: {
+ true: "opacity-50",
+ },
+ },
+})
+
+export function Input(props: InputProps) {
+ return (
+
+ input({ ...renderProps, className })
+ )}
+ />
+ )
+}
diff --git a/examples/react-aria/src/components/Modal.tsx b/examples/react-aria/src/components/Modal.tsx
new file mode 100644
index 00000000..95b087a3
--- /dev/null
+++ b/examples/react-aria/src/components/Modal.tsx
@@ -0,0 +1,22 @@
+"use client"
+// Adapted from React Aria's Tailwind starter (Apache-2.0, see ../../LICENSE-react-aria), changed for this example.
+import {
+ ModalOverlay,
+ Modal as RACModal,
+ type ModalOverlayProps,
+} from "react-aria-components"
+
+/** The overlay a `` opens in. */
+export function Modal(props: ModalOverlayProps) {
+ return (
+
+
+
+ )
+}
diff --git a/examples/react-aria/src/components/Switch.tsx b/examples/react-aria/src/components/Switch.tsx
new file mode 100644
index 00000000..787a0cf4
--- /dev/null
+++ b/examples/react-aria/src/components/Switch.tsx
@@ -0,0 +1,51 @@
+"use client"
+// Adapted from React Aria's Tailwind starter (Apache-2.0, see ../../LICENSE-react-aria), changed for this example.
+import {
+ composeRenderProps,
+ Switch as RACSwitch,
+ type SwitchProps,
+} from "react-aria-components"
+import { tv } from "tailwind-variants"
+
+import { composeTailwindRenderProps, focusRing } from "./utils"
+
+const track = tv({
+ extend: focusRing,
+ base: "flex h-5 w-9 shrink-0 cursor-default items-center rounded-full px-0.5 transition",
+ variants: {
+ isSelected: {
+ false: "bg-border",
+ true: "bg-primary",
+ },
+ isDisabled: {
+ true: "opacity-50",
+ },
+ },
+})
+
+/** An on/off setting that applies at once. Use `isSelected`, not `checked`. */
+export function Switch(props: SwitchProps) {
+ return (
+
+ {composeRenderProps(
+ props.children,
+ (children, { isSelected, isDisabled, isFocusVisible }) => (
+ <>
+
+
+
+ {children}
+ >
+ )
+ )}
+
+ )
+}
diff --git a/examples/react-aria/src/components/TextField.tsx b/examples/react-aria/src/components/TextField.tsx
new file mode 100644
index 00000000..06c384f3
--- /dev/null
+++ b/examples/react-aria/src/components/TextField.tsx
@@ -0,0 +1,44 @@
+"use client"
+// Adapted from React Aria's Tailwind starter (Apache-2.0, see ../../LICENSE-react-aria), changed for this example.
+import {
+ TextField as RACTextField,
+ type TextFieldProps as RACTextFieldProps,
+ type ValidationResult,
+} from "react-aria-components"
+
+import { Description, FieldError, Input, Label } from "./Field"
+import { composeTailwindRenderProps } from "./utils"
+
+export interface TextFieldProps extends RACTextFieldProps {
+ label?: string
+ description?: string
+ errorMessage?: string | ((validation: ValidationResult) => string)
+ placeholder?: string
+}
+
+/**
+ * A labelled text input. Use `isRequired` and `isDisabled` (React Aria's
+ * names), not `required` and `disabled`.
+ */
+export function TextField({
+ label,
+ description,
+ errorMessage,
+ placeholder,
+ ...props
+}: TextFieldProps) {
+ return (
+
+ {label && {label} }
+
+ {description && {description} }
+ {errorMessage}
+
+ )
+}
diff --git a/examples/react-aria/src/components/utils.ts b/examples/react-aria/src/components/utils.ts
new file mode 100644
index 00000000..01433194
--- /dev/null
+++ b/examples/react-aria/src/components/utils.ts
@@ -0,0 +1,23 @@
+// Adapted from React Aria's Tailwind starter (Apache-2.0, see ../../LICENSE-react-aria), changed for this example.
+import { composeRenderProps } from "react-aria-components"
+import { twMerge } from "tailwind-merge"
+import { tv } from "tailwind-variants"
+
+/** The focus ring every focusable component shows for keyboard focus. */
+export const focusRing = tv({
+ base: "outline outline-offset-2 outline-primary",
+ variants: {
+ isFocusVisible: {
+ false: "outline-0",
+ true: "outline-2",
+ },
+ },
+})
+
+/** A component's own classes, merged with the `className` given to it. */
+export function composeTailwindRenderProps(
+ className: string | ((v: T) => string) | undefined,
+ tw: string
+): string | ((v: T) => string) {
+ return composeRenderProps(className, (className) => twMerge(tw, className))
+}
diff --git a/examples/react-aria/src/index.css b/examples/react-aria/src/index.css
new file mode 100644
index 00000000..b48fb019
--- /dev/null
+++ b/examples/react-aria/src/index.css
@@ -0,0 +1,22 @@
+@import "tailwindcss";
+
+/* The app's tokens, next to Tailwind's: bg-primary, text-muted-foreground, border-border, ... */
+@theme {
+ --color-background: oklch(1 0 0);
+ --color-foreground: oklch(0.21 0.034 264.665);
+ --color-muted: oklch(0.967 0.003 264.542);
+ --color-muted-foreground: oklch(0.446 0.03 256.802);
+ --color-border: oklch(0.872 0.01 258.338);
+ --color-primary: oklch(0.546 0.245 262.881);
+ --color-primary-hover: oklch(0.488 0.243 264.376);
+ --color-primary-foreground: oklch(1 0 0);
+ /* Errors and irreversible actions. */
+ --color-destructive: oklch(0.577 0.245 27.325);
+ --color-destructive-hover: oklch(0.505 0.213 27.518);
+}
+
+@layer base {
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/examples/react-aria/src/main.tsx b/examples/react-aria/src/main.tsx
new file mode 100644
index 00000000..63905315
--- /dev/null
+++ b/examples/react-aria/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react"
+import { createRoot } from "react-dom/client"
+
+import { App } from "./App"
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+)
diff --git a/examples/react-aria/tsconfig.json b/examples/react-aria/tsconfig.json
new file mode 100644
index 00000000..8b80e917
--- /dev/null
+++ b/examples/react-aria/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src"],
+ "exclude": ["src/InviteForm.tsx"]
+}
diff --git a/examples/shadcn-demo/.gitignore b/examples/shadcn-demo/.gitignore
new file mode 100644
index 00000000..3c3629e6
--- /dev/null
+++ b/examples/shadcn-demo/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/examples/shadcn-demo/LICENSE-shadcn b/examples/shadcn-demo/LICENSE-shadcn
new file mode 100644
index 00000000..fad4d887
--- /dev/null
+++ b/examples/shadcn-demo/LICENSE-shadcn
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 shadcn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/examples/shadcn-demo/README.md b/examples/shadcn-demo/README.md
index 8abae540..f54285e8 100644
--- a/examples/shadcn-demo/README.md
+++ b/examples/shadcn-demo/README.md
@@ -2,14 +2,16 @@
A small shadcn/ui-style design system to try `onsystem` on:
-| Path | What it is |
-| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
-| `components/ui/*.tsx` | `Button`, `Badge`, `Card`, `Dialog`, `Input`, built with `cva` and Radix (adapted from [shadcn/ui](https://ui.shadcn.com), MIT) |
-| `app/globals.css` | Tailwind v4 tokens: `:root` / `.dark` custom properties mapped through `@theme inline` |
-| `docs/*.md` | Usage guidelines and examples, one file per component |
-| `app/settings/members.tsx` | UI written against the design system. `check` reports nothing. |
-| `app/settings/danger-zone.tsx` | UI written without it. `check` reports one problem per rule. |
-| `onsystem.config.json` | The config that ties it together |
+| Path | What it is |
+| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `components.json` | shadcn/ui's config, as `npx shadcn init` writes it |
+| `components/ui/*.tsx` | `Button`, `Badge`, `Card`, `Dialog`, `Input`, built with `cva` and Radix (adapted from [shadcn/ui](https://ui.shadcn.com), [MIT](LICENSE-shadcn)) |
+| `app/globals.css` | Tailwind v4 tokens: `:root` / `.dark` custom properties mapped through `@theme inline` |
+| `docs/components/*.md` | Usage guidelines and examples, one file per component |
+| `app/settings/members.tsx` | UI written against the design system. `check` reports nothing. |
+| `app/settings/danger-zone.tsx` | UI written without it. `check` reports 11 problems, at least one for each of onsystem's 8 rules. |
+
+There is no onsystem config: like any shadcn/ui project, it needs none. `inspect` prints `config (defaults)` and finds the components through `components.json`, and the docs in `docs/components/`, where it looks by default.
## Try it
diff --git a/examples/shadcn-demo/components.json b/examples/shadcn-demo/components.json
new file mode 100644
index 00000000..4ee62ee1
--- /dev/null
+++ b/examples/shadcn-demo/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/examples/shadcn-demo/docs/badge.md b/examples/shadcn-demo/docs/components/badge.md
similarity index 100%
rename from examples/shadcn-demo/docs/badge.md
rename to examples/shadcn-demo/docs/components/badge.md
diff --git a/examples/shadcn-demo/docs/button.md b/examples/shadcn-demo/docs/components/button.md
similarity index 100%
rename from examples/shadcn-demo/docs/button.md
rename to examples/shadcn-demo/docs/components/button.md
diff --git a/examples/shadcn-demo/docs/card.md b/examples/shadcn-demo/docs/components/card.md
similarity index 100%
rename from examples/shadcn-demo/docs/card.md
rename to examples/shadcn-demo/docs/components/card.md
diff --git a/examples/shadcn-demo/docs/dialog.md b/examples/shadcn-demo/docs/components/dialog.md
similarity index 100%
rename from examples/shadcn-demo/docs/dialog.md
rename to examples/shadcn-demo/docs/components/dialog.md
diff --git a/examples/shadcn-demo/docs/input.md b/examples/shadcn-demo/docs/components/input.md
similarity index 100%
rename from examples/shadcn-demo/docs/input.md
rename to examples/shadcn-demo/docs/components/input.md
diff --git a/examples/shadcn-demo/onsystem.config.json b/examples/shadcn-demo/onsystem.config.json
deleted file mode 100644
index e45a817a..00000000
--- a/examples/shadcn-demo/onsystem.config.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "$schema": "https://unpkg.com/onsystem/schema.json",
- "components": ["components/ui/**/*.tsx"],
- "tokens": ["app/globals.css"],
- "docs": ["docs/**/*.md"]
-}
diff --git a/examples/tailwind-v3/.gitignore b/examples/tailwind-v3/.gitignore
new file mode 100644
index 00000000..3c3629e6
--- /dev/null
+++ b/examples/tailwind-v3/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/examples/tailwind-v3/LICENSE-shadcn b/examples/tailwind-v3/LICENSE-shadcn
new file mode 100644
index 00000000..fad4d887
--- /dev/null
+++ b/examples/tailwind-v3/LICENSE-shadcn
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 shadcn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/examples/tailwind-v3/README.md b/examples/tailwind-v3/README.md
new file mode 100644
index 00000000..42576f8b
--- /dev/null
+++ b/examples/tailwind-v3/README.md
@@ -0,0 +1,41 @@
+# tailwind-v3
+
+shadcn/ui on Tailwind v3, the way projects were set up before Tailwind v4: the tokens are bare HSL channels in `app/globals.css`, and `tailwind.config.ts` turns them into classes (`bg-primary` is `hsl(var(--primary))`). onsystem reads both, with no config of its own, and reads the Tailwind config as text without running it.
+
+| Path | What it is |
+| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `components.json` | shadcn/ui's config, which names `tailwind.config.ts` and `app/globals.css` |
+| `components/ui/*.tsx` | `Button`, `Badge`, `Card`, `Input`, built with `cva` and `forwardRef` as shadcn/ui wrote them for Tailwind v3 (adapted from [shadcn/ui](https://ui.shadcn.com), [MIT](LICENSE-shadcn)) |
+| `app/globals.css` | `:root` / `.dark` custom properties that hold HSL channels (`--muted-foreground: 240 3.8% 46.1%`) |
+| `tailwind.config.ts` | The `colors` and `borderRadius` that turn them into classes |
+| `app/account/profile.tsx` | UI written against the design system. `check` reports nothing. |
+| `app/account/delete-account.tsx` | UI written without it. `check` reports 9 problems. |
+
+## Try it
+
+Copy the folder, install it and run onsystem, with Node.js 20.19 or later:
+
+```sh
+npx degit dgesteves/onsystem/examples/tailwind-v3 onsystem-tailwind-v3
+cd onsystem-tailwind-v3
+npm install
+npm run inspect # what onsystem read: components, variants and the v3 tokens
+npm run check # check the pages (exits 1: delete-account.tsx has errors)
+```
+
+The scripts run the latest onsystem release with npx. `inspect` prints `config (defaults)` and finds the components through `components.json`. The fixes use the class names from `tailwind.config.ts`: `text-[#71717a]` is the color of `--muted-foreground`, so `check` suggests `text-muted-foreground`.
+
+`no-hardcoded-spacing` does not run here: it needs a spacing scale, which onsystem reads from a Tailwind v4 stylesheet or from spacing tokens, and this project has neither. The other rules run as on Tailwind v4.
+
+To have an agent write UI against it, set it up as the [shadcn-demo](https://github.com/dgesteves/onsystem/tree/main/examples/shadcn-demo#try-it) describes, from this folder.
+
+## Working on onsystem
+
+From the repository root, to run this checkout's build on the example:
+
+```sh
+pnpm install && pnpm build
+cd examples/tailwind-v3
+node ../../dist/cli.js inspect
+node ../../dist/cli.js check . # exits 1: delete-account.tsx has errors
+```
diff --git a/examples/tailwind-v3/app/account/delete-account.tsx b/examples/tailwind-v3/app/account/delete-account.tsx
new file mode 100644
index 00000000..e0e1c5c1
--- /dev/null
+++ b/examples/tailwind-v3/app/account/delete-account.tsx
@@ -0,0 +1,31 @@
+// What a coding agent wrote before it could see the design system.
+// `npm run check` (or the check_ui tool) reports every problem below.
+import { Trash2 } from "lucide-react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+
+export function DeleteAccount({ onDelete }: { onDelete: () => void }) {
+ return (
+
+
+ Delete account
+ This cannot be undone.
+
+
+ Irreversible
+
+ Delete account
+
+
+
+
+
+ Cancel
+
+ Owners only.
+
+
+ )
+}
diff --git a/examples/tailwind-v3/app/account/profile.tsx b/examples/tailwind-v3/app/account/profile.tsx
new file mode 100644
index 00000000..ac8efa88
--- /dev/null
+++ b/examples/tailwind-v3/app/account/profile.tsx
@@ -0,0 +1,36 @@
+// The same kind of UI written against the design system: check reports nothing.
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+
+export function Profile() {
+ return (
+
+
+ Profile
+ How other people see you.
+
+
+
+ Name
+
+
+
+ Your plan: Pro
+
+
+
+ Cancel
+ Save
+
+
+ )
+}
diff --git a/examples/tailwind-v3/app/globals.css b/examples/tailwind-v3/app/globals.css
new file mode 100644
index 00000000..4fe6a3bd
--- /dev/null
+++ b/examples/tailwind-v3/app/globals.css
@@ -0,0 +1,57 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Tailwind v3 tokens: bare HSL channels, used as hsl(var(--primary)) in tailwind.config.ts */
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 240 10% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 240 10% 3.9%;
+ --primary: 240 5.9% 10%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 240 4.8% 95.9%;
+ --secondary-foreground: 240 5.9% 10%;
+ --muted: 240 4.8% 95.9%;
+ --muted-foreground: 240 3.8% 46.1%;
+ --accent: 240 4.8% 95.9%;
+ --accent-foreground: 240 5.9% 10%;
+ /* Errors and irreversible actions. */
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 5.9% 90%;
+ --input: 240 5.9% 90%;
+ --ring: 240 10% 3.9%;
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 240 10% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 240 10% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 240 5.9% 10%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 240 3.7% 15.9%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 240 3.7% 15.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 240 4.9% 83.9%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/examples/tailwind-v3/components.json b/examples/tailwind-v3/components.json
new file mode 100644
index 00000000..ff940cac
--- /dev/null
+++ b/examples/tailwind-v3/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/examples/tailwind-v3/components/ui/badge.tsx b/examples/tailwind-v3/components/ui/badge.tsx
new file mode 100644
index 00000000..1d6edf33
--- /dev/null
+++ b/examples/tailwind-v3/components/ui/badge.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends
+ React.HTMLAttributes,
+ VariantProps {}
+
+/** A short status or count next to a label. */
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/examples/tailwind-v3/components/ui/button.tsx b/examples/tailwind-v3/components/ui/button.tsx
new file mode 100644
index 00000000..2344d9f4
--- /dev/null
+++ b/examples/tailwind-v3/components/ui/button.tsx
@@ -0,0 +1,62 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends
+ React.ButtonHTMLAttributes,
+ VariantProps {
+ /** Render the child element (e.g. a ` `) with button styles instead of a ``. */
+ asChild?: boolean
+}
+
+/**
+ * Triggers an action, such as saving a form or opening a dialog. Use
+ * `variant="destructive"` for irreversible actions.
+ */
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/examples/tailwind-v3/components/ui/card.tsx b/examples/tailwind-v3/components/ui/card.tsx
new file mode 100644
index 00000000..c5cbfec2
--- /dev/null
+++ b/examples/tailwind-v3/components/ui/card.tsx
@@ -0,0 +1,80 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+/** A surface that groups related content, built from flat parts. */
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/examples/tailwind-v3/components/ui/input.tsx b/examples/tailwind-v3/components/ui/input.tsx
new file mode 100644
index 00000000..3e43fee2
--- /dev/null
+++ b/examples/tailwind-v3/components/ui/input.tsx
@@ -0,0 +1,23 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+/** A single-line text field. Pair it with a ``. */
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/examples/tailwind-v3/lib/utils.ts b/examples/tailwind-v3/lib/utils.ts
new file mode 100644
index 00000000..bd0c391d
--- /dev/null
+++ b/examples/tailwind-v3/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/examples/tailwind-v3/package.json b/examples/tailwind-v3/package.json
new file mode 100644
index 00000000..72698eab
--- /dev/null
+++ b/examples/tailwind-v3/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "tailwind-v3",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "shadcn/ui on Tailwind v3 (HSL channel tokens and a tailwind.config.ts) used to demo onsystem.",
+ "scripts": {
+ "inspect": "npx -y onsystem inspect",
+ "check": "npx -y onsystem check .",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@radix-ui/react-slot": "^1.2.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^1.40.0",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "tailwind-merge": "^2.6.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "tailwindcss": "^3.4.17",
+ "typescript": "~6.0.3"
+ }
+}
diff --git a/examples/tailwind-v3/tailwind.config.ts b/examples/tailwind-v3/tailwind.config.ts
new file mode 100644
index 00000000..f9f33962
--- /dev/null
+++ b/examples/tailwind-v3/tailwind.config.ts
@@ -0,0 +1,49 @@
+import type { Config } from "tailwindcss"
+
+const config = {
+ darkMode: ["class"],
+ content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config
+
+export default config
diff --git a/examples/tailwind-v3/tsconfig.json b/examples/tailwind-v3/tsconfig.json
new file mode 100644
index 00000000..22e34e0e
--- /dev/null
+++ b/examples/tailwind-v3/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules", "app/account/delete-account.tsx"]
+}
diff --git a/plugins/onsystem/hooks/check-ui.mjs b/plugins/onsystem/hooks/check-ui.mjs
index 588e17d4..d31b834e 100644
--- a/plugins/onsystem/hooks/check-ui.mjs
+++ b/plugins/onsystem/hooks/check-ui.mjs
@@ -96,7 +96,7 @@ const BASELINE_FILES = ['onsystem.baseline.json', 'design-system-mcp.baseline.js
const DEFAULT_COMPONENT_DIRS = ['components/ui', 'src/components/ui'];
// The primitives libraries a flat src/ of components wraps (PRIMITIVES in src/detect.ts).
const PRIMITIVES =
- /^(?:react-aria-components|react-aria|radix-ui|@radix-ui\/react-[\w-]+|@base-ui-components\/react|@base-ui\/react|@headlessui\/react|@ark-ui\/react)(?:\/|$)/;
+ /^(?:react-aria-components|react-aria|radix-ui|@radix-ui\/react-(?!icons(?:\/|$))[\w-]+|@base-ui-components\/react|@base-ui\/react|@headlessui\/react|@ark-ui\/react)(?:\/|$)/;
const BIN = process.env.ONSYSTEM_BIN || process.env.DESIGN_SYSTEM_MCP_BIN || undefined;
const input = await readInput();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a9487264..49dc0980 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -106,6 +106,37 @@ importers:
specifier: ^5.0.5
version: 5.0.5(@types/node@24.19.2)(@vitest/coverage-istanbul@5.0.5)(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)(yaml@2.9.1))
+ examples/react-aria:
+ dependencies:
+ react:
+ specifier: ^19.3.0
+ version: 19.3.1
+ react-aria-components:
+ specifier: ^1.20.0
+ version: 1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
+ react-dom:
+ specifier: ^19.3.0
+ version: 19.3.1(react@19.3.1)
+ tailwind-merge:
+ specifier: ^3.7.0
+ version: 3.7.0
+ tailwind-variants:
+ specifier: ^3.3.1
+ version: 3.3.1(tailwind-merge@3.7.0)(tailwindcss@4.3.3)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.3.0
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.3.0
+ version: 19.3.0(@types/react@19.3.0)
+ tailwindcss:
+ specifier: ^4.3.3
+ version: 4.3.3
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+
examples/shadcn-demo:
dependencies:
'@radix-ui/react-dialog':
@@ -149,6 +180,43 @@ importers:
specifier: ~6.0.3
version: 6.0.3
+ examples/tailwind-v3:
+ dependencies:
+ '@radix-ui/react-slot':
+ specifier: ^1.2.3
+ version: 1.5.0(@types/react@19.3.0)(react@19.3.1)
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ lucide-react:
+ specifier: ^1.40.0
+ version: 1.59.0(@types/react@19.3.0)(react@19.3.1)
+ react:
+ specifier: ^19.3.0
+ version: 19.3.1
+ react-dom:
+ specifier: ^19.3.0
+ version: 19.3.1(react@19.3.1)
+ tailwind-merge:
+ specifier: ^2.6.0
+ version: 2.6.1
+ devDependencies:
+ '@types/react':
+ specifier: ^19.3.0
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.3.0
+ version: 19.3.0(@types/react@19.3.0)
+ tailwindcss:
+ specifier: ^3.4.17
+ version: 3.4.19(yaml@2.9.1)
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+
site:
dependencies:
'@ai-sdk/openai':
@@ -1592,6 +1660,16 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1658,6 +1736,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ binary-extensions@2.3.0:
+ resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+ engines: {node: '>=8'}
+
body-parser@2.4.0:
resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==}
engines: {node: '>=18'}
@@ -1709,6 +1791,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ camelcase-css@2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+
caniuse-lite@1.0.30001816:
resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==}
@@ -1729,6 +1815,10 @@ packages:
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+ chokidar@3.6.0:
+ resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+ engines: {node: '>= 8.10.0'}
+
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1755,6 +1845,10 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+ commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+ engines: {node: '>= 6'}
+
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1789,6 +1883,11 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -1855,6 +1954,12 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+ didyoumean@1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+ dlv@1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
dts-resolver@3.0.0:
resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==}
engines: {node: ^22.18.0 || >=24.0.0}
@@ -2054,6 +2159,10 @@ packages:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -2328,6 +2437,10 @@ packages:
resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
engines: {node: '>= 0.4'}
+ is-binary-path@2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+
is-boolean-object@1.2.2:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'}
@@ -2336,6 +2449,10 @@ packages:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
+ is-core-module@2.17.0:
+ resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==}
+ engines: {node: '>= 0.4'}
+
is-data-view@1.0.2:
resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
engines: {node: '>= 0.4'}
@@ -2425,6 +2542,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ jiti@1.21.7:
+ resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+ hasBin: true
+
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -2648,6 +2769,13 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
+ lilconfig@3.1.4:
+ resolution: {integrity: sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==}
+ engines: {node: '>=14'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2736,6 +2864,9 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
nanoid@3.3.20:
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2773,10 +2904,18 @@ packages:
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
engines: {node: '>=18'}
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -2892,6 +3031,9 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
@@ -2906,6 +3048,10 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
+ pirates@4.0.7:
+ resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+ engines: {node: '>= 6'}
+
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -2914,6 +3060,49 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
+ postcss-import@15.1.0:
+ resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+
+ postcss-js@4.1.0:
+ resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+
+ postcss-load-config@6.0.1:
+ resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ jiti: '>=1.21.0'
+ postcss: '>=8.0.9'
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+ postcss:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ postcss-nested@6.2.0:
+ resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-selector-parser@6.1.5:
+ resolution: {integrity: sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==}
+ engines: {node: '>=4'}
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
@@ -3025,6 +3214,13 @@ packages:
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
engines: {node: '>=0.10.0'}
+ read-cache@1.0.2:
+ resolution: {integrity: sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==}
+
+ readdirp@3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -3053,6 +3249,11 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+ resolve@1.22.13:
+ resolution: {integrity: sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -3244,10 +3445,22 @@ packages:
babel-plugin-macros:
optional: true
+ sucrase@3.35.1:
+ resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tailwind-merge@2.6.1:
+ resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
+
tailwind-merge@3.7.0:
resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==}
@@ -3263,6 +3476,11 @@ packages:
tailwindcss:
optional: true
+ tailwindcss@3.4.19:
+ resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3270,6 +3488,13 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
tinybench@6.2.2:
resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==}
engines: {node: '>=20.0.0'}
@@ -3307,6 +3532,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
+ ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
tsdown@0.23.2:
resolution: {integrity: sha512-om1OoDykFlv07uM98WAX3ZNmu02FBmHqZYLW1hm+IMktb92r7VMNKgQ/G8dknECI1Q2huPrFJ+sggdYk8efsQg==}
engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0}
@@ -3450,6 +3678,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -4926,6 +5157,15 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ any-promise@1.3.0: {}
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.2
+
+ arg@5.0.2: {}
+
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -4994,6 +5234,8 @@ snapshots:
baseline-browser-mapping@2.11.29: {}
+ binary-extensions@2.3.0: {}
+
body-parser@2.4.0:
dependencies:
bytes: 3.1.2
@@ -5062,6 +5304,8 @@ snapshots:
callsites@3.1.0: {}
+ camelcase-css@2.0.1: {}
+
caniuse-lite@1.0.30001816: {}
ccount@2.0.1: {}
@@ -5077,6 +5321,18 @@ snapshots:
character-entities-legacy@3.0.0: {}
+ chokidar@3.6.0:
+ dependencies:
+ anymatch: 3.1.3
+ braces: 3.0.3
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -5105,6 +5361,8 @@ snapshots:
comma-separated-tokens@2.0.3: {}
+ commander@4.1.1: {}
+
concat-map@0.0.1: {}
content-disposition@1.1.0: {}
@@ -5130,6 +5388,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ cssesc@3.0.0: {}
+
csstype@3.2.3: {}
culori@4.0.2: {}
@@ -5188,6 +5448,10 @@ snapshots:
dependencies:
dequal: 2.0.3
+ didyoumean@1.2.2: {}
+
+ dlv@1.1.3: {}
+
dts-resolver@3.0.0: {}
dunder-proto@1.0.1:
@@ -5530,6 +5794,14 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
@@ -5804,6 +6076,10 @@ snapshots:
dependencies:
has-bigints: 1.1.0
+ is-binary-path@2.1.0:
+ dependencies:
+ binary-extensions: 2.3.0
+
is-boolean-object@1.2.2:
dependencies:
call-bound: 1.0.4
@@ -5811,6 +6087,10 @@ snapshots:
is-callable@1.2.7: {}
+ is-core-module@2.17.0:
+ dependencies:
+ hasown: 2.0.4
+
is-data-view@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -5900,6 +6180,8 @@ snapshots:
isexe@2.0.0: {}
+ jiti@1.21.7: {}
+
jiti@2.7.0: {}
jju@1.4.0: {}
@@ -6061,6 +6343,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
+ lilconfig@3.1.4: {}
+
+ lines-and-columns@1.2.4: {}
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -6151,6 +6437,12 @@ snapshots:
ms@2.1.3: {}
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
nanoid@3.3.20: {}
natural-compare@1.4.0: {}
@@ -6186,8 +6478,12 @@ snapshots:
node-releases@2.0.57: {}
+ normalize-path@3.0.0: {}
+
object-assign@4.1.1: {}
+ object-hash@3.0.0: {}
+
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -6286,6 +6582,8 @@ snapshots:
path-key@3.1.1: {}
+ path-parse@1.0.7: {}
+
path-to-regexp@8.4.2: {}
picocolors@1.1.1: {}
@@ -6294,10 +6592,44 @@ snapshots:
picomatch@4.0.7: {}
+ pirates@4.0.7: {}
+
pkce-challenge@5.0.1: {}
possible-typed-array-names@1.1.0: {}
+ postcss-import@15.1.0(postcss@8.5.30):
+ dependencies:
+ postcss: 8.5.30
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.2
+ resolve: 1.22.13
+
+ postcss-js@4.1.0(postcss@8.5.30):
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.5.30
+
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.30)(yaml@2.9.1):
+ dependencies:
+ lilconfig: 3.1.4
+ optionalDependencies:
+ jiti: 1.21.7
+ postcss: 8.5.30
+ yaml: 2.9.1
+
+ postcss-nested@6.2.0(postcss@8.5.30):
+ dependencies:
+ postcss: 8.5.30
+ postcss-selector-parser: 6.1.5
+
+ postcss-selector-parser@6.1.5:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-value-parser@4.2.0: {}
+
postcss@8.5.23:
dependencies:
nanoid: 3.3.20
@@ -6423,6 +6755,12 @@ snapshots:
react@19.3.1: {}
+ read-cache@1.0.2: {}
+
+ readdirp@3.6.0:
+ dependencies:
+ picomatch: 2.3.2
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -6459,6 +6797,13 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
+ resolve@1.22.13:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.17.0
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
reusify@1.1.0: {}
rolldown-plugin-dts@0.29.0-beta.2(rolldown@1.2.15)(typescript@6.0.3):
@@ -6732,10 +7077,24 @@ snapshots:
optionalDependencies:
'@babel/core': 7.29.7
+ sucrase@3.35.1:
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ commander: 4.1.1
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.7
+ tinyglobby: 0.2.17
+ ts-interface-checker: 0.1.13
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tailwind-merge@2.6.1: {}
+
tailwind-merge@3.7.0: {}
tailwind-variants@3.3.1(tailwind-merge@3.7.0)(tailwindcss@4.3.3):
@@ -6743,10 +7102,46 @@ snapshots:
tailwind-merge: 3.7.0
tailwindcss: 4.3.3
+ tailwindcss@3.4.19(yaml@2.9.1):
+ dependencies:
+ '@alloc/quick-lru': 5.3.0
+ arg: 5.0.2
+ chokidar: 3.6.0
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.3.3
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ jiti: 1.21.7
+ lilconfig: 3.1.4
+ micromatch: 4.0.8
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.1.1
+ postcss: 8.5.30
+ postcss-import: 15.1.0(postcss@8.5.30)
+ postcss-js: 4.1.0(postcss@8.5.30)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.30)(yaml@2.9.1)
+ postcss-nested: 6.2.0(postcss@8.5.30)
+ postcss-selector-parser: 6.1.5
+ resolve: 1.22.13
+ sucrase: 3.35.1
+ transitivePeerDependencies:
+ - tsx
+ - yaml
+
tailwindcss@4.3.3: {}
tapable@2.3.3: {}
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
tinybench@6.2.2: {}
tinyexec@1.3.1: {}
@@ -6772,6 +7167,8 @@ snapshots:
dependencies:
typescript: 6.0.3
+ ts-interface-checker@0.1.13: {}
+
tsdown@0.23.2(publint@0.3.25)(typescript@6.0.3):
dependencies:
cac: 7.1.0
@@ -6926,6 +7323,8 @@ snapshots:
dependencies:
react: 19.3.1
+ util-deprecate@1.0.2: {}
+
vary@1.1.2: {}
verkit@0.5.0: {}
diff --git a/src/detect.ts b/src/detect.ts
index 5a702d1c..4b1de60f 100644
--- a/src/detect.ts
+++ b/src/detect.ts
@@ -163,7 +163,8 @@ export const APP_STYLESHEETS = [
* from a registry is read through its `exports` alone, when they point at the
* component sources it publishes, not from `dist/` back to `src/`.
* 4. A flat folder of components that wrap a primitives library, the way React
- * Aria's Tailwind starter ships them (`src/Button.tsx`, `src/Checkbox.tsx`).
+ * Aria's Tailwind starter ships them (`src/Button.tsx`, `src/Checkbox.tsx`), or the
+ * same files copied into an app's `src/components/`, `components/` or `app/components/`.
*
* With more than one design system, the one the root's code imports most comes first
* (`designSystems`). A candidate whose globs match no file is skipped. Returns undefined
@@ -376,7 +377,7 @@ export function detectProject(
let flatWhy = 'no package.json';
const flat = own
- ? fromFlatFolder(root, own, (r) => {
+ ? fromFlatFolder(root, own, 'src', (r) => {
flatWhy = r;
})
: undefined;
@@ -385,6 +386,21 @@ export function detectProject(
return detection(root, [flat], false, flat.source);
}
step('a flat src/ of components', false, flatWhy);
+ // The same components copied into an app (`src/components/Button.tsx` next to `src/App.tsx`),
+ // read next to the app's own components/ui.
+ if (!own || !primitivesDependencies(own).length) return undefined;
+ for (const folder of COMPONENT_FOLDERS) {
+ if (!fs.existsSync(path.join(root, folder))) continue;
+ let why = '';
+ const inFolder = fromFlatFolder(root, own, folder, (r) => {
+ why = r;
+ });
+ if (inFolder && matchesAny(root, inFolder)) {
+ step(`a flat ${folder}/ of components`, true, inFolder.source);
+ return detection(root, [inFolder], true, inFolder.source);
+ }
+ step(`a flat ${folder}/ of components`, false, why || 'its components match no file');
+ }
return undefined;
}
@@ -411,50 +427,79 @@ function plural(n: number, one: string, many = `${one}s`): string {
return `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
}
-/** Libraries of unstyled primitives that design systems wrap. */
+/**
+ * Libraries of unstyled primitives that design systems wrap. Not Radix's icons
+ * (`@radix-ui/react-icons`), which an app's own components import as much as a design system's.
+ */
const PRIMITIVES =
- /^(?:react-aria-components|react-aria|radix-ui|@radix-ui\/react-[\w-]+|@base-ui-components\/react|@base-ui\/react|@headlessui\/react|@ark-ui\/react)(?:\/|$)/;
+ /^(?:react-aria-components|react-aria|radix-ui|@radix-ui\/react-(?!icons(?:\/|$))[\w-]+|@base-ui-components\/react|@base-ui\/react|@headlessui\/react|@ark-ui\/react)(?:\/|$)/;
/** Files that make `src/` an app rather than a library: entries and route folders. */
const APP_ENTRIES = /^(?:main|index|App|app|root|entry-client|entry-server)\.[jt]sx?$/;
const APP_FOLDERS = new Set(['app', 'pages', 'routes']);
-/** Fewer PascalCase files than this in `src/` are an app's, not a component library. */
+/** Fewer PascalCase files than this in the folder are an app's, not a component library. */
const MIN_FLAT_COMPONENTS = 5;
/**
- * A flat `src/` of components, as React Aria's Tailwind starter has it. It needs all of:
- * a dependency on a primitives library, no app entry (`main.tsx`, `App.tsx`) or route
- * folder in `src/`, and at least five PascalCase `.tsx`/`.jsx` files at its top level, four
- * in five of which import the primitives library. An app whose `src/` holds pages or
- * features imports its own components, not primitives, so it does not qualify.
+ * An app's folders of components, read like a flat `src/` when they are one: React Aria's
+ * starter copied into an app (`src/components/Button.tsx` next to `src/App.tsx`), or into a
+ * Next.js app's `components/` or `app/components/`.
+ */
+const COMPONENT_FOLDERS = ['src/components', 'components', 'app/components'];
+
+/** The primitives libraries among a package's dependencies. */
+function primitivesDependencies(pkg: Record): string[] {
+ return Object.keys({
+ ...asRecord(pkg.dependencies),
+ ...asRecord(pkg.devDependencies),
+ ...asRecord(pkg.peerDependencies),
+ }).filter((name) => PRIMITIVES.test(name));
+}
+
+/** `react-aria-components`, or `a primitives library (@radix-ui/react-dialog, @radix-ui/react-slot)`. */
+function primitivesNamed(dependencies: string[]): string {
+ if (dependencies.length <= 1) return dependencies[0] ?? 'a primitives library';
+ const shown = dependencies.slice(0, 3).join(', ');
+ const more = dependencies.length - 3;
+ return `a primitives library (${more > 0 ? `${shown} and ${String(more)} more` : shown})`;
+}
+
+/**
+ * A flat folder of components, as React Aria's Tailwind starter has `src/`. It needs all of:
+ * a dependency on a primitives library, at least five PascalCase `.tsx`/`.jsx` files at the
+ * folder's top level, four in five of which import the primitives library, and, for `src/`,
+ * no app entry (`main.tsx`, `App.tsx`) or route folder in it. An app whose folder holds pages
+ * or features imports its own components, not primitives, so it does not qualify.
*/
function fromFlatFolder(
root: string,
pkg: Record,
+ folder: string,
note: Note = ignore,
): Found | undefined {
- const dependencies = Object.keys({
- ...asRecord(pkg.dependencies),
- ...asRecord(pkg.devDependencies),
- ...asRecord(pkg.peerDependencies),
- }).filter((name) => PRIMITIVES.test(name));
+ const dependencies = primitivesDependencies(pkg);
if (!dependencies.length) {
note('no dependency on a primitives library (React Aria, Radix, Base UI, Headless UI, Ark UI)');
return undefined;
}
- const dir = path.join(root, 'src');
+ const dir = path.join(root, folder);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
- note('no src/ folder');
+ note(`no ${folder}/ folder`);
return undefined;
}
- const app = entries.find(
- (e) => (e.isFile() && APP_ENTRIES.test(e.name)) || (e.isDirectory() && APP_FOLDERS.has(e.name)),
- );
+ const app =
+ folder === 'src'
+ ? entries.find(
+ (e) =>
+ (e.isFile() && APP_ENTRIES.test(e.name)) ||
+ (e.isDirectory() && APP_FOLDERS.has(e.name)),
+ )
+ : undefined;
if (app) {
note(`src/${app.name} makes src/ an app`);
return undefined;
@@ -464,7 +509,7 @@ function fromFlatFolder(
.map((e) => e.name);
if (files.length < MIN_FLAT_COMPONENTS) {
note(
- `${plural(files.length, 'PascalCase .tsx file')} in src/, fewer than ${MIN_FLAT_COMPONENTS}`,
+ `${plural(files.length, 'PascalCase .tsx file')} in ${folder}/, fewer than ${MIN_FLAT_COMPONENTS}`,
);
return undefined;
}
@@ -488,19 +533,24 @@ function fromFlatFolder(
}
if (wrapping * 5 < files.length * 4) {
note(
- `${wrapping} of ${files.length} files in src/ import ${dependencies[0] ?? 'a primitives library'}, fewer than four in five`,
+ `${wrapping} of ${files.length} files in ${folder}/ import ${primitivesNamed(dependencies)}, fewer than four in five`,
);
return undefined;
}
const library = [...libraries].sort((a, b) => b[1] - a[1])[0]?.[0] ?? dependencies[0] ?? '';
- const stylesheet = STYLESHEETS.map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
+ // A library's `src/` brings its own stylesheet; in an app, the app's stylesheets are the
+ // theme, where zero config looks for them anyway.
+ const stylesheet =
+ folder === 'src'
+ ? STYLESHEETS.map((f) => path.join(root, f)).find((f) => fs.existsSync(f))
+ : undefined;
return {
- components: ['src/*.{tsx,jsx}'],
+ components: [`${folder}/*.{tsx,jsx}`],
tokens: stylesheet ? [escapePath(relativePath(root, stylesheet))] : [],
imports: [],
dirs: [dir],
- source: `src/ (${String(files.length)} components wrapping ${library})`,
+ source: `${folder}/ (${String(files.length)} components wrapping ${library})`,
};
}
diff --git a/test/config.test.ts b/test/config.test.ts
index 425f49d5..a2285de8 100644
--- a/test/config.test.ts
+++ b/test/config.test.ts
@@ -14,7 +14,7 @@ import {
rulesFor,
} from '../src/config.js';
import { matchesGlob } from '../src/util/paths.js';
-import { ACME_ROOT, DEMO_ROOT, fixture, load } from './helpers.js';
+import { ACME_ROOT, fixture, load } from './helpers.js';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsm-config-'));
afterAll(() => {
@@ -37,9 +37,9 @@ describe('loadConfig', () => {
it('uses the config file directory as the root when given --config', async () => {
const config = await loadConfig({
cwd: '/',
- config: path.join(DEMO_ROOT, 'onsystem.config.json'),
+ config: path.join(ACME_ROOT, 'onsystem.config.json'),
});
- expect(config.root).toBe(DEMO_ROOT);
+ expect(config.root).toBe(ACME_ROOT);
});
it('falls back to shadcn-friendly defaults without a config', async () => {
diff --git a/test/detect-components-folder.test.ts b/test/detect-components-folder.test.ts
new file mode 100644
index 00000000..aab875d6
--- /dev/null
+++ b/test/detect-components-folder.test.ts
@@ -0,0 +1,254 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+import { main, type Io } from '../src/cli.js';
+import { DEFAULT_COMPONENTS, loadConfig } from '../src/config.js';
+import { detectProject } from '../src/detect.js';
+import { fixture, load } from './helpers.js';
+
+// React Aria's getting-started page has people download its Tailwind starter and use the
+// components. In an app they land in src/components (or components/ in a Next.js app), next to
+// the app's entry, which a flat src/ of components may not have.
+
+const json = (value: unknown) => JSON.stringify(value, null, 2);
+
+/** A component wrapping React Aria, the way its Tailwind starter writes one per file. */
+const racComponent = (name: string, from = 'react-aria-components') => `'use client';
+import { ${name} as RAC${name}, type ${name}Props } from "${from}"
+export function ${name}(props: ${name}Props) { return }
+`;
+
+const THEME = `@import "tailwindcss";
+@theme { --color-accent: oklch(0.55 0.2 260); }
+`;
+
+const VITE_APP = json({
+ name: 'rac-app',
+ private: true,
+ dependencies: { react: '^19.0.0', 'react-aria-components': '^1.22.0' },
+});
+
+const STARTER = ['Button', 'Checkbox', 'Dialog', 'Select', 'Switch', 'TextField'];
+
+/** The starter's components in `folder`, with its helper module. */
+const starterIn = (folder: string) => ({
+ ...Object.fromEntries(STARTER.map((name) => [`${folder}/${name}.tsx`, racComponent(name)])),
+ [`${folder}/utils.ts`]: 'export const focusRing = "outline-2"\n',
+});
+
+const TSCONFIG = json({
+ compilerOptions: { jsx: 'react-jsx', moduleResolution: 'bundler', strict: true },
+ include: ['src'],
+});
+
+/** A Vite app with the starter copied into src/components, next to its entry. */
+const viteApp = () =>
+ fixture({
+ 'package.json': VITE_APP,
+ 'tsconfig.json': TSCONFIG,
+ 'src/main.tsx':
+ 'import { createRoot } from "react-dom/client"\nimport { App } from "./App"\ncreateRoot(document.getElementById("root")!).render( )\n',
+ 'src/App.tsx':
+ 'import { Button } from "./components/Button"\nexport function App() { return Save }\n',
+ 'src/index.css': THEME,
+ ...starterIn('src/components'),
+ });
+
+async function run(args: string[], cwd: string) {
+ const out: string[] = [];
+ const io: Io = { cwd, color: false, stdout: (t) => out.push(t), stderr: (t) => out.push(t) };
+ const code = await main([...args, '--no-cache'], io);
+ return { code, text: out.join('\n') };
+}
+
+describe('detectProject: a flat folder of components in an app', () => {
+ it("finds React Aria's starter copied into src/components next to the app's entry", async () => {
+ const root = viteApp();
+ const config = await loadConfig({ root });
+ expect(config.detected).toBe('src/components/ (6 components wrapping react-aria-components)');
+ // The app's own components/ui, if it has one, is still read.
+ expect(config.components).toEqual([...DEFAULT_COMPONENTS, 'src/components/*.{tsx,jsx}']);
+ expect(config.tokens.map((t) => t.path)).toContain('src/index.css');
+ expect(config.docs).toContain('src/components/**/*.{md,mdx}');
+ const ds = await load(root);
+ expect(
+ ds
+ .roots()
+ .map((c) => c.name)
+ .sort(),
+ ).toEqual(STARTER);
+ });
+
+ it('says why in inspect --explain, and checks the app against the components', async () => {
+ const root = viteApp();
+ const inspect = await run(['inspect', '--explain'], root);
+ expect(inspect.code).toBe(0);
+ expect(inspect.text).toContain(
+ 'no a flat src/ of components: src/App.tsx makes src/ an app',
+ );
+ expect(inspect.text).toContain(
+ 'found a flat src/components/ of components: src/components/ (6 components wrapping react-aria-components)',
+ );
+ fs.writeFileSync(
+ path.join(root, 'src/Draft.tsx'),
+ 'export function Draft() { return Save }\n',
+ );
+ const check = await run(['check', 'src/Draft.tsx'], root);
+ expect(check.text).not.toContain('No design system found');
+ expect(check.text).toContain('Native where the design system has ');
+ });
+
+ it('has init write the glob it found', async () => {
+ const root = viteApp();
+ const init = await run(['init', '--yes'], root);
+ expect(init.code).toBe(0);
+ expect(init.text).toContain(
+ 'Found the design system through src/components/ (6 components wrapping react-aria-components).',
+ );
+ const written = JSON.parse(
+ fs.readFileSync(path.join(root, 'onsystem.config.json'), 'utf8'),
+ ) as { components?: string[] };
+ expect(written.components).toContain('src/components/*.{tsx,jsx}');
+ });
+
+ it('finds components/ in a Next.js app, and app/components', () => {
+ const next = (folder: string) =>
+ fixture({
+ 'package.json': VITE_APP,
+ 'app/layout.tsx': 'export default function Layout() { return null }\n',
+ 'app/page.tsx': 'export default function Page() { return null }\n',
+ 'app/globals.css': THEME,
+ ...starterIn(folder),
+ });
+ expect(detectProject(next('components'))?.source).toBe(
+ 'components/ (6 components wrapping react-aria-components)',
+ );
+ expect(detectProject(next('app/components'))?.source).toBe(
+ 'app/components/ (6 components wrapping react-aria-components)',
+ );
+ // Radix wrappers count the same way.
+ const radix = fixture({
+ 'package.json': json({ name: 'app', dependencies: { '@radix-ui/react-dialog': '^1.1.0' } }),
+ ...Object.fromEntries(
+ STARTER.map((name) => [
+ `components/${name}.tsx`,
+ racComponent(name, '@radix-ui/react-dialog'),
+ ]),
+ ),
+ });
+ expect(detectProject(radix)?.source).toBe(
+ 'components/ (6 components wrapping @radix-ui/react-dialog)',
+ );
+ });
+
+ it("leaves a folder of the app's own features alone", () => {
+ const feature = (name: string) =>
+ `import { Button } from "./Button"\nexport function ${name}() { return ${name} }\n`;
+ const app = (files: Record, pkg = VITE_APP) =>
+ fixture({ 'package.json': pkg, 'src/main.tsx': 'export {}\n', ...files });
+ const features = Object.fromEntries(
+ ['Header', 'Sidebar', 'Footer', 'Profile', 'Billing'].map((name) => [
+ `src/components/${name}.tsx`,
+ feature(name),
+ ]),
+ );
+ // Features import the app's own components, not primitives: fewer than four in five wrap.
+ const mixed = app({ ...features, ...starterIn('src/components') });
+ expect(detectProject(mixed)).toBeUndefined();
+ // Fewer than five components.
+ const few = app(
+ Object.fromEntries(
+ ['Button', 'Checkbox', 'Dialog', 'Select'].map((name) => [
+ `src/components/${name}.tsx`,
+ racComponent(name),
+ ]),
+ ),
+ );
+ expect(detectProject(few)).toBeUndefined();
+ // No primitives library among the dependencies.
+ const none = app(starterIn('src/components'), json({ name: 'app', dependencies: {} }));
+ expect(detectProject(none)).toBeUndefined();
+ });
+
+ it("leaves an app's own components alone when they import Radix's icons", async () => {
+ // @radix-ui/react-icons is an icon set, not a primitives library: features use it too.
+ const feature = (name: string, className = 'p-4') =>
+ `import { StarIcon } from '@radix-ui/react-icons'
+export function ${name}({ title }: { title: string }) {
+ return
+}
+`;
+ const app = (folder: string) =>
+ fixture({
+ 'package.json': json({
+ name: 'icons-app',
+ private: true,
+ dependencies: { next: '^15.0.0', react: '^19.0.0', '@radix-ui/react-icons': '^1.3.0' },
+ }),
+ 'app/globals.css': THEME,
+ ...Object.fromEntries(
+ ['Header', 'Sidebar', 'Footer', 'ProfileCard', 'PricingTable'].map((name) => [
+ `${folder}/${name}.tsx`,
+ feature(name, name === 'Header' ? 'p-4 text-[#ff0000]' : 'p-4'),
+ ]),
+ ),
+ });
+ expect(detectProject(app('components'))).toBeUndefined();
+ expect(detectProject(app('src'))).toBeUndefined();
+ // check holds them to the rules as app code, rather than skipping them as the design system.
+ const check = await run(['check', '.'], app('components'));
+ expect(check.text).toContain('Hardcoded color `text-[#ff0000]`');
+ expect(check.text).not.toContain('design-system files skipped');
+ const inspect = await run(['inspect', '--explain'], app('components'));
+ expect(inspect.text).toContain(
+ 'no a flat src/ of components: no dependency on a primitives library',
+ );
+ });
+
+ it('names the primitives libraries in inspect --explain', async () => {
+ const mixed = (dependencies: Record) =>
+ fixture({
+ 'package.json': json({ name: 'app', dependencies }),
+ 'src/main.tsx': 'export {}\n',
+ ...starterIn('src/components'),
+ ...Object.fromEntries(
+ ['Header', 'Sidebar', 'Footer', 'Profile', 'Billing'].map((name) => [
+ `src/components/${name}.tsx`,
+ `import { Button } from "./Button"\nexport function ${name}() { return ${name} }\n`,
+ ]),
+ ),
+ });
+ const one = await run(['inspect', '--explain'], mixed({ 'react-aria-components': '^1.22.0' }));
+ expect(one.text).toContain(
+ '6 of 11 files in src/components/ import react-aria-components, fewer than four in five',
+ );
+ // Any of them counts, so with more than one the line names them all, not the first.
+ const two = await run(
+ ['inspect', '--explain'],
+ mixed({ 'react-aria-components': '^1.22.0', '@radix-ui/react-dialog': '^1.1.0' }),
+ );
+ expect(two.text).toContain(
+ '6 of 11 files in src/components/ import a primitives library (react-aria-components, @radix-ui/react-dialog), fewer than four in five',
+ );
+ });
+
+ it("is the hook's list of primitives libraries", () => {
+ const literal = (file: string) =>
+ /const PRIMITIVES =\s*(\/.+\/);/.exec(
+ fs.readFileSync(path.resolve(import.meta.dirname, '..', file), 'utf8'),
+ )?.[1];
+ expect(literal('src/detect.ts')).toContain('@radix-ui');
+ expect(literal('plugins/onsystem/hooks/check-ui.mjs')).toBe(literal('src/detect.ts'));
+ });
+
+ it('keeps a flat src/ of components first', () => {
+ const root = fixture({
+ 'package.json': VITE_APP,
+ ...starterIn('src'),
+ ...starterIn('src/components'),
+ });
+ expect(detectProject(root)?.source).toBe('src/ (6 components wrapping react-aria-components)');
+ });
+});
diff --git a/test/example.test.ts b/test/example.test.ts
index 63a1f65b..848562e1 100644
--- a/test/example.test.ts
+++ b/test/example.test.ts
@@ -2,66 +2,145 @@ import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
+import { parse } from 'yaml';
import { main, type Io } from '../src/cli.js';
+import { RULE_IDS } from '../src/config.js';
+import type { CheckResult } from '../src/types.js';
import { DEMO_ROOT, fixture } from './helpers.js';
import { blocks } from './markdown.js';
// The website links examples/shadcn-demo as the demo design system, so people open it from
-// GitHub, and some copy the folder out (`npx degit`) to try it. A copy has only the folder: the
-// steps its README gives first, its scripts, its config and the draft page's comment must work
-// there, without a clone or a build of this repository.
+// GitHub, and some copy the folder out (`npx degit`) to try it; the other examples are tried the
+// same way. A copy has only the folder: the steps its README gives first, its scripts and the
+// draft page's comment must work there, without a clone or a build of this repository, and with
+// no onsystem config, as in the projects they stand for.
-const read = (file: string) => fs.readFileSync(path.join(DEMO_ROOT, file), 'utf8');
-const readme = read('README.md');
-const manifest = JSON.parse(read('package.json')) as {
+const EXAMPLES_ROOT = path.dirname(DEMO_ROOT);
+
+/** Each example: its draft page (findings), its clean page (none) and what zero config finds. */
+const EXAMPLES: Record = {
+ 'shadcn-demo': {
+ draft: 'app/settings/danger-zone.tsx',
+ clean: 'app/settings/members.tsx',
+ detected: 'components.json (ui: @/components/ui → components/ui)',
+ },
+ 'tailwind-v3': {
+ draft: 'app/account/delete-account.tsx',
+ clean: 'app/account/profile.tsx',
+ detected: 'components.json (ui: @/components/ui → components/ui)',
+ },
+ 'react-aria': {
+ draft: 'src/InviteForm.tsx',
+ clean: 'src/App.tsx',
+ detected: 'src/components/ (7 components wrapping react-aria-components)',
+ },
+};
+
+interface Manifest {
scripts?: Record;
dependencies?: Record;
devDependencies?: Record;
-};
-const scripts = manifest.scripts ?? {};
+}
-/** What the README says before its section for people working on onsystem itself. */
-const forVisitors = readme.split('\n## Working on onsystem\n')[0] ?? '';
+function example(name: string) {
+ const root = path.join(EXAMPLES_ROOT, name);
+ const read = (file: string) => fs.readFileSync(path.join(root, file), 'utf8');
+ const readme = read('README.md');
+ const manifest = JSON.parse(read('package.json')) as Manifest;
+ return {
+ root,
+ read,
+ readme,
+ manifest,
+ scripts: manifest.scripts ?? {},
+ /** What the README says before its section for people working on onsystem itself. */
+ forVisitors: readme.split('\n## Working on onsystem\n')[0] ?? '',
+ };
+}
/** The `npm run` scripts a block of commands runs, in order. */
const npmRuns = (commands: string) =>
[...commands.matchAll(/npm run ([\w:-]+)/g)].map((m) => m[1] ?? '');
-/** The folder copied outside the repository, with its dependencies installed. */
-function copyOfTheFolder(): string {
- const root = fixture({}, { nodeModules: true });
- fs.cpSync(DEMO_ROOT, root, {
+/**
+ * The folder copied outside the repository, with its dependencies installed: its
+ * `node_modules` links each package the example has installed.
+ */
+function copyOf(root: string): string {
+ const copy = fixture({});
+ fs.cpSync(root, copy, {
recursive: true,
- filter: (src) => path.relative(DEMO_ROOT, src).split(path.sep)[0] !== 'node_modules',
+ filter: (src) => path.relative(root, src).split(path.sep)[0] !== 'node_modules',
});
- return root;
+ const installed = path.join(root, 'node_modules');
+ fs.mkdirSync(path.join(copy, 'node_modules'));
+ // Each package is linked to where it really is, as fixture() links the demo's.
+ const link = (name: string) => {
+ fs.symlinkSync(
+ fs.realpathSync(path.join(installed, name)),
+ path.join(copy, 'node_modules', name),
+ 'junction',
+ );
+ };
+ for (const name of fs.readdirSync(installed)) {
+ if (name.startsWith('.')) continue;
+ if (!name.startsWith('@')) {
+ link(name);
+ continue;
+ }
+ fs.mkdirSync(path.join(copy, 'node_modules', name));
+ for (const scoped of fs.readdirSync(path.join(installed, name))) link(`${name}/${scoped}`);
+ }
+ return copy;
}
-/** Runs an `npm run` script of the copy, with this checkout's CLI in place of the npm release. */
-async function runScript(name: string, cwd: string) {
+/** Runs an `npm run` script of a copy, with this checkout's CLI in place of the npm release. */
+async function runScript(scripts: Record, name: string, cwd: string) {
const script = scripts[name] ?? '';
const npx = 'npx -y onsystem ';
expect(script.startsWith(npx), `npm run ${name}: ${script}`).toBe(true);
- const args = script.slice(npx.length).split(/\s+/);
+ return run([...script.slice(npx.length).split(/\s+/)], cwd);
+}
+
+async function run(args: string[], cwd: string) {
const out: string[] = [];
const io: Io = { cwd, color: false, stdout: (t) => out.push(t), stderr: (t) => out.push(t) };
const code = await main([...args, '--no-cache'], io);
return { code, output: out.join('\n') };
}
-describe('examples/shadcn-demo outside the repository', () => {
+/** The findings `check --format json` gives for the copy. */
+async function findings(cwd: string) {
+ const { output } = await run(['check', '.', '--format', 'json'], cwd);
+ return (JSON.parse(output) as CheckResult[]).flatMap((r) => r.diagnostics);
+}
+
+it('tests every example', () => {
+ const folders = fs
+ .readdirSync(EXAMPLES_ROOT, { withFileTypes: true })
+ .filter((e) => e.isDirectory())
+ .map((e) => e.name)
+ .sort();
+ expect(folders).toEqual(Object.keys(EXAMPLES).sort());
+});
+
+describe.each(Object.entries(EXAMPLES))('examples/%s outside the repository', (name, pages) => {
+ const { root, read, readme, manifest, scripts, forVisitors } = example(name);
+ const draft = path.basename(pages.draft);
+
it('starts its README with steps that work in a copy of the folder', () => {
const [first = ''] = blocks(readme, 'sh');
- expect(first).toContain('npx degit dgesteves/onsystem/examples/shadcn-demo');
+ expect(first).toContain(`npx degit dgesteves/onsystem/examples/${name} `);
expect(first).toContain('npm install');
expect(npmRuns(first)).toEqual(['inspect', 'check']);
+ expect(first).toContain(`exits 1: ${draft} has errors`);
// Nothing a visitor reads first needs a clone or a build of this repository.
expect(forVisitors).not.toMatch(/\bpnpm\b|dist\/cli\.js|repository root/);
});
it('has the scripts the README runs, with onsystem from npm', () => {
- for (const name of npmRuns(forVisitors)) expect(scripts).toHaveProperty(name);
+ for (const script of npmRuns(forVisitors)) expect(scripts).toHaveProperty(script);
// npx, not a dependency: pnpm would install the npm release in this workspace and hoist it to
// node_modules/.pnpm/node_modules, which the NODE_PATH of pnpm's bins (vitest's among them)
// puts in reach of every process the tests start, such as the hook's "own install" lookup.
@@ -69,38 +148,118 @@ describe('examples/shadcn-demo outside the repository', () => {
expect(manifest.devDependencies).not.toHaveProperty('onsystem');
});
- it('runs the README scripts in the copy with the results it describes', async () => {
- const root = copyOfTheFolder();
-
- const inspect = await runScript('inspect', root);
+ it('needs no onsystem config: zero config finds the design system', async () => {
+ expect(fs.readdirSync(root).filter((f) => /^onsystem\.config\./.test(f))).toEqual([]);
+ const inspect = await runScript(scripts, 'inspect', copyOf(root));
expect(inspect.code).toBe(0);
- for (const component of ['Button', 'Badge', 'Card', 'Dialog', 'Input']) {
- expect(inspect.output).toMatch(new RegExp(`^\\s+${component}\\s`, 'm'));
- }
- // The README says to install first: Dialog's props come from Radix's types.
- expect(inspect.output).toMatch(/^\s+Dialog\s+[1-9]\d* props/m);
- expect(forVisitors).toContain("`Dialog`'s come from Radix");
-
- const check = await runScript('check', root);
- expect(check.code).toBe(1);
- expect(check.output).toContain('app/settings/danger-zone.tsx');
- expect(check.output).not.toContain('members.tsx');
- expect(check.output).toMatch(/\d+ errors?, \d+ warnings? in 1 of 2 files checked/);
- expect(forVisitors).toContain('exits 1: danger-zone.tsx has errors');
+ expect(inspect.output).toMatch(/^config\s+\(defaults\)$/m);
+ expect(inspect.output.split('\n')).toContain(`detected ${pages.detected}`);
});
- it('points $schema at the published schema, which a copy can load', () => {
- const config = JSON.parse(read('onsystem.config.json')) as { $schema?: string };
- const schema = JSON.parse(
- fs.readFileSync(path.join(import.meta.dirname, '../schema.json'), 'utf8'),
- ) as { $id: string };
- expect(config.$schema).toBe(schema.$id);
+ it('runs check in the copy with the results its README describes', async () => {
+ const copy = copyOf(root);
+ const check = await runScript(scripts, 'check', copy);
+ expect(check.code).toBe(1);
+ expect(check.output).toContain(pages.draft);
+ expect(check.output).not.toContain(pages.clean);
+ expect(check.output).toMatch(/\d+ errors?, \d+ warnings? in 1 of \d+ files checked/);
+ // The README's table says what check reports on the draft, and that the clean page passes.
+ const count = (await findings(copy)).length;
+ const row = (page: string) =>
+ readme.split('\n').find((line) => line.startsWith(`| \`${page}\``)) ?? '';
+ expect(row(pages.draft)).toContain(`\`check\` reports ${String(count)} problems`);
+ expect(row(pages.clean)).toContain('`check` reports nothing.');
});
it('names a script the copy has in the draft page', () => {
- const comment = read('app/settings/danger-zone.tsx').split('\n').slice(0, 2).join('\n');
+ const comment = read(pages.draft).split('\n').slice(0, 2).join('\n');
const [named] = npmRuns(comment);
expect(named, comment).toBeDefined();
expect(scripts).toHaveProperty(named ?? '');
});
+
+ it('keeps node_modules out of git in a copy', () => {
+ expect(read('.gitignore').split('\n')).toContain('node_modules');
+ });
+
+ it('is typechecked in CI', () => {
+ expect(scripts.typecheck).toBe('tsc --noEmit');
+ const ci = parse(
+ fs.readFileSync(path.join(root, '../../.github/workflows/ci.yml'), 'utf8'),
+ ) as {
+ jobs: Record;
+ };
+ const runs = Object.values(ci.jobs).flatMap((job) => job.steps.map((s) => s.run ?? ''));
+ expect(runs).toContain('pnpm --filter "./examples/*" typecheck');
+ });
+});
+
+describe('examples/shadcn-demo', () => {
+ const { root, readme, forVisitors } = example('shadcn-demo');
+
+ it('reports at least one problem for each rule on the draft page, as its README says', async () => {
+ const rules = new Set((await findings(copyOf(root))).map((d) => d.ruleId));
+ expect([...rules].sort()).toEqual([...RULE_IDS].sort());
+ expect(readme).toContain(
+ `at least one for each of onsystem's ${String(RULE_IDS.length)} rules`,
+ );
+ });
+
+ it('reads the docs from docs/components, where zero config looks', async () => {
+ const inspect = await run(['inspect'], copyOf(root));
+ expect(inspect.output).toMatch(/^files\s+5 component files, 1 token file, 5 docs$/m);
+ // The README says to install first: Dialog's props come from Radix's types.
+ expect(inspect.output).toMatch(/^\s+Dialog\s+[1-9]\d* props/m);
+ expect(forVisitors).toContain("`Dialog`'s come from Radix");
+ });
+});
+
+describe('examples adapted from shadcn/ui', () => {
+ it.each(['shadcn-demo', 'tailwind-v3'])('%s keeps shadcn/ui’s MIT notice', (name) => {
+ const { read, readme } = example(name);
+ expect(readme).toContain('[MIT](LICENSE-shadcn)');
+ const license = read('LICENSE-shadcn');
+ expect(license).toContain('Copyright (c) 2023 shadcn');
+ expect(license).toContain('The above copyright notice and this permission notice');
+ });
+});
+
+describe("examples adapted from React Aria's Tailwind starter", () => {
+ it('react-aria says so and keeps its Apache-2.0 license', () => {
+ const { read, readme } = example('react-aria');
+ expect(readme).toContain(
+ 'adapted from the [React Aria Tailwind starter](https://github.com/adobe/react-spectrum/tree/main/starters/tailwind) by Adobe ([Apache-2.0](LICENSE-react-aria)), changed for this example',
+ );
+ expect(readme).not.toContain('written for this example');
+ // The license as react-spectrum publishes it, with Adobe's copyright line.
+ const license = read('LICENSE-react-aria');
+ expect(license).toMatch(/^\s+Apache License\n\s+Version 2\.0, January 2004\n/);
+ expect(license).toContain('Copyright 2019 Adobe');
+ expect(license).toContain('4. Redistribution.');
+ });
+});
+
+describe('examples/tailwind-v3', () => {
+ const { root } = example('tailwind-v3');
+
+ it('reads the v3 tokens: HSL channels, named by tailwind.config.ts', async () => {
+ const { output } = await run(['check', '.'], copyOf(root));
+ expect(output).toContain(
+ 'Hardcoded color `text-[#71717a]`. Matches token muted-foreground (exact match) → `text-muted-foreground`.',
+ );
+ expect(output).toContain(
+ 'Matches token muted (ΔE 0.001, same value as secondary, accent) → `bg-muted`.',
+ );
+ });
+});
+
+describe('examples/react-aria', () => {
+ const { root, forVisitors } = example('react-aria');
+
+ it("suggests React Aria's prop names, from its types", async () => {
+ const { output } = await run(['check', '.'], copyOf(root));
+ expect(output).toContain(' has no prop "checked". Did you mean "isSelected"?');
+ expect(output).toContain(' has no prop "disabled". Did you mean "isDisabled"?');
+ expect(forVisitors).toContain('`check` cannot tell that `checked` should be `isSelected`');
+ });
});
diff --git a/test/extract.test.ts b/test/extract.test.ts
index ca5938c6..2bb9a271 100644
--- a/test/extract.test.ts
+++ b/test/extract.test.ts
@@ -90,7 +90,7 @@ describe('extraction from a shadcn/ui-style system (types resolved)', () => {
it('collects class names and attaches docs and examples', () => {
const button = component(ds, 'Button');
expect(button.classNames).toContain('bg-primary');
- expect(button.docs?.file).toBe('docs/button.md');
+ expect(button.docs?.file).toBe('docs/components/button.md');
expect(button.examples.map((e) => e.title)).toContain('Destructive action');
expect(ds.relatedTokens(button).map((t) => t.name)).toEqual(
expect.arrayContaining(['primary', 'destructive', 'ring', 'radius-md']),
diff --git a/test/server.test.ts b/test/server.test.ts
index 446d3006..fbdcbd0f 100644
--- a/test/server.test.ts
+++ b/test/server.test.ts
@@ -105,7 +105,7 @@ describe('MCP server over the in-memory transport', () => {
'- variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" = "default"',
);
expect(body).toContain('## Variants');
- expect(body).toContain('## Docs (docs/button.md)');
+ expect(body).toContain('## Docs (docs/components/button.md)');
expect(body).toContain('### "Destructive action"');
expect(result.structuredContent).toMatchObject({ name: 'Button', element: 'button' });
diff --git a/test/setup-steps.test.ts b/test/setup-steps.test.ts
index 3ca24426..7449c1bf 100644
--- a/test/setup-steps.test.ts
+++ b/test/setup-steps.test.ts
@@ -67,8 +67,12 @@ describe('the steps /setup.md gives an agent', () => {
it('record a baseline next to a config that has none, so the check passes', async () => {
// The quickstart's step 1 has the user run `init`, which only offers the baseline.
- const root = demo(['onsystem.config.json']);
- expect(fs.existsSync(path.join(root, 'onsystem.config.json'))).toBe(true);
+ // The demo needs no config: give it one, as a project that already has one.
+ const root = demo();
+ fs.writeFileSync(
+ path.join(root, 'onsystem.config.json'),
+ JSON.stringify({ components: ['components/ui/**/*.tsx'], tokens: ['app/globals.css'] }),
+ );
expect(fs.existsSync(path.join(root, 'onsystem.baseline.json'))).toBe(false);
// Step 2: the config is kept, and nothing records the baseline...