Skip to content

fix: package mode: theme partials, subpath exports with no file, dist-only message - #250

Merged
dgesteves merged 5 commits into
mainfrom
fix/os2-77-package-mode
Oct 28, 2026
Merged

dgesteves merged 5 commits into
mainfrom
fix/os2-77-package-mode

Conversation

@dgesteves

@dgesteves dgesteves commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Roadmap item: OS2-77 (package-mode correctness: theme partials, subpath imports, one dist-only message).

What was wrong

  • A Tailwind theme partial turned off no-hardcoded-spacing in every app. A Tailwind v4 design-system package ships a theme that does not @import "tailwindcss" (the app does). When an app takes on the package's config, those tokens replace the app's own stylesheet, so no spacing token exists and the rule is skipped. In an installed app on @northwind/ui (tokens ["theme.css"], a partial), p-[13px] was not reported. The same app with its own config naming both stylesheets reports it, with p-3 as the nearest step.
  • An invented subpath import was waved through in a workspace app, with wrong advice. With "./components/*": "./src/components/*.tsx" and no src/components/tooltip.tsx, <Tooltip> from @acme/ui/components/tooltip was not reported. check said instead: Extraction looks incomplete: ... add the missing files to "components" in the config. The file does not exist, so the import cannot compile. The same import from the package installed in node_modules was already reported, so the two layouts disagreed. Cause: outsideModel in src/lint/target.ts treated any module declared in exports but not on disk as possibly real.
  • In an app whose package publishes no sources, list_components and get_component pointed at the wrong fix. They said Check the "components" globs in the config. The app has no config. The cause is that the package publishes neither its component sources nor type declarations, and writing an app config to follow that advice loses the package's tokens.
  • init wrote ONSYSTEM.md "for the apps' teams" but left it out of files, so apps that install the package never got it.

What changed

  • src/tokens/index.ts: some token stylesheets may be a Tailwind v4 theme (@theme) where none imports Tailwind. If the project is on Tailwind v4, the theme now gets Tailwind's default spacing unit and radius scale, as a stylesheet that imports Tailwind does. On v4 means: @tailwindcss/postcss, @tailwindcss/vite or @tailwindcss/cli in its package.json, or tailwindcss in a range that is not v3's (^3, ~3, 3.x). Tailwind v3 has no --spacing and no rounded-xs or rounded-4xl, and its plugins (@tailwindcss/typography) run on either version, so they do not count. If the theme clears the defaults (--spacing-*: initial), it gets none. Of the finding's two proposals, I took "treat an app on Tailwind v4 as having the default --spacing". The other, reading the app's zero-config stylesheet, would need src/config.ts and src/design-system.ts, which fix(server): retry failed loads, reload JS configs, answer per path when --root holds projects #242 has open. The root package.json is already in the model's cache key.

  • src/lint/target.ts: an import with no file behind it now counts as invented in one case. The package's exports, as its package.json writes them, map it onto source files only (.ts/.tsx, or .jsx outside a build folder), and none of them is on disk. no-unknown-component reports it. The entry is the one Node.js picks, and its targets are every condition but types (exportedTargets, a new helper in src/detect.ts built on the existing exportMatches). Node.js picks an exact subpath, else the pattern with the longest prefix before its *, then the longest key. exportMatches now breaks that last tie as Node.js does (PATTERN_KEY_COMPARE), where it used to keep the object's order, and its * now stands for at least one character. A .jsx target counts as there when the .tsx file TypeScript reads for it is. Anything else is still only called unchecked:

    • an entry with a target in a build folder (dist, build, lib, out, esm, cjs), whatever its extension, built or not ("./*": { "types": "./dist/components/*.d.ts", "default": "./dist/components/*.js" }, or "./*": "./dist/*.tsx"), whose source may be any file the build reads (src/components/tooltip.ts);
    • an entry with a null target;
    • an import no entry maps.

    config.imports is only used to find the package's folder. Its targets cannot decide this, since they map dist/ back to src/*.tsx. The folder is the nearest package.json with a name, so a nested {"type": "module"} in src/components is passed over.

  • src/explain.ts: new unpublishedComponents(ds), one message for an app that takes on the config of an installed package and knows no component from it: @acme/ui publishes neither its component sources nor their type declarations, so its components are unknown here and the component rules did not run: ask its team to add its sources to "files" in its package.json, or to publish type declarations. When the package does publish declarations and "libraries": false in the config turned them off, it says that instead, and that "libraries": [] reads them and no library. It adds a line about the token rules when there are tokens, then the docs link. inspect --explain prints it.

  • src/server/render.ts and src/server/index.ts: list_components and get_component use that message. noComponentsMessage takes the reason as an optional fourth argument. renderNotFound drops Available: . when the list is empty.

  • src/init.ts: ONSYSTEM.md is added to files next to the config, unless files leaves it out ("!ONSYSTEM.md"), which is the team's call. The note names only the files it added.

  • docs/design-system-packages.md documents all of the above. It also says that a config with "extends": false and copied paths does not read the package's exports. In a workspace app, such a config leaves an invented subpath import unchecked rather than reported. docs/rules.md names the theme-partial case next to Tailwind's defaults.

  • Patch changeset.

