Skip to content
Closed
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
21 changes: 19 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,32 @@ 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 `<entry>.<platform>.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`.
- 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`.

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.
Expand Down
39 changes: 38 additions & 1 deletion packages/uniwind/src/bundler/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, Array<Platform>> = {
[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],
Comment on lines +15 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Build contract is undocumented

This adds a public build and runtime contract for platform-specific CSS entries without updating CONTEXT.md. The repository requires CONTEXT.md to be updated whenever public APIs, build/runtime contracts, supported platforms, or architecture change. This requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

}

export class UniwindBundlerConfig {
static fromMetroConfig(config: UniwindMetroConfig, platform?: string | null) {
const getPlatform = () => {
Expand Down Expand Up @@ -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
Comment on lines +93 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Platform CSS stays stale

When a developer creates, edits, or removes a platform entry such as global.web.css while Metro is running, Metro still tracks only the configured base entry. The selected platform file is read directly without being registered as a graph dependency, so Metro may not rerun the base transform and can serve stale platform CSS until it is restarted. Register the resolved entry as a dependency or otherwise invalidate the base transform when sibling entries change.

Knowledge Base Used: Bundler adapters and generated artifacts

Fix in Claude Code Fix in Codex

}
}

return entryPath
}

get themes() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>, 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)
})
})
24 changes: 24 additions & 0 deletions skills/uniwind/references/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Platform scope is unclear

This presents platform entry files as a general configuration feature, but only Metro compiles through cssPath. Vite and the artifact CLI continue using the configured base entry, so a Vite user who follows this section and adds global.web.css will have that file silently ignored. Clarify that this feature is Metro-only, or support the same selection in the other documented build paths.

Knowledge Base Used:

Fix in Claude Code Fix in Codex


```
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:
Expand Down