Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/layered-configs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'onsystem': minor
---

An app's own config builds on the config its design-system package declares, instead of replacing it. What the app's config leaves out comes from the package's, unless it sets `components` of its own: `components`, with the package's `importPath`, `docs`, `elements`, `tokens` and `libraries`. A config that sets `components` reads its tokens as before: without `tokens`, from the app's own stylesheet (`app/globals.css`) and the ones it imports. Its `rules` apply over the package's, rule by rule, either way, and, with the package's components, its `elements` element by element; the package's `exclude` patterns are added to the app's; its `overrides`, `tsconfig` and `includeDesignSystem` are its own. Before, an app config that only turned one rule to `"warn"` lost the package's components and color tokens: in an app that installs the package, `check` said `No design system found` and passed. `inspect` names both configs (`config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)`). `"extends": false` in the app's config reads it alone, as before. This can change what `check` reports in an app with a config of its own whose dependency declares one: it now gets the package's rules where its config leaves them out, and the package's components and tokens when it sets no `components` of its own. A workspace package whose `"onsystem"` field cannot be read is a warning in such an app, which then reads its own config alone, and still an error in an app without one.

A monorepo root's config builds on a project's design-system package config the same way in `check` and `inspect` from the root, so its `rules` and `overrides` apply to every project without a config of its own. Before, the config a project's design-system package declared took their place, without a word. From the project's folder, the root's config is still not read.

`init --force` in an app on such a package, without a config or with one whose `components` match no file outside the package, writes a config with only `$schema`, for the app's own rules and overrides, and keeps the package's components, `importPath`, tokens and rules. Before, it wrote the package's paths into the app's config, or a config that lost the package's components. In an app whose config names components of its own, it writes them again, as before.

`init` in an app whose design system zero config reads from an installed package leaves `components` and `tokens` to zero config. It wrote paths into the package's folder, which pnpm names by version (`node_modules/.pnpm/@acme+ui@1.0.0_react@19.0.0/node_modules/@acme/ui/…`), so after an upgrade `check` failed with `Token file not found`, and the component files it listed matched nothing.

In a design-system package whose `files` leave out its component sources, `init` says that apps installing it read its components from the type declarations it publishes, when its `package.json` names some. It said they get no component checks, which holds only without type declarations.
31 changes: 17 additions & 14 deletions docs/configuration.md

Large diffs are not rendered by default.