Three existing tests assert on what changed:

  • test/design-system-package.test.ts and test/init-registry-apps.test.ts: the files list and the init note;
  • test/design-system-packages-docs.test.ts: the published files, the list_components and get_component text, and the invented <Badge> in the subpath layout, which the package's config now reports.

How it was tested

test/package-mode.test.ts has 31 tests. Run against main's src/, 13 fail and 18 pass:

  • the partial gets Tailwind's spacing unit and 8 radii, and p-[13px] is reported, both installed and as a workspace package; @tailwindcss/postcss counts as v4 next to a tailwindcss ^3 range (3 tests)
  • <Tooltip> from the missing subpath is no-unknown-component, while <StarIcon>, whose own export leads to an unbuilt dist/, is still only unchecked; the same past a nested package.json with no name; with "./*": "./src/components/*.jsx", <Tip> from src/components/tip.tsx is unchecked and the invented <Popover> is reported (3 tests)
  • list_components and get_component give the unpublished-sources message, and the "libraries": false one when that turned the declarations off (2 tests)
  • renderNotFound with an empty list no longer prints Available: .
  • inspect --explain prints the message
  • init puts ONSYSTEM.md in files, names only the files it added, and leaves ONSYSTEM.md out under "!ONSYSTEM.md" (3 tests)

