From d346720c6ca3b7384a7e3b591d87d6ef3e027c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20R=C3=B6der?= Date: Thu, 17 Sep 2026 16:44:18 +0200 Subject: [PATCH 1/3] feat: resolve platform entry files for cssEntryFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cssEntryFile` takes one path, so every platform compiles the same stylesheet. That is a problem whenever a platform needs CSS the others must not get — web-only vendor styles you override, for instance, which are otherwise compiled into the native bundle as dead weight. A file named after a platform beside the configured entry now overrules it for that platform, the way Metro resolves `.ios` / `.native` modules: global.css the configured entry, and the fallback everywhere global.web.css used on web instead global.native.css used on iOS and Android instead global.ios.css used on iOS, in preference to global.native.css Suffixes are the platform variants Uniwind already generates (`ios`, `android`, `web`, `native`, `tv`, `android-tv`, `apple-tv`), so an entry is named after the prefix you would otherwise write inside it. Resolution is most-specific-first, and web does not fall back to `native`. Only `cssPath` changes, so `cssEntryFile` stays the configured path and the identity of the module Metro transforms — nothing else in the pipeline needs to know. `generateArtifacts` deliberately still reads the configured entry: the theme artifact is a single file for the whole install, so a per-platform value there would have the web and native transforms overwrite each other's copy. Fully backwards compatible: with no suffixed file present, `cssPath` returns exactly what it returned before. Co-Authored-By: Claude Opus 5 (1M context) --- packages/uniwind/src/bundler/config.ts | 39 ++++++++- .../bundler/css-entry-platform-files.test.ts | 85 +++++++++++++++++++ skills/uniwind/references/setup.md | 24 ++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/uniwind/tests/native/bundler/css-entry-platform-files.test.ts diff --git a/packages/uniwind/src/bundler/config.ts b/packages/uniwind/src/bundler/config.ts index f448b405..a57bde7b 100644 --- a/packages/uniwind/src/bundler/config.ts +++ b/packages/uniwind/src/bundler/config.ts @@ -4,8 +4,26 @@ import { UniwindCSSVisitor } from '@/bundler/css-visitor' import type { UniwindConfig, UniwindMetroConfig } from '@/bundler/types' import { Platform } from '@/common/consts' import { isDefined } from '@/common/utils' +import fs from 'fs' import path from 'path' +/** + * Which suffixed entries a platform accepts, most specific first. + * + * Mirrors how Metro resolves `.ios` / `.native` modules, including that web never falls back + * to `.native`. The suffixes are the platform variants Uniwind already generates, so an entry + * is named after the prefix you would otherwise write inside it. + */ +const CSS_ENTRY_PLATFORM_FALLBACKS: Record> = { + [Platform.Web]: [Platform.Web], + [Platform.iOS]: [Platform.iOS, Platform.Native], + [Platform.Android]: [Platform.Android, Platform.Native], + [Platform.Native]: [Platform.Native], + [Platform.TV]: [Platform.TV, Platform.Native], + [Platform.AndroidTV]: [Platform.AndroidTV, Platform.TV, Platform.Android, Platform.Native], + [Platform.AppleTV]: [Platform.AppleTV, Platform.TV, Platform.iOS, Platform.Native], +} + export class UniwindBundlerConfig { static fromMetroConfig(config: UniwindMetroConfig, platform?: string | null) { const getPlatform = () => { @@ -57,8 +75,27 @@ export class UniwindBundlerConfig { constructor(private readonly config: UniwindMetroConfig, readonly platform: Platform) {} + /** + * The stylesheet to compile, honouring a platform file beside the configured entry. + * + * `global.web.css` overrules `global.css` on web, `global.native.css` does on both native + * platforms, and so on. The configured entry stays the fallback and the identity of the + * module Metro transforms, so nothing else in the pipeline needs to know. + */ get cssPath() { - return path.join(process.cwd(), this.config.cssEntryFile) + const entryPath = path.join(process.cwd(), this.config.cssEntryFile) + const extension = path.extname(entryPath) + const stem = entryPath.slice(0, entryPath.length - extension.length) + + for (const suffix of CSS_ENTRY_PLATFORM_FALLBACKS[this.platform] ?? []) { + const platformEntryPath = `${stem}.${suffix}${extension}` + + if (fs.existsSync(platformEntryPath)) { + return platformEntryPath + } + } + + return entryPath } get themes() { diff --git a/packages/uniwind/tests/native/bundler/css-entry-platform-files.test.ts b/packages/uniwind/tests/native/bundler/css-entry-platform-files.test.ts new file mode 100644 index 00000000..701dcd95 --- /dev/null +++ b/packages/uniwind/tests/native/bundler/css-entry-platform-files.test.ts @@ -0,0 +1,85 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { UniwindBundlerConfig } from '../../../src/bundler/config' + +/** + * `cssPath` resolves against `process.cwd()`, so the entry is handed over as a path relative to + * it rather than chdir-ing the worker. + */ +const withEntries = (entries: Array, assert: (cssEntryFile: string) => void) => { + const root = mkdtempSync(join(tmpdir(), 'uniwind-css-entry-')) + + try { + entries.forEach(entry => writeFileSync(join(root, entry), '')) + + assert(relative(process.cwd(), join(root, 'global.css'))) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +test('uses the configured entry when no platform file sits beside it', () => { + withEntries(['global.css'], cssEntryFile => { + const forWeb = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'web') + const forIOS = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'ios') + + expect(forWeb.cssPath.endsWith('global.css')).toBe(true) + expect(forIOS.cssPath.endsWith('global.css')).toBe(true) + }) +}) + +test('prefers a platform file over the configured entry', () => { + withEntries(['global.css', 'global.web.css'], cssEntryFile => { + const forWeb = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'web') + const forIOS = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'ios') + + expect(forWeb.cssPath.endsWith('global.web.css')).toBe(true) + expect(forIOS.cssPath.endsWith('global.css')).toBe(true) + }) +}) + +test('falls back from a platform file to the native one', () => { + withEntries(['global.css', 'global.native.css'], cssEntryFile => { + const forIOS = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'ios') + const forAndroid = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'android') + + expect(forIOS.cssPath.endsWith('global.native.css')).toBe(true) + expect(forAndroid.cssPath.endsWith('global.native.css')).toBe(true) + }) +}) + +test('takes the more specific platform file when both exist', () => { + withEntries(['global.css', 'global.native.css', 'global.ios.css'], cssEntryFile => { + const forIOS = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'ios') + const forAndroid = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'android') + + expect(forIOS.cssPath.endsWith('global.ios.css')).toBe(true) + expect(forAndroid.cssPath.endsWith('global.native.css')).toBe(true) + }) +}) + +// Metro resolves `.native` for native platforms only, and web is not one of them. +test('never falls back to the native file on web', () => { + withEntries(['global.css', 'global.native.css'], cssEntryFile => { + const forWeb = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }, 'web') + + expect(forWeb.cssPath.endsWith('global.css')).toBe(true) + }) +}) + +test('resolves the TV entries when isTV maps the platform', () => { + withEntries(['global.css', 'global.native.css', 'global.ios.css', 'global.apple-tv.css'], cssEntryFile => { + const forAppleTV = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile, isTV: true }, 'ios') + + expect(forAppleTV.cssPath.endsWith('global.apple-tv.css')).toBe(true) + }) +}) + +test('an absent platform argument resolves the native entry', () => { + withEntries(['global.css', 'global.native.css'], cssEntryFile => { + const forNative = UniwindBundlerConfig.fromMetroConfig({ cssEntryFile }) + + expect(forNative.cssPath.endsWith('global.native.css')).toBe(true) + }) +}) diff --git a/skills/uniwind/references/setup.md b/skills/uniwind/references/setup.md index 17fd149a..230914e8 100644 --- a/skills/uniwind/references/setup.md +++ b/skills/uniwind/references/setup.md @@ -59,6 +59,30 @@ module.exports = withUniwindConfig(withOtherConfig(config, opts), { cssEntryFile module.exports = withOtherConfig(withUniwindConfig(config, { cssEntryFile: './global.css' }), opts); ``` +### Platform entry files + +A file named after a platform, sitting beside `cssEntryFile`, overrules it when Uniwind compiles for that platform — the same way Metro resolves `.ios` / `.native` modules. Keep pointing `cssEntryFile` at the base entry; the suffixed file is picked up behind it. + +``` +global.css # the configured entry, and the fallback for every platform +global.web.css # used on web instead +global.native.css # used on iOS and Android instead +global.ios.css # used on iOS, in preference to global.native.css +``` + +Suffixes are the platform variants Uniwind already generates — `ios`, `android`, `web`, `native`, `tv`, `android-tv`, `apple-tv` — so an entry is named after the prefix you would otherwise write inside it. Resolution is most-specific-first, and web never falls back to `native`. + +Use it when a platform needs stylesheets the others must not get — web-only vendor CSS you override, for instance, which would otherwise be compiled into the native bundle as dead weight: + +```css +/* global.web.css */ +@import 'tailwindcss'; +@import 'uniwind'; +@import './vendor-overrides.css'; +``` + +Each entry is compiled on its own, so every one of them must carry the full set of bare imports (`tailwindcss`, `uniwind`, …). Put the shared remainder in a file they both `@import`. + ### Vite Configuration (v1.2.0+) If user has storybook setup, add extra vite config: From b5989ed9a27497fb018f4f18f4bafcfc0ca2bf0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20R=C3=B6der?= Date: Thu, 17 Sep 2026 19:43:49 +0200 Subject: [PATCH 2/3] docs: record platform CSS entries in CONTEXT.md AGENTS.md asks for CONTEXT.md to be updated before a build contract changes, and this is one. Notes the resolution order, that the suffixes are the variants in artifacts/css/variants.ts, that only `cssPath` is affected while `cssEntryFile` stays the identity the Metro transformer matches, why `generateArtifacts` still reads the configured entry, and that each entry must carry the full set of bare imports. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f3ca4ab4..dcd8fcbd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -98,15 +98,24 @@ Shared runtime: Configuration shape: -- `cssEntryFile`: required CSS entry path, resolved from `process.cwd()`. +- `cssEntryFile`: required CSS entry path, resolved from `process.cwd()`. A sibling platform file overrules it for that platform. - `extraThemes`: optional named themes added to default `light` and `dark`. - `dtsFile`: optional generated declaration file path, default `uniwind-types.d.ts`. - Metro-only `polyfills.rem`: custom rem base, default `16`. - Metro-only `debug` and `isTV` flags exist in types. +Platform entry files: + +- `UniwindBundlerConfig.cssPath` resolves a sibling `..css` before falling back to the configured `cssEntryFile`, mirroring how Metro resolves `.ios` / `.native` modules. +- Suffixes are the platform variants in `artifacts/css/variants.ts`: `ios`, `android`, `web`, `native`, `tv`, `android-tv`, `apple-tv`. +- Order is most specific first — `ios` then `native`, `apple-tv` then `tv` then `ios` then `native`. Web never falls back to `native`. +- Only `cssPath` is affected. `cssEntryFile` remains the configured path, the identity the Metro transformer matches, and the module Metro transforms. +- `generateArtifacts` deliberately still reads `cssEntryFile`: the theme artifact is one file per install, so a per-platform value there would let web and native transforms overwrite each other. +- Each entry compiles independently, so every entry must carry the full set of bare imports (`tailwindcss`, `uniwind`, ...). Shared content belongs in a file the entries `@import`. + Compilation flow: -- `compileTailwind` reads `cssEntryFile`, runs Tailwind v4 compile, scans files under the CSS entry directory, and builds final CSS. +- `compileTailwind` reads `cssPath`, runs Tailwind v4 compile, scans files under the CSS entry directory, and builds final CSS. - `compileCSS` routes to web or native by platform. - `compileWebCSS` runs Lightning CSS with `UniwindCSSVisitor` and returns CSS. - `compileNativeCSS` runs `ProcessorBuilder`, serializes variables, scoped variables, and native stylesheet metadata into JS source. From 4ab611c072c8550d1325837208ef2b2586aa24be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20R=C3=B6der?= Date: Thu, 17 Sep 2026 19:49:21 +0200 Subject: [PATCH 3/3] docs: list every platform fallback chain in CONTEXT.md The previous wording gave iOS, Apple TV and web as examples, which left `android` and `android-tv` unspecified. All seven are now written out in full. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index dcd8fcbd..54d3988a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -108,7 +108,15 @@ Platform entry files: - `UniwindBundlerConfig.cssPath` resolves a sibling `..css` before falling back to the configured `cssEntryFile`, mirroring how Metro resolves `.ios` / `.native` modules. - Suffixes are the platform variants in `artifacts/css/variants.ts`: `ios`, `android`, `web`, `native`, `tv`, `android-tv`, `apple-tv`. -- Order is most specific first — `ios` then `native`, `apple-tv` then `tv` then `ios` then `native`. Web never falls back to `native`. +- Each platform tries its suffixes in order, most specific first, then falls back to the configured entry: + - `web`: `web` + - `ios`: `ios`, `native` + - `android`: `android`, `native` + - `native`: `native` + - `tv`: `tv`, `native` + - `android-tv`: `android-tv`, `tv`, `android`, `native` + - `apple-tv`: `apple-tv`, `tv`, `ios`, `native` +- Web is the only platform with no `native` fallback, matching how Metro resolves modules. - Only `cssPath` is affected. `cssEntryFile` remains the configured path, the identity the Metro transformer matches, and the module Metro transforms. - `generateArtifacts` deliberately still reads `cssEntryFile`: the theme artifact is one file per install, so a per-platform value there would let web and native transforms overwrite each other. - Each entry compiles independently, so every entry must carry the full set of bare imports (`tailwindcss`, `uniwind`, ...). Shared content belongs in a file the entries `@import`.