Skip to content

feat(config): an app's config builds on its design-system package's, and so does the root's - #202

Merged
dgesteves merged 5 commits into
mainfrom
fix/os2-76-layer-configs
Oct 23, 2026
Merged

dgesteves merged 5 commits into
mainfrom
fix/os2-76-layer-configs

Conversation

@dgesteves

@dgesteves dgesteves commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Roadmap item: OS2-76 (layer configs: an app config extends the package's; root rules apply over a package config), with the review follow-ups for OS2-75 (#176) and OS2-49 (#194) that sit in the same files.

What was wrong

  • An app's own config replaced the package's. In an app that installs @acme/ui (sources published, config declared), {"rules":{"no-hardcoded-color":"warn"}} in the app's onsystem.config.json dropped the package's components and color tokens: on main, check app/page.tsx printed No design system found (no components or color tokens): only the accessibility rule ran and exited 0, where the same app with no config reported 5 errors and 1 warning.
  • A monorepo root's config was shadowed by the package's. With a root config {"rules":{"no-hardcoded-spacing":"off"},"overrides":[{"files":["apps/admin/legacy/**"],"rules":{"prefer-design-system-component":"off"}}]}, check . at the root on main still reported the spacing warning in apps/admin/app/page.tsx and Native <table> where the design system has <Table> in apps/admin/legacy/old.tsx. inspect showed only packages/ui/onsystem.config.json (declared by @acme/ui) for the app.
  • init --force in such an app wrote a copy of the package's paths, or, when the package's config left components to zero config, a config that lost the package's components, as its own note said (It does not know @acme/kit's components).
  • OS2-75 follow-up: init --yes in a pnpm app that zero config reads an installed design-system package for wrote components, docs and tokens through node_modules/.pnpm/@acme+ui@1.0.0_react@19.0.0/node_modules/@acme/ui/.... pnpm moves that folder at the next version, so check then fails with Token file not found, and the component globs match nothing.
  • OS2-49 follow-up: since feat: read design-system packages published as dist/ from their type declarations #194 reads dist-only packages from their .d.ts, several texts were wrong: init told a package that publishes dist/ with type declarations that installed apps "get no component, prop, variant or native-element checks" and "no component checks until files publishes src/"; docs/design-system-packages.md (intro, the third row of "What apps get", the dist-only bullets, "importPath does not turn the component rules on"), docs/configuration.md (the libraries row, "Published libraries (MUI)", the importPath row), the importPath description in src/config.ts and schema.json, and docs/plugin.md ("the hook checks the tokens") all said such apps get no components. I checked on main first: with real declarations and the config declared, every component rule runs and --require-design-system exits 1; without declarations, only the token rules and icon-button-accessible-name on native elements run, and it exits 2.