The 18 that pass on main are guards:

  • nothing is added in an app that does not depend on Tailwind, or on Tailwind v3 (^3.4.0, ~3.4.17, 3.x), or when the partial clears the spacing namespace (5 tests);
  • <Tooltip> from @acme/ui/tooltip, through "./*" into dist/components with its source in src/components/tooltip.ts, is only unchecked, built or not, linked into node_modules or not (4 tests). The first version of this branch reported it in all 4.
  • <Tip> from @acme/ui/components/tip.js, with "./components/*" into src/components/*.tsx and "./components/*.js" into a built dist/components, in either order, and the same with "./*" and "./*.js", is only unchecked, linked or not (8 tests). In the linked tests, Node.js resolves the import to dist/components/tip.js (createRequire(...).resolve); import.meta.resolve gives the same for all four orders. The previous version of this branch reported it in the 4 where the shorter key comes first.
  • an unbuilt "./components/*": "./dist/components/*.tsx" leaves <Popover> unchecked, which the previous version reported.

I also ran the built CLI on the reviewer's subpath layouts: the shadcn/ui monorepo template with variants of its exports, each with and without a pnpm-style link and with init's config. On the 8 dist-star layouts with init's config, the first version of this branch reported "Tooltip" is not a design-system component, and this one says it is unchecked. The other layouts give the same results as the first version, except the 4 with a source condition before a dist/ one: the invented <Tooltip> there is unchecked again, as on main. The pattern-order fix changes none of these 44 results. In the reviewer's Tailwind v3 app on a v4 partial, inspect shows 7 tokens, down from 12 on the first version, and none of v4's defaults.

Gate, all passing:

  • pnpm install --frozen-lockfile --prefer-offline
  • pnpm format:check
  • pnpm lint
  • pnpm typecheck
  • pnpm test: 105 files passed, 1 skipped; 1355 tests passed, 3 skipped
  • pnpm build: schema.json unchanged
  • pnpm smoke: passed
  • pnpm corpus: "Counts match corpus/snapshot.json." No snapshot change, so the README rate is untouched. The blind sample fp-sample-20261016 still has 80 of its 80 labels reported.

Follow-ups (outside this PR's files)

  • check's notice in a dist-only app (notice() in src/design-system.ts, open in fix(server): retry failed loads, reload JS configs, answer per path when --root holds projects #242) should use unpublishedComponents.
  • The hook (plugins/onsystem/hooks/check-ui.mjs) should drop its get_component hint when there are no components.
  • runsTailwindV4 reads only the app's own package.json: a tailwindcss hoisted to the monorepo root is not seen, and a catalog: or workspace:* range counts as v4, since it does not say. Reading the root's package.json, the pnpm catalog or the installed package would settle it.
  • An installed package (not a workspace one) whose "./*" leads into a built dist/components reports <Tooltip> as unknown when its source is not in the components glob. Main does the same.
  • pnpm store paths (node_modules/.pnpm/...) printed through the node_modules/<name> link, in inspect and --explain (src/cli.ts, src/detect.ts, src/config.ts).
  • Sibling token packages written by init as package specifiers (@acme/tailwind-config/theme.css) and resolved in installed apps (src/config.ts). init already warns about ../ token paths on main.
  • A corpus case with a theme-partial package (corpus/repos.json).

…-only message

A design-system package whose tokens are a Tailwind v4 theme partial
(@theme, no @import "tailwindcss", since the app imports Tailwind) gave
its apps no spacing token, so no-hardcoded-spacing went quiet and
p-[13px] was not reported. loadTokens now gives such a partial
Tailwind's default spacing unit and radius scale when the project
depends on Tailwind, unless the partial clears them.

In a workspace app, <Tooltip> from @acme/ui/components/tooltip, where
"./components/*" maps onto src/components/*.tsx and there is no
tooltip.tsx, was said to be unchecked, with advice to add the missing
files to "components". An export that maps a specifier onto a source
file that is not there now means no such module, and no-unknown-component
reports it. An export that comes first and leads to dist/, not built
yet, still leaves it unchecked.

list_components and inspect --explain, in an app whose installed
package publishes neither component sources nor type declarations, say
so and what its team can do, instead of pointing at the components
globs. renderNotFound drops "Available: ." for an empty list.

init adds ONSYSTEM.md to "files" next to the config, so apps that
install the package get the guide.
…'s files note

- An import through an export into a build folder is no longer reported as
  no-unknown-component when its source has another name: with
  "./*": { types: "./dist/components/*.d.ts", default: "./dist/components/*.js" }
  and src/components/tooltip.ts, <Tooltip> from @acme/ui/tooltip is said
  not to have been checked, built or not, linked or not. The decision now
  comes from the package's exports as its package.json writes them
  (exportedTargets in src/detect.ts), not from config.imports, which maps
  dist/ back to src/*.tsx. Only an entry whose targets are all source
  files, none on disk, is reported.
- The package is found past a package.json with no name in its folders
  ({"type": "module"} in src/components).
- With "libraries": false in the app's config and a package that publishes
  type declarations, list_components, get_component and inspect --explain
  say that "libraries": false turned them off, and that [] reads them.
- get_component gives the same reason as list_components when there are
  no components.
- A theme partial gets Tailwind's default spacing unit and radius scale
  only on Tailwind v4: not with tailwindcss ^3, ~3 or 3.x, unless
  @tailwindcss/postcss, /vite or /cli is listed.
- init names only the files it added to "files", and leaves ONSYSTEM.md
  out when "files" excludes it ("!ONSYSTEM.md").
- The changeset, docs/design-system-packages.md and docs/rules.md say so.
…rgets

- exportMatches orders two patterns with the same prefix by key length, as
  Node.js's PATTERN_KEY_COMPARE does, and a pattern's * stands for at least
  one character. With "./components/*": "./src/components/*.tsx" and
  "./components/*.js" into dist/components, in either order, <Tip> from
  "@acme/ui/components/tip.js" resolves to dist/components/tip.js, as
  Node.js resolves it, and is no longer reported as no-unknown-component.
- A .jsx export target counts as there when the .tsx file TypeScript reads
  for it is.
- A target in a build folder (dist, build, lib, out, esm, cjs) is never
  taken for a missing source file, whatever its extension: an unbuilt
  "./*": "./dist/*.tsx" stays unchecked, as the docs say.
@dgesteves
dgesteves merged commit d163348 into main Oct 28, 2026
19 checks passed
@dgesteves
dgesteves deleted the fix/os2-77-package-mode branch October 28, 2026 11:19
@dgesteves-release dgesteves-release Bot mentioned this pull request Oct 28, 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