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 ( +
+

New project

+ + Invite my team + Private +
+ + + + + {({ close }) => ( + <> + Discard this project? +

+ Nothing has been saved yet. +

+
+ + +
+ + )} +
+
+
+ +
+ + ) +} 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 ( +
+ + Send me a copy + Notify by email +

They get an email with a link.

+
+ + +
+ + ) +} 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 && } + + {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 + + + +

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. + + + + +

+ Your plan: Pro +

+
+ + + + +
+ ) +} 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 ` }\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 }\n', + ); + const check = await run(['check', 'src/Draft.tsx'], root); + expect(check.text).not.toContain('No design system found'); + expect(check.text).toContain('Native }\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

{title}

+} +`; + 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 }\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...