What changed

  • src/config.ts: a project's own config (or --config, or a workspace root's config it takes on) is read over the config its design-system package declares. ResolvedConfig.inherits is set when that config sets no components, so init and inspect --explain can tell where the components come from. When it sets no components of its own, what it leaves out comes from the package's: components together with the package's importPath, docs, elements, tokens and libraries. A config that sets components takes none of those, so its components are not mislabelled with the package's import path, and its tokens and libraries are read as on main: without tokens, from zero config's stylesheets (the app's app/globals.css and what it imports). rules merge rule by rule, the app's on top, and, with the package's components, elements merge element by element. The package's exclude patterns are added to the app's, as they already were in an app without a config. overrides, tsconfig and includeDesignSystem stay with whichever config sets them, as they already did for the package. New config key "extends" (boolean, default true): false reads the config alone, as before. "extends": "@acme/ui" is an error that says extends is true or false and that the package is found from the dependencies. A workspace package whose "onsystem" field cannot be read is still an error in an app without a config, and a warning in one with a config, which is then read alone.
  • src/workspace.ts (two conditions): a project in a monorepo with a root config now gets the root config together with the package lookup, so the root's rules and overrides apply over the package's config in check and inspect from the root. Runs from the project's folder still read no root config, as documented. A project that reads a package's config counts as one with a design system even when that config gives it no component file (tokens only), as it did when the package's config was its configFile.
  • src/design-system.ts (watchConfigFiles): the MCP server also watches the config the package declares, so an edit to it is picked up live when the project reads it under a config of its own or the root's. On main that config was the project's configFile, and was watched as such.
  • src/cli.ts and src/explain.ts: the config line says onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui), and inspect --explain says which this project reads under onsystem.config.json. Without this they would print onsystem.config.json (declared by @acme/ui), which is wrong. When the app's config sets no components, --explain names the package's config as their source: components come from node_modules/@acme/ui/onsystem.config.json, so zero config did not look (it named the app's config before). With "extends": false, --explain names the dependency it left out: no a design-system package's config: @acme/ui (installed) declares node_modules/@acme/ui/onsystem.config.json, not read: "extends": false in onsystem.config.json reads it alone.
  • src/init.ts:
    • --force in an app on a package that declares a config writes { "$schema": … } only, and says what it builds on, when the app has no config, or one whose components match no file outside the package: none set, a glob that matches nothing, or the package's own paths copied in (node_modules/@acme/ui/src/*.tsx, as main's init --force wrote them). A config that names components of the app's own (components/ui/**/*.tsx) is written out again, as on main: {"components":["components/ui/**/*.tsx"],"tokens":["app/globals.css"]} in the fifth review's app.
    • At a monorepo root, init's note says the root's rules apply over the package's config.
    • In an app whose design system zero config reads from an installed package, components and tokens are left to zero config, with a note saying why. I chose this over rewriting paths to node_modules/<name>/: a file list also misses the components a new version adds, and zero config already finds the package again after an upgrade.
    • When only the tokens come from an installed package (a shadcn app whose components.json names app/styles/tailwind.css, which imports @acme/ui/theme.css), components is left to zero config as well whenever a config that names them would read other token files: such a config turns detection off, and the stylesheet components.json names is not one of the usual ones. With app/globals.css, the components are still written.
    • The dist-only text now depends on whether the package's package.json names type declarations (types, typings, a types export condition, or index.d.ts) that files publishes.
  • Docs: design-system-packages.md gets a new "An app's own config" subsection, the precedence list becomes layering, and "What apps get" has 6 rows (with and without .d.ts). configuration.md gets the zero-config paragraph, "Published libraries" and the libraries, importPath and new extends rows. plugin.md line 32 is corrected. After the fifth review: the precedence sentence says the two configs layer field by field, as "An app's own config" says; a new sentence says that an app taking the package's components (no config, or one without components) reads only the tokens the package's config names, with the workaround "tokens": ["app/globals.css", "node_modules/@acme/ui/theme.css"] (a tokens list does not follow @imports); and the init --force sentence, here and in the changeset, says which apps get $schema alone. schema.json is regenerated by pnpm build.
  • Changeset: minor. This changes what check reports for apps that have a config of their own and a dependency that declares one: they now get the package's rules where their config leaves them out, and its components and tokens when their config sets no components.

A different fix from the one proposed

The finding proposes "extends": "@northwind/ui", or a merge. This PR merges by default and adds "extends": false as the way out, so a one-line severity tweak keeps the design system without the app team having to know about extends. An app with no config already takes the package's config on, and an app with a config now gets the same design system. I left out a warning for a project whose own config shadows the root config. That case is documented ("applies to every project that has no config of its own"), and it is deliberate in setups that keep per-app configs, where the warning would print on every root run with no way to silence it. The case the finding reports, a package config shadowing the root's, no longer happens.