55 changes: 33 additions & 22 deletions docs/design-system-packages.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The plugin is the fastest way to keep Claude on your design system. It bundles t
- **Errors come back to be fixed.** The hook runs after the file is written. It exits with Claude Code's blocking-error code, and the findings, each with its fix, go to Claude, which corrects the file before moving on.
- **Warnings don't.** They reach Claude as a note, without blocking.
- **Only what Claude just changed.** After an Edit it lists the findings on the lines the edit changed, not the unchanged lines it repeats around them, and only counts older ones. When it can't tell which lines those are (the new text is in the file more than once, or a formatter rewrote it), it passes the findings on as a note and doesn't block. It honours a [baseline](ci.md#adopting-it-in-an-existing-codebase).
- **Quiet where there is nothing to check.** It stays silent in projects without design-system components (tokens alone don't count) or a config, so installing the plugin for every project is safe. A config that a dependency declares counts: in an app on a [design-system package](design-system-packages.md) that publishes only compiled code, the hook checks the tokens, and their errors block.
- **Quiet where there is nothing to check.** It stays silent in projects without design-system components (tokens alone don't count) or a config, so installing the plugin for every project is safe. A config that a dependency declares counts: in an app on a [design-system package](design-system-packages.md#what-apps-get) that publishes only compiled code, the hook checks the components it reads from the package's type declarations, and the tokens, and their errors block; when the package publishes no type declarations, it checks no component of the package, only the tokens and native elements.
- **The design system's own files are left alone.** Like `check`, it skips them: an edit to `components/ui/button.tsx` changes the design system, which is a call for you and your reviewers rather than a lint error.
- **So is what `check .` leaves out.** A file the config's `exclude` covers, tests and stories by default (`*.test.tsx`, `*.stories.tsx`, `__tests__` folders) or a folder you list such as `app/legacy`, is not checked: the hook runs `check --skip-excluded`, which leaves such a file out even though it is named. Add a folder to `exclude` to keep the hook off code you don't hold to the design system. A project's own install from before that flag checks the file, as it did before.
- **It says when it can't check.** When the CLI can't run (npx can't fetch it because of a [min-release-age policy](troubleshooting.md#a-min-release-age-policy-blocks-npx), a private registry or no network, or the config is broken), it says so once per session and project, as a message to you, and never blocks the edit. When npm could not reach the registry, or npx didn't finish within the hook's 55 seconds, the hook doesn't start npx again for ten minutes, so an offline session waits for npm once, not on every edit.
Expand Down
6 changes: 5 additions & 1 deletion schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"$schema": {
"type": "string"
},
"extends": {
"description": "Whether this config builds on the one a design-system package declares (\"onsystem\" in the package.json of a dependency, found from the dependencies): its rules apply over the package's, and, unless it sets components of its own, what it leaves out comes from the package's (components with their importPath, docs, elements, tokens and libraries). Default: true. false reads this config alone.",
"type": "boolean"
},
"components": {
"description": "Globs for component source files, relative to the root.",
"anyOf": [
Expand Down Expand Up @@ -103,7 +107,7 @@
"type": "string"
},
"importPath": {
"description": "Package name components are imported from, e.g. \"@acme/ui\", or a pattern for packages imported by path: \"@acme/ui/{path}\" is @acme/ui/primitives/button for primitives/button.tsx in the package. Default: inferred from package exports and tsconfig paths.",
"description": "Package name components are imported from, e.g. \"@acme/ui\", or a pattern for packages imported by path: \"@acme/ui/{path}\" is @acme/ui/primitives/button for primitives/button.tsx in the package. An installed dependency it names exactly, whose component sources the project does not read, is read from its type declarations (.d.ts). Default: inferred from package exports and tsconfig paths.",
"type": "string"
},
"elements": {
Expand Down
19 changes: 13 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1124,7 +1124,7 @@ async function inspect(
}
const lines: string[] = [
`root ${model.root}`,
`config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config)}` : '(defaults)'}`,
`config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config, (f) => displayPath(io.cwd, f))}` : '(defaults)'}`,
...(config.detected ? [`detected ${config.detected}`] : []),
...describeLibraries(config, model, io.cwd).map((line) => `library ${line}`),
`files ${plural(model.stats.files.components, 'component file')}, ${plural(model.stats.files.tokens, 'token file')}, ${plural(model.stats.files.docs, 'doc')}`,
Expand Down Expand Up @@ -1296,8 +1296,9 @@ async function inspectWorkspace(
const roots = ds.roots();
const tokens = [...tokenCounts(model)].map(([category, n]) => `${category} ${n}`);
const own =
project.config.configFile && project.config.configFile !== workspace.configFile
? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config)}`
project.config.configFile &&
(project.config.configFile !== workspace.configFile || project.config.designSystemPackage)
? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config, (f) => displayPath(workspace.root, f))}`
: undefined;
lines.push(
'',
Expand Down Expand Up @@ -1362,9 +1363,15 @@ function explainWorkspace(
return lines;
}

/** ` (declared by @acme/ui)` after a config a design-system package declares; empty otherwise. */
function declaredBy(config: ResolvedConfig): string {
return config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : '';
/**
* ` (declared by @acme/ui)` after a config a design-system package declares, `, over
* packages/ui/onsystem.config.json (declared by @acme/ui)` after one read over it; empty otherwise.
*/
function declaredBy(config: ResolvedConfig, display: (file: string) => string): string {
const declared = config.designSystemPackage;
if (!declared) return '';
const over = config.configFile === declared.config ? '' : `, over ${display(declared.config)}`;
return `${over} (declared by ${declared.name})`;
}

/** `A, B, C` up to a dozen names, then `and 40 more`. */
Expand Down
Loading
Loading