From c1c79216c87a20c9dbe2e6beb74024b0b8e751e9 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Sun, 6 Sep 2026 21:34:58 -0700 Subject: [PATCH] Point @source at the components again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@source` resolves relative to the stylesheet, and moving the stylesheet into src/theme/ moved the target with it: `@source "."` went from meaning `src/`, which holds the components, to meaning `src/theme/`, which holds none. A consumer's Tailwind auto-detects the consumer's own files and never walks node_modules, so that directive is the only thing putting this package's class strings in front of the compiler. The cost, measured by compiling the published stylesheet the way a consumer does: 0.1.1 emits 103,857 bytes with `h-8`, `line-clamp-1` and `py-2` present; 0.2.0 emits 22,248 with none of them. Every component ships with the token declarations and almost no utilities. A plain SelectTrigger renders 38px instead of 32 because `h-8` was never generated, which is how this surfaced — in a consumer's browser suite, since nothing in this repository compiles CSS. Nothing here could have caught it, so src/gates/design.test.ts now resolves every @source in the stylesheet and fails unless one of them reaches src/components. Confirmed failing against the broken value before the fix went in. --- package.json | 2 +- pnpm-lock.yaml | 90 ++++++++++++++++++++++------------------ src/gates/design.test.ts | 41 ++++++++++++++++++ src/theme/styles.css | 18 ++++++-- src/theme/tokens.test.ts | 4 +- 5 files changed, 109 insertions(+), 46 deletions(-) diff --git a/package.json b/package.json index 14bb24c..38783ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@robomous/ui-core", - "version": "0.2.0", + "version": "0.2.1", "description": "Robomous design system: twenty-one owned React components, the design tokens they resolve through, and the gates that hold the rules behind them.", "license": "Apache-2.0", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 397f622..80992f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.9.1) + version: 10.0.1(eslint@10.9.1(jiti@2.7.0)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -56,13 +56,13 @@ importers: version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.1.0 - version: 6.1.1(vite@8.2.2(@types/node@26.4.0)) + version: 6.1.1(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)) eslint: specifier: ^10.9.0 - version: 10.9.1 + version: 10.9.1(jiti@2.7.0) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@10.9.1) + version: 7.1.1(eslint@10.9.1(jiti@2.7.0)) jsdom: specifier: ^30.0.1 version: 30.0.1 @@ -83,10 +83,10 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.67.0 - version: 8.68.0(eslint@10.9.1)(typescript@6.0.3) + version: 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.4.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0)) + version: 4.1.11(@types/node@26.4.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)) packages: @@ -1569,6 +1569,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2314,9 +2318,9 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0))': dependencies: - eslint: 10.9.1 + eslint: 10.9.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2337,9 +2341,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.9.1)': + '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0))': optionalDependencies: - eslint: 10.9.1 + eslint: 10.9.1(jiti@2.7.0) '@eslint/object-schema@3.0.5': {} @@ -3254,15 +3258,15 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1)(typescript@6.0.3))(eslint@10.9.1)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.68.0(eslint@10.9.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1)(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1)(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.68.0 - eslint: 10.9.1 + eslint: 10.9.1(jiti@2.7.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -3270,14 +3274,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.68.0(eslint@10.9.1)(typescript@6.0.3)': + '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.68.0 '@typescript-eslint/types': 8.68.0 '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.68.0 debug: 4.4.3 - eslint: 10.9.1 + eslint: 10.9.1(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -3300,13 +3304,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.68.0 '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 - eslint: 10.9.1 + eslint: 10.9.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -3329,13 +3333,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.68.0(eslint@10.9.1)(typescript@6.0.3)': + '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.68.0 '@typescript-eslint/types': 8.68.0 '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - eslint: 10.9.1 + eslint: 10.9.1(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -3345,10 +3349,10 @@ snapshots: '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@26.4.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@26.4.0) + vite: 8.2.2(@types/node@26.4.0)(jiti@2.7.0) '@vitest/expect@4.1.11': dependencies: @@ -3359,13 +3363,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@26.4.0) + vite: 8.2.2(@types/node@26.4.0)(jiti@2.7.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -3498,11 +3502,11 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-react-hooks@7.1.1(eslint@10.9.1): + eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.8 - eslint: 10.9.1 + eslint: 10.9.1(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.5.4 zod-validation-error: 4.0.2(zod@4.5.4) @@ -3520,9 +3524,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.9.1: + eslint@10.9.1(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.7.0 @@ -3552,6 +3556,8 @@ snapshots: minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -3644,6 +3650,9 @@ snapshots: isexe@2.0.0: {} + jiti@2.7.0: + optional: true + js-tokens@4.0.0: {} jsdom@30.0.1: @@ -4021,13 +4030,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.68.0(eslint@10.9.1)(typescript@6.0.3): + typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1)(typescript@6.0.3))(eslint@10.9.1)(typescript@6.0.3) - '@typescript-eslint/parser': 8.68.0(eslint@10.9.1)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1)(typescript@6.0.3) - eslint: 10.9.1 + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -4067,7 +4076,7 @@ snapshots: dependencies: react: 19.2.8 - vite@8.2.2(@types/node@26.4.0): + vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -4077,11 +4086,12 @@ snapshots: optionalDependencies: '@types/node': 26.4.0 fsevents: 2.3.3 + jiti: 2.7.0 - vitest@4.1.11(@types/node@26.4.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0)): + vitest@4.1.11(@types/node@26.4.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -4098,7 +4108,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.4.0) + vite: 8.2.2(@types/node@26.4.0)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.4.0 diff --git a/src/gates/design.test.ts b/src/gates/design.test.ts index 9b7195d..7ad79c7 100644 --- a/src/gates/design.test.ts +++ b/src/gates/design.test.ts @@ -186,3 +186,44 @@ test("src/index.ts exports every component module in src/components", () => { `or move the module next to its only caller:\n${missing.join("\n")}`, ).toEqual([]); }); + +/** + * The stylesheet's `@source` still reaches the components. + * + * `styles.css` ships as source and a consumer's Tailwind compiles it. That + * compiler auto-detects the *consumer's* files and never walks + * `node_modules`, so `@source` is the only thing that puts this package's + * class strings in front of it. The directive resolves relative to the + * stylesheet, so moving the stylesheet moves the target — and pointing it one + * directory too shallow costs a consumer every utility that only this package + * writes, with no error anywhere: the CSS compiles, it is simply missing + * `h-8`, `line-clamp-1` and every variant utility the components rely on. + * + * Nothing else here can catch that, because nothing here compiles CSS. This + * resolves the path and asks whether the components are under it. + */ +test("the stylesheet's @source resolves to a directory that holds the components", () => { + const stylesheet = read("src/theme/styles.css"); + const directives = [...stylesheet.matchAll(/^\s*@source\s+"([^"]+)"\s*;/gm)].map((m) => m[1]); + expect(directives.length, "styles.css declares no @source, so it scans nothing").toBeGreaterThan( + 0, + ); + + const stylesheetDir = path.join(REPO, "src", "theme"); + const componentDir = path.join(REPO, "src", "components"); + + const reaching = directives.filter((spec) => { + const target = path.resolve(stylesheetDir, spec); + // `@source` scans a directory recursively, so it reaches the components + // when its target is the component directory or an ancestor of it. + const rel = path.relative(target, componentDir); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }); + + expect( + reaching, + "no @source in src/theme/styles.css reaches src/components, so a consumer's build would " + + "emit none of this package's own utilities. @source resolves relative to the stylesheet: " + + `from src/theme/ the components are at "..". Declared: ${directives.join(", ")}`, + ).not.toEqual([]); +}); diff --git a/src/theme/styles.css b/src/theme/styles.css index a9a6c4a..8bf2bb0 100644 --- a/src/theme/styles.css +++ b/src/theme/styles.css @@ -34,10 +34,22 @@ /* * The package's own components, so a class string that only ever appears - * inside `ui-core` still produces CSS in the consumer's build. Relative to - * this file, which is what `@source` takes. + * inside `ui-core` still produces CSS in the consumer's build. + * + * `@source` resolves **relative to this file**, and this file is in + * `src/theme/`. So the target is `..` — `src/`, which holds `components/` — + * and not `.`, which would be `src/theme/` and contains no component at all. + * A consumer's Tailwind auto-detects its own sources and never looks inside + * `node_modules`, so this directive is the only thing that puts these + * components' class strings in front of the compiler: point it one directory + * too shallow and every consumer builds a stylesheet with no `h-8`, no + * `line-clamp-1` and no variant utility in it, while every check in this + * repository stays green. + * + * `src/gates/design.test.ts` resolves this path and fails if it stops + * reaching the components. */ -@source "."; +@source ".."; @custom-variant dark (&:is(.dark *)); diff --git a/src/theme/tokens.test.ts b/src/theme/tokens.test.ts index 7f5bcc0..fdbd2b2 100644 --- a/src/theme/tokens.test.ts +++ b/src/theme/tokens.test.ts @@ -254,9 +254,9 @@ describe("structure", () => { }); it("reaches its own package's classes: @source after the imports", () => { - const sourceAt = STYLESHEET.indexOf('@source ".";'); + const sourceAt = STYLESHEET.indexOf('@source "..";'); const lastImportAt = STYLESHEET.lastIndexOf("@import"); - expect(sourceAt, 'styles.css has no @source ".";').toBeGreaterThan(-1); + expect(sourceAt, 'styles.css has no @source "..";').toBeGreaterThan(-1); expect(sourceAt).toBeGreaterThan(lastImportAt); });