Files outside the item's list

  • src/workspace.ts: the two conditions above, and a doc comment. No wave 4 or 5 item lists this file.
  • src/design-system.ts: watchConfigFiles and the fallback reload in reloadConfig only (without a loadConfig option, a config passed by path that the project reads over the package's is now read again, where the reload looked the config up by name instead). OS2-54 (fix: per-user temp cache, quoted repository text, and linear color rule #199) changes this file in other functions; OS2-47 (wave 5, config reload) lists it.
  • src/explain.ts: wording of the config and trace lines, and one trace step for "extends": false. OS2-77 (wave 5) lists this file and comes after this PR.
  • src/cli.ts: declaredBy() and the 2 lines that call it. OS2-54 (wave 4) also lists src/cli.ts, in other functions, so a rebase there should be mechanical.
  • Tests that asserted the old replace behaviour are updated: test/design-system-package.test.ts and test/init-registry-apps.test.ts. Its first test now uses a package with no types, since the package with them is covered in the new file. test/design-system-packages-docs.test.ts and test/dist-only-package.test.ts get real .d.ts fixtures, as the item allows.

How it was tested

  • New test/layered-config.test.ts, 25 tests. The first 12 fail on main (source stashed, test kept):

    • one-rule app config keeps the components and tokens
    • rule-by-rule merge, with overrides kept to each config
    • own components take none of the package's importPath, docs, elements, tokens or libraries
    • "extends": false reads the config alone
    • a broken workspace declaration is a warning in an app with a config
    • root rules and overrides apply over the package's config from the root, and not from the app's folder
    • "extends": false in a root config
    • init's root note
    • init --force writes $schema only, and the findings stay the same
    • init --yes in a pnpm .pnpm layout writes no node_modules path, and check still works after the package moves to @acme+ui@1.1.0_…
    • init's dist-only text, with and without type declarations, against what an installed app then gets

    Added after review, each run against this branch before the fix (source stashed, tests kept):

    • init --yes in a shadcn app with components.json, a stylesheet outside the usual places and an installed package's theme, in npm and pnpm layouts: writes $schema only, keeps the 3 findings zero config reports and both token files, also after the package moves to @acme+ui@1.1.0_… (2 tests, both fail before the fix, which wrote components and lost the tokens)
    • the same app with app/globals.css: the components are still written (2 tests, a guard: they pass before and after)
    • a root config and an app whose package config gives tokens only: inspect lists 1 project with a design system with its config line (fails before the fix)

    Added after the third review, each run against the previous head (source stashed, tests kept); all fail there:

    • an app config {"components":["components/ui/**/*.tsx"]} with an app/globals.css that imports the package's theme and adds --color-brand and --radius-card: the app's tokens are read (bg-[#1a7f37] is an exact match for bg-brand, rounded-[14px] suggests rounded-card), and so is the package's spacing scale it imports. Before, the tokens were only node_modules/@acme/ui/theme.css.
    • a package's "libraries": false leaves MUI on in an app whose config sets components, and applies when the app's config leaves components out
    • inspect --explain with "extends": false names the package left out, and "extends": "@acme/ui" gets the hint
    • the updated test for own components, and a new one in test/design-system-packages-docs.test.ts for the docs sentence
      Added after the fifth review, run against the previous head (source stashed, tests kept):
    • init --force in an app whose config is {"components":["components/ui/**/*.tsx"]}, with its own --color-brand: writes {"$schema", "components":["components/ui/**/*.tsx"], "tokens":["app/globals.css"]} without the "reads the config @acme/ui declares" note, and check reports the same 2 findings before and after (fails on the previous head, which wrote $schema only), and the same case in test/design-system-packages-docs.test.ts with its docs sentence (fails there too)
    • inspect --explain names node_modules/@acme/ui/onsystem.config.json as where the components come from under a rules-only app config, and onsystem.config.json, over … under one with components (fails on the previous head)
    • init --force over a rules-only config, over the package's paths copied in (node_modules/@acme/ui/src/*.tsx, with importPath and the theme) and over a glob that matches nothing: $schema only, and the findings of an app without a config (a guard: it passes on the previous head too, and failed on my first version of this fix, which spelled out every config with components)
    • docs: an app with no config or a rules-only one reads only node_modules/@acme/ui/theme.css, so its own --color-brand gets no exact match; with "tokens": ["app/globals.css", "node_modules/@acme/ui/theme.css"] all 8 rules still run and both are read; with ["app/globals.css"] alone the imported theme is not read (no spacing finding); the "field by field" sentence
  • test/design-system-package.test.ts: 2 new watch tests next to the existing one. An edit to the package's config is picked up by DesignSystemHost under the app's own config, and by the server's WorkspaceProjects under a root config. Both time out before the fix.

  • test/design-system-packages-docs.test.ts: a new test for the exclude and elements sentences.

  • test/design-system-packages-docs.test.ts checks each changed docs sentence and table row against check, inspect and the MCP tools, including:

    • all 8 rules from .d.ts
    • the token rules without it
    • components read in the app's scope or through importPath
    • the layered app config
    • "extends": false
    • init --force
    • subpath imports through a layered config
  • Gate, in this order, after rebasing on main: pnpm install --frozen-lockfile --prefer-offline, pnpm format:check, pnpm lint, pnpm typecheck, pnpm test (93 files passed, 1 skipped; 1219 tests passed, 3 skipped), pnpm build. pnpm smoke passed in the previous round; this round touches no server or tool code. I did not run the real-world corpus locally. No corpus repository has a config of its own next to a declaring dependency, or a root config, and dub-web-package (init in @dub/ui, app without a config) goes through the unchanged path. CI runs it on this pull request.

  • The third review's fixtures, with this branch and the previous head: in its app shapes (Next.js app/globals.css, Vite src/index.css, Remix app/tailwind.css, no components.json, each with npm and pnpm layouts, and the shadcn monorepo's 3 variants) the output of check, inspect, init --yes and the upgrade is identical between the two (11 shapes; findings unchanged after init in all 11 and after the upgrade in all 8 single-app shapes). In its other fixtures, only the own-components app changes: inspect shows 11 tokens again and check . reports 2 errors and 1 warning and exits 1, as on main. The previous head showed 2 tokens and 2 warnings, and exited 0.

  • The fifth review's round: every fixture directory of the first, third and fifth reviews (104), copied, with the previous head (4eb7a70) and this branch, running inspect, inspect --explain, check ., init --yes and init --yes --force (each followed by inspect and check) in its root and in each project folder. 74 give identical output. 21 differ only in the --explain line components come from …, which now names the package's config for apps that take its components. The other 9 are apps whose config names components of their own (owncomp-* in the third and fifth reviews, force-own-main, shape-next-npm-*, later-npm, later-pnpm): init --force writes their components again, and check then reads the app's own components and its stylesheet's tokens. In owncomp-* that is 1 component and 11 tokens, with 2 errors and 1 warning, the same as main's build gives there; the previous head gave 3 components, 2 tokens, 2 warnings and an icon-button-accessible-name error in the app's own components/ui/button.tsx. The fifth review's run-*.sh scripts (the 11 app shapes the third review also ran, the registry app, the monorepo, root init, extends, a package that declares its config after init) give identical output on both, except the force-own run. My first version of this fix spelled out every config that sets components; these runs showed it then wrote node_modules/@northwind/ui/src/components/*.tsx back into the first review's reg-main app and lost its tokens (2 findings instead of 7), and failed with "zero config found no design system" over a glob that matches nothing (ex). Hence the "no file outside the package" condition: both now give the previous head's output.

Follow-ups

  • An app that takes the package's components (no config, or one without components) reads only the tokens the package's config names, not its own stylesheet's, as an app without a config already did on main. Reading both by default is a follow-up; the docs now say it and give the tokens workaround.
  • README.md:77-78 still says installed apps on a compiled-only package get no component checks. That line belongs to X2-23-onsystem.
  • OS2-77 (wave 5) merges the dist-only messages into one. This PR only makes the current texts true.

…and so does the root's

An app with an onsystem.config.json of its own read none of the config
its design-system package declares. A config holding one rule severity,
{"rules":{"no-hardcoded-color":"warn"}}, in an app that installs such a
package lost the package's components and color tokens: check said "No
design system found" and exited 0. A monorepo root's rules and overrides
did not reach any app whose package declares a config, without a word.

- loadConfig reads the package's config under the project's own: what
  the project's config leaves out comes from the package's (components
  with their importPath, docs and elements; tokens; libraries), and its
  rules apply over the package's, rule by rule. "extends": false reads
  a config alone, as before.
- A workspace root's config, from the root, builds on the package's the
  same way (src/workspace.ts passes the root config with the lookup).
- inspect and inspect --explain name both configs ("onsystem.config.json,
  over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)").
- init --force in an app on such a package writes a config with only
  $schema, which keeps the package's design system. init at a monorepo
  root says its rules apply over the package's config.
- 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 node_modules/.pnpm/<name>@<version>_<peers>/..., which broke with
  "Token file not found" at the next upgrade.
- init in a package that publishes dist/ without its sources says apps
  read the components from the type declarations it publishes, when it
  names some; it said they get no component checks.
- The docs say what apps get from a package published with type
  declarations, and how app and root configs build on the package's.
…s-only projects, watch the package's config

- init in an app whose components zero config finds itself (components.json)
  and whose tokens include an installed package's stylesheet left "tokens"
  out but wrote "components". A config that names components turns
  detection off, so a stylesheet outside the usual places
  (app/styles/tailwind.css, and the package's theme it imports) was lost:
  the dry run said "No color tokens found". init now leaves "components" to
  zero config as well when a config that names them would read other token
  files, and says so. With app/globals.css it still writes the components.
- A workspace project whose design-system package's config gives it no
  component file (tokens only) is listed with a design system again when
  the root has a config: it was moved to "Without a design system".
- The MCP server watches the config a design-system package declares when
  the project reads it under a config of its own or the root's, so an edit
  to it is picked up live again.
- Docs and the changeset: the package's exclude patterns are added to the
  app's, and elements merge element by element. init's note in a
  design-system package says apps read its config under their own.
…okens and libraries

An app config that sets "components" but leaves "tokens" out took the
design-system package's tokens instead of zero config's stylesheets, so
the app's own app/globals.css, and what it imports, was no longer read.
With {"components":["components/ui/**/*.tsx"]}, an app/globals.css that
imports @acme/ui/theme.css and adds --color-brand and --radius-card, and
@acme/ui declaring tokens ["theme.css"], inspect went from 11 tokens to 2
and check . from 2 errors and 1 warning (exit 1) to 2 warnings (exit 0).
init writes such a config ({components}, no tokens) when the stylesheet
imports an installed package. "libraries" fell back to the package's the
same way, so a package's "libraries": false turned MUI off in such an app.

- The package's tokens and libraries now come only with its components,
  as importPath, docs and elements already did: a config that sets
  components of its own reads them as on main.
- "extends": "@acme/ui" says that extends is true or false and that the
  package is found from the dependencies, instead of "expected boolean,
  received string".
- inspect --explain with "extends": false names the dependency whose
  config was left out, and why.
- Docs, the changeset, the extends description (config.ts, schema.json)
  and init's note in a design-system package say so.
… the package's config in --explain

init --force in an app whose config names components of its own wrote a config with $schema
only whenever a dependency declared a config, so the app took the package's components, checked
its own components as app code, and lost its own tokens. It now writes $schema only when the
app's config takes the package's components: it sets no components, or ones that match no file
outside the package. A config with components of the app's own is written out again, as on
main.

inspect --explain named the app's config as where the components come from when they came from
the package's. ResolvedConfig.inherits says which one it is.

The docs say that an app taking the package's components reads only the package's tokens, with
the workaround, that the two configs layer field by field, and which apps init --force writes
$schema alone for.
@dgesteves
dgesteves force-pushed the fix/os2-76-layer-configs branch from 4eb7a70 to d0c8a0c Compare October 23, 2026 22:15
…package note with --components

init --force over a config that takes the package's components and lists
its own stylesheet beside the package's theme (the documented way to read
both) wrote $schema only and dropped that list: 12 tokens became 3 and the
colors went from errors to warnings. It now keeps the list when one of its
files is outside the package, and says so. A list of the package's files
only is still left to the package's config. With --components, init no
longer says the components come from the package's config.
@dgesteves
dgesteves merged commit ce19072 into main Oct 23, 2026
19 checks passed
@dgesteves
dgesteves deleted the fix/os2-76-layer-configs branch October 23, 2026 22:59
@dgesteves-release dgesteves-release Bot mentioned this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant