diff --git a/.changeset/cli-generated-app-tailwind-v4-3852.md b/.changeset/cli-generated-app-tailwind-v4-3852.md new file mode 100644 index 0000000000..49f0828ce2 --- /dev/null +++ b/.changeset/cli-generated-app-tailwind-v4-3852.md @@ -0,0 +1,17 @@ +--- +'@object-ui/cli': patch +--- + +修复 `objectui dev` 生成的临时 app 的 CSS 管线:整套从 Tailwind 3 迁到 Tailwind 4 + +生成器写出的样式面此前是完整的 v3 三件套 —— `src/index.css` 用 `@tailwind base/components/utilities` 指令、`postcss.config.js` 写 v3 的 `tailwindcss: {}` 插件键、外加一份 `tailwind.config.js` —— 而仓内与 `@object-ui/components` 都已在 v4(components 的 peer 是 `tailwindcss ^4.2.1`)。两个后果都是真的: + +- **仓内 `objectui dev` 今天不出样式。** `commands/dev.ts` 的 monorepo 分支把 `require('tailwindcss')(configPath)` 当 PostCSS 插件调用,v4 下这条路径只会抛 "moved to `@tailwindcss/postcss`",而该异常被 `try/catch` 吞成一行黄字警告;`css.postcss` 因此没被设上,Vite 退回去搜配置文件,`/src/index.css` 请求最终 500(实测:`Failed to load PostCSS config … Cannot find module '@tailwindcss/postcss'`),浏览器里一条样式都没有。 +- **仓外一次干净安装会 ERESOLVE。** 生成清单声明 `tailwindcss ^3.4.19`,与它依赖的 `@object-ui/components` 的 v4 peer 冲突。 + +改动: + +- `src/index.css` 改为仓内惯用的 v4 CSS-first 写法(`@import 'tailwindcss'` + `@custom-variant dark` + `@source` + `@theme`),`@theme` 的 token 集与 `packages/components/src/index.css` 逐条对齐 —— 包含 v3 config 一直缺、而生成的 `src/Layout.tsx` 自己就在用的 8 个 `sidebar-*` token。 +- `postcss.config.js` 改写 `'@tailwindcss/postcss': {}`;`tailwind.config.js` 不再生成(v4 下没有 `@config` 指向它时它就是死文件,仓内本身也零个 `tailwind.config.*`),v3 的 `content` 扫描面等价迁为 `@source`。 +- 清单:`tailwindcss` 抬到 `^4.3.3` 并新增 `@tailwindcss/postcss ^4.3.3`,两者都锚回仓内(#3827 记的 `TAILWIND_V3_DEFERRED` 记账钉随之翻转)。 +- `commands/dev.ts` 改用 `@tailwindcss/postcss`,并由 `@object-ui/cli` 自己声明这两个插件包;加载失败不再吞成警告,而是带修法响亮报错 —— 静默无样式正是这个缺陷能潜伏这么久的原因。 diff --git a/packages/cli/package.json b/packages/cli/package.json index d340fa89fb..255afd6bb6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,8 +56,10 @@ "@object-ui/components": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", + "@tailwindcss/postcss": "^4.3.3", "@types/glob": "^9.0.0", "@vitejs/plugin-react": "^6.0.5", + "autoprefixer": "^10.5.4", "chalk": "^6.0.0", "commander": "^15.0.0", "express": "^4.22.2", diff --git a/packages/cli/src/__tests__/app-generator.test.ts b/packages/cli/src/__tests__/app-generator.test.ts index a79b08f4da..4818e35ca9 100644 --- a/packages/cli/src/__tests__/app-generator.test.ts +++ b/packages/cli/src/__tests__/app-generator.test.ts @@ -282,12 +282,14 @@ function inRepoRangesOf(name: string): Record { * - `in-repo` — the root does not, but sibling manifests do, unanimously. * - `cli-version` — derived from this CLI's own version at generation time, not * a literal at all (see `platformPackageRange` in `app-generator.ts`). - * - `deferred-tailwind-v4` — see `TAILWIND_V3_DEFERRED` below. + * + * There is no longer a fourth kind: `deferred-tailwind-v4` existed only to hold + * `tailwindcss` at `^3.4.19` while the generated CSS pipeline was still v3, and + * objectui#3852 migrated that pipeline — so Tailwind anchors to this repo like + * everything else. See `keeps the generated Tailwind pipeline v4 end to end` + * below for what replaced the ledger. */ -const DEPENDENCY_ANCHORS: Record< - string, - 'root' | 'in-repo' | 'cli-version' | 'deferred-tailwind-v4' -> = { +const DEPENDENCY_ANCHORS: Record = { '@object-ui/components': 'cli-version', '@object-ui/plugin-charts': 'cli-version', '@object-ui/plugin-editor': 'cli-version', @@ -297,6 +299,7 @@ const DEPENDENCY_ANCHORS: Record< '@object-ui/plugin-markdown': 'cli-version', '@object-ui/plugin-view': 'cli-version', '@object-ui/react': 'cli-version', + '@tailwindcss/postcss': 'in-repo', '@types/react': 'root', '@types/react-dom': 'root', '@vitejs/plugin-react': 'in-repo', @@ -306,29 +309,40 @@ const DEPENDENCY_ANCHORS: Record< react: 'root', 'react-dom': 'root', 'react-router-dom': 'root', - tailwindcss: 'deferred-tailwind-v4', + tailwindcss: 'root', typescript: 'root', vite: 'root' }; +/** `--color-*` / `--radius-*` token names declared by a `@theme` block. */ +function themeTokensOf(css: string): string[] { + const block = /@theme\s*\{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''; + return [...block.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map((match) => match[1]).sort(); +} + +/** Custom properties a stylesheet declares outside its `@theme` block. */ +function declaredCustomProperties(css: string): Set { + const outsideTheme = css.replace(/@theme\s*\{[\s\S]*?\n\}/g, ''); + return new Set([...outsideTheme.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map((match) => match[1])); +} + +/** Custom properties a `@theme` block resolves THROUGH, e.g. `hsl(var(--card))`. */ +function customPropertiesReferencedByTheme(css: string): string[] { + const block = /@theme\s*\{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''; + return [...new Set([...block.matchAll(/var\((--[a-z0-9-]+)\)/g)].map((match) => match[1]))].sort(); +} + /** - * The Tailwind entries this PR deliberately does NOT re-anchor, and why. - * - * This repo is on Tailwind 4 (`^4.3.3` at the root, `@tailwindcss/postcss` in - * every in-repo `postcss.config.js`) and `@object-ui/components` declares - * `tailwindcss: ^4.2.1` as a PEER — so `^3.4.19` here is not merely drift, it - * conflicts with a peer of a package the generated app depends on. + * A `^x.y.z` range's floor, for comparing a generated range against a peer's. * - * Anchoring it is still not a version edit: the generated `src/index.css` uses - * v3 directives (`@tailwind base;`), the generated `postcss.config.js` names - * the `tailwindcss` plugin key that v4 moved to `@tailwindcss/postcss`, and the - * generated `tailwind.config.js` is a v3 config whose `content` globs became - * `@source` in v4. Bumping the range without rewriting those three files - * produces an app that installs and renders unstyled — a worse failure than the - * honest v3 trio, because it looks fixed. Filed as objectui#3852 with the - * measurements; kept internally consistent at v3 until then. + * Enough semver for the one question asked below — whether the Tailwind the + * generated app installs can satisfy `@object-ui/components`' peer — without + * importing a `semver` this package does not declare. */ -const TAILWIND_V3_DEFERRED = ['tailwindcss']; +function caretFloor(range: string): [number, number, number] { + const [major, minor, patch] = range.replace(/^\^/, '').split('.').map(Number); + return [major, minor, patch]; +} function dependenciesOf(manifest: Record): Record { return (manifest.dependencies ?? {}) as Record; @@ -452,11 +466,6 @@ describe('generated app manifests', () => { const generated = routed[name] ?? plain[name]; expect(generated, `${name} must be declared by at least one generator`).toBeTruthy(); - if (anchor === 'deferred-tailwind-v4') { - expect(TAILWIND_V3_DEFERRED).toContain(name); - continue; - } - if (anchor === 'cli-version') { expect(generated, `${name} must track this CLI's own version`).toBe(`^${cliVersion}`); continue; @@ -485,7 +494,7 @@ describe('generated app manifests', () => { // declared both places must already agree, so reading one instead of the // other cannot hide a drift. for (const [name, anchor] of Object.entries(DEPENDENCY_ANCHORS)) { - if (anchor === 'cli-version' || anchor === 'deferred-tailwind-v4') continue; + if (anchor === 'cli-version') continue; const rootRange = rootRangeOf(name); if (rootRange === undefined) continue; for (const [range, manifests] of Object.entries(inRepoRangesOf(name))) { @@ -525,21 +534,28 @@ describe('generated app manifests', () => { } }); - it('declares the tailwind trio at v3 deliberately, not by drift', () => { - // The deferral is an explicit, reviewed act: the generated CSS pipeline is - // v3 end to end, so the range matches the files beside it. Re-anchoring it - // means migrating those files (objectui#3852). Adding a second deferred - // entry has to edit this list. - expect(TAILWIND_V3_DEFERRED).toEqual(['tailwindcss']); - expect(allRangesOf(buildRoutedAppPackageJson()).tailwindcss).toBe('^3.4.19'); - expect(routedFiles()['src/index.css']).toContain('@tailwind base;'); - expect(routedFiles()['postcss.config.js']).toContain('tailwindcss: {}'); - // And the conflict this leaves standing, named rather than hidden: the - // components package the generated app depends on peers Tailwind 4. + it('installs a Tailwind that satisfies the components peer it depends on', () => { + // Evidence 1 of objectui#3852, now a gate. The generated app depends on + // `@object-ui/components`, which peers `tailwindcss ^4.2.1`; the generated + // devDependency was `^3.4.19`, so once objectui#3827 made the `@object-ui/*` + // ranges resolvable, a clean install outside this repo hit ERESOLVE. Judged + // by comparison, not by two literals, so bumping either side alone is red. const components = JSON.parse( readFileSync(resolve(REPO_ROOT, 'packages/components/package.json'), 'utf-8') ) as { peerDependencies?: Record }; - expect(components.peerDependencies?.tailwindcss).toBe('^4.2.1'); + const peer = components.peerDependencies?.tailwindcss as string; + const generated = allRangesOf(buildRoutedAppPackageJson()).tailwindcss; + expect(peer, 'components must peer a Tailwind version at all').toBeTruthy(); + + const [peerMajor, peerMinor, peerPatch] = caretFloor(peer); + const [genMajor, genMinor, genPatch] = caretFloor(generated); + expect(genMajor, `generated ${generated} must share a major with the peer ${peer}`).toBe( + peerMajor + ); + expect( + genMinor * 1_000_000 + genPatch, + `generated ${generated} is below the peer floor ${peer}` + ).toBeGreaterThanOrEqual(peerMinor * 1_000_000 + peerPatch); }); it('writes both maps empty inside a workspace, as before', () => { @@ -604,6 +620,146 @@ describe('generated app file maps', () => { }); }); +/** + * The generated CSS pipeline (objectui#3852). + * + * What objectui#3827 recorded as `TAILWIND_V3_DEFERRED` and this describe block + * replaces: the generated app shipped a complete Tailwind 3 trio — `@tailwind + * base/components/utilities` directives, a `tailwindcss`-keyed PostCSS config, + * and a `tailwind.config.js` — into a repo that has been on Tailwind 4 (and + * carried zero `tailwind.config.*` files) since its own migration, while + * depending on a `@object-ui/components` that peers `tailwindcss ^4.2.1`. + * + * These gates judge the pipeline's SHAPE, which is all a unit test can reach: an + * `@import 'tailwindcss'` is resolved by `@tailwindcss/postcss` relative to the + * stylesheet's own directory, so compiling a generated `index.css` for real + * needs a `node_modules` beside the fixture ("Can't resolve 'tailwindcss' in + * …/app/src" when there is none). That end of it was verified in a browser + * against a running `objectui dev` instead — readings in the PR. + */ +describe('generated Tailwind 4 pipeline', () => { + const V3_FOSSILS = ['@tailwind base;', '@tailwind components;', '@tailwind utilities;', '@apply ']; + + const everyShape = () => ({ + plain: plainFiles(), + routed: routedFiles(), + plainInWorkspace: buildAppFiles(SCHEMA, IN_WORKSPACE), + routedInWorkspace: buildRoutedAppFiles(ROUTES, APP_CONFIG, IN_WORKSPACE) + }); + + it('writes a v4 CSS-first entrypoint, with no v3 directive left anywhere', () => { + for (const [shape, files] of Object.entries(everyShape())) { + const css = files['src/index.css']; + expect(css, `${shape}: must import Tailwind the v4 way`).toContain(`@import 'tailwindcss';`); + expect(css, `${shape}: dark variant must follow the .dark class`).toContain( + '@custom-variant dark (&:where(.dark, .dark *));' + ); + for (const fossil of V3_FOSSILS) { + expect(css.includes(fossil), `${shape}: v3 leftover ${fossil}`).toBe(false); + } + } + }); + + it('names v4 PostCSS plugin package, not the key that throws', () => { + for (const [shape, files] of Object.entries(everyShape())) { + const config = files['postcss.config.js']; + expect(config, `${shape}: must name the v4 plugin package`).toContain( + `'@tailwindcss/postcss': {}`); + // The v3 key resolves to a shim that throws; matched on the bare + // `tailwindcss:` form so the quoted v4 spelling above does not satisfy it. + expect(/(^|[^'"])tailwindcss:/.test(config), `${shape}: v3 plugin key`).toBe(false); + } + }); + + it('writes no tailwind.config.js, because v4 would never read it', () => { + // A config file is inert in v4 unless a stylesheet points `@config` at it, + // and none does. Writing one anyway is how the v3 `content` globs stayed the + // apparent source of truth while nothing consumed them. `commands/dev.ts` + // used to pass this path to `tailwindcss()`; it no longer exists either. + for (const [shape, files] of Object.entries(everyShape())) { + expect(Object.keys(files), `${shape}`).not.toContain('tailwind.config.js'); + expect(files['src/index.css'].includes('@config'), `${shape}`).toBe(false); + } + }); + + it('translates the v3 content globs into @source, workspace globs included', () => { + // The v3 `content` list was `['./index.html', './src/**/*.{js,ts,jsx,tsx,json}']`, + // widened inside a workspace with two absolute globs built from `cwd`. All + // four survive as `@source`, with the app-relative pair rewritten against + // `src/index.css`'s own directory (that is what a relative `@source` + // resolves against — measured, not assumed). + const inWorkspace = buildRoutedAppFiles(ROUTES, APP_CONFIG, IN_WORKSPACE)['src/index.css']; + expect(inWorkspace).toContain(`@source '../index.html';`); + expect(inWorkspace).toContain(`@source '../src/**/*.{js,ts,jsx,tsx,json}';`); + expect(inWorkspace).toContain( + `@source '${join(REPO_ROOT, 'packages/components/src/**/*.{ts,tsx}')}';` + ); + expect(inWorkspace).toContain( + `@source '${join(REPO_ROOT, 'packages/plugin-*/src/**/*.{ts,tsx}')}';` + ); + + // Outside a workspace those two packages are installed rather than aliased, + // so the equivalent scan face is their built output under `node_modules` — + // which v4 does not auto-detect, being gitignored. + const standalone = routedFiles()['src/index.css']; + expect(standalone).toContain(`@source '../node_modules/@object-ui/*/dist/**/*.js';`); + expect(standalone).not.toContain(STANDALONE.cwd); + }); + + it('declares every theme token the component library declares', () => { + // The generated app owns the single Tailwind entrypoint for everything it + // renders — `@object-ui/components` deliberately does not inject its own + // sheet — so a `--color-*` token the library's classes resolve through and + // this stylesheet omits is a class that compiles to nothing. Comparing the + // two sets rather than listing literals means adding a token to the library + // fails here instead of silently degrading a generated app. + const libraryTokens = themeTokensOf( + readFileSync(resolve(REPO_ROOT, 'packages/components/src/index.css'), 'utf-8') + ); + expect(libraryTokens.length, 'the library must declare tokens to compare against'). + toBeGreaterThan(20); + // Non-vacuous in the direction that failed: the v3 config's `theme.extend` + // omitted the sidebar tokens the generated `src/Layout.tsx` itself uses. + expect(libraryTokens).toContain('--color-sidebar-primary'); + expect(libraryTokens).toContain('--color-sidebar-accent-foreground'); + + for (const [shape, files] of Object.entries(everyShape())) { + expect(themeTokensOf(files['src/index.css']), `${shape}`).toEqual(libraryTokens); + } + }); + + it('resolves every theme token through a variable it declares itself', () => { + // Self-consistency, and the other half of the gate above: `--color-sidebar: + // hsl(var(--sidebar))` is worth nothing if `--sidebar` is undeclared. Both + // light and dark have to carry it, or the token evaluates to nothing under + // `.dark` only — the hardest version of this bug to notice. + for (const [shape, files] of Object.entries(everyShape())) { + const css = files['src/index.css']; + const declared = declaredCustomProperties(css); + const referenced = customPropertiesReferencedByTheme(css); + // Non-vacuity first, and not decoration: run against the pre-fix v3 + // stylesheet this gate passes, because a CSS file with no `@theme` at all + // references nothing and an empty set has no undeclared member. Every + // other gate here went red on that input; this one was green for the + // "produces nothing" reason objectui#3826 warns about. + expect(referenced.length, `${shape}: no @theme tokens to check`).toBeGreaterThan(20); + + const missing = referenced.filter((property) => !declared.has(property)); + expect(missing, `${shape}: @theme resolves through undeclared variables`).toEqual([]); + + const dark = /\.dark\s*\{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''; + const darkDeclared = new Set( + [...dark.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map((match) => match[1]) + ); + const colourVariables = referenced.filter((property) => property !== '--radius'); + expect( + colourVariables.filter((property) => !darkDeclared.has(property)), + `${shape}: .dark leaves colour variables at their light values` + ).toEqual([]); + } + }); +}); + describe('generation onto disk', () => { /** * Runs the real `createTempApp*` entry points into a throwaway directory. diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index cf3ba7f6bd..141f7e0a06 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -21,6 +21,51 @@ interface DevOptions { open?: boolean; } +/** + * The PostCSS plugins the workspace-local temp app is served with. + * + * Inside a workspace the generated app installs nothing (it resolves everything + * by hoisting) and its own `postcss.config.js` is removed, so this is the only + * thing that compiles its stylesheet. Two things about it are deliberate: + * + * 1. **`@tailwindcss/postcss`, not `tailwindcss`.** Tailwind 4 moved the PostCSS + * plugin into its own package; calling `tailwindcss()` as a plugin — which + * this did until objectui#3852, passing it the generated + * `tailwind.config.js` — hits a shim whose only job is to throw ("It looks + * like you're trying to use `tailwindcss` directly as a PostCSS plugin"). The + * config-file argument goes away with it: v4 is CSS-first and the generated + * `src/index.css` carries its own `@source`/`@theme` (see `app-generator.ts`). + * 2. **Resolved from this CLI, and loud when it cannot be.** Both plugins are + * declared by `@object-ui/cli` itself, so a bare `import` finds them wherever + * the CLI is installed — rather than depending on what the invoking project + * happens to hoist (this repo's root declares `tailwindcss` but NOT + * `@tailwindcss/postcss`, so resolving from the cwd cannot work here at all). + * A failure throws with the reason attached: the previous `catch` warned one + * yellow line and served the app with no stylesheet, which is exactly how a + * dead CSS pipeline stayed unnoticed long enough to be filed as + * objectui#3852. + */ +async function loadTempAppPostcssPlugins(): Promise { + try { + const [tailwindPostcss, autoprefixer] = await Promise.all([ + import('@tailwindcss/postcss'), + import('autoprefixer') + ]); + return [tailwindPostcss.default(), autoprefixer.default()]; + } catch (error) { + // The caught error's message is inlined below. We can't pass it as the + // `Error` `cause` option because this package targets ES2020, whose lib + // types the 1-arg `Error` constructor only; hence the scoped disable. + // eslint-disable-next-line preserve-caught-error + throw new Error( + `Failed to load the Tailwind CSS PostCSS pipeline: ${error instanceof Error ? error.message : error}\n` + + ` Both '@tailwindcss/postcss' and 'autoprefixer' are dependencies of @object-ui/cli — a\n` + + ` broken install of the CLI is the likeliest cause; reinstall it and try again.\n` + + ` (Refusing to start unstyled: that failure is silent in the browser.)` + ); + } +} + export async function dev(schemaPath: string, options: DevOptions) { const cwd = process.cwd(); @@ -168,7 +213,12 @@ export async function dev(schemaPath: string, options: DevOptions) { if (isMonorepo) { console.log(chalk.blue('📦 Detected monorepo - configuring workspace aliases')); - // Remove postcss.config.js to prevent interference with programmatic config + // Remove postcss.config.js: the programmatic `css.postcss` below takes over + // (an inline config makes Vite skip config-file discovery entirely), and the + // generated file names `@tailwindcss/postcss` — a package the temp app never + // installs inside a workspace, and one this repo's root does not declare + // either, so leaving the file for a later Vite pass to find would only + // reintroduce an unresolvable plugin. const postcssPath = join(tmpDir, 'postcss.config.js'); if (existsSync(postcssPath)) { unlinkSync(postcssPath); @@ -205,22 +255,8 @@ export async function dev(schemaPath: string, options: DevOptions) { // Debug aliases // console.log('Aliases:', viteConfig.resolve.alias); - // Configure PostCSS programmatically reusing root dependencies - try { - const tailwindcss = require('tailwindcss'); - const autoprefixer = require('autoprefixer'); - - viteConfig.css = { - postcss: { - plugins: [ - tailwindcss(join(tmpDir, 'tailwind.config.js')), - autoprefixer(), - ], - }, - }; - } catch (_e) { - console.warn(chalk.yellow('⚠️ Failed to load PostCSS plugins from root node_modules. Styles might not work correctly.')); - } + // Configure PostCSS programmatically — see `loadTempAppPostcssPlugins`. + viteConfig.css = { postcss: { plugins: await loadTempAppPostcssPlugins() } }; } // Create Vite server diff --git a/packages/cli/src/utils/app-generator.ts b/packages/cli/src/utils/app-generator.ts index 02c332522b..785aa9a41e 100644 --- a/packages/cli/src/utils/app-generator.ts +++ b/packages/cli/src/utils/app-generator.ts @@ -262,18 +262,24 @@ function buildRoutedAppDependencies(): Record { * `devDependencies` shared by both generated apps (identical in both today). * * Every range is anchored to an in-repo manifest, and - * `app-generator.test.ts`'s `DEV_DEPENDENCY_ANCHORS` names the anchor for each - * one and fails on an unanchored addition. The three Tailwind-side entries are - * deliberately NOT anchored to the repo's Tailwind 4 — see - * `TAILWIND_V3_DEFERRED` in that test file, and objectui#3852. + * `app-generator.test.ts`'s `DEPENDENCY_ANCHORS` names the anchor for each one + * and fails on an unanchored addition. The Tailwind-side entries were held at + * v3 as an explicit `TAILWIND_V3_DEFERRED` ledger by objectui#3827, because + * bumping the range without rewriting the CSS pipeline beside it would produce + * an app that installs and renders unstyled. objectui#3852 rewrote that + * pipeline, so they are anchored to this repo's Tailwind 4 like everything + * else — `tailwindcss` to the root, `@tailwindcss/postcss` (v4's PostCSS + * plugin, a separate package) to the in-repo range every `postcss.config.*` + * here already names. */ const APP_DEV_DEPENDENCIES: Record = { + '@tailwindcss/postcss': '^4.3.3', '@types/react': '19.2.18', '@types/react-dom': '19.2.4', '@vitejs/plugin-react': '^6.0.5', autoprefixer: '^10.5.4', postcss: '^8.5.26', - tailwindcss: '^3.4.19', + tailwindcss: '^4.3.3', typescript: '^6.0.3', vite: '^8.2.0' }; @@ -300,10 +306,18 @@ const APP_TSCONFIG = { include: ['src'] }; -/** The generated `postcss.config.js`, identical for both generators. */ +/** + * The generated `postcss.config.js`, identical for both generators. + * + * Tailwind 4 moved the PostCSS plugin out of `tailwindcss` into + * `@tailwindcss/postcss`; naming the old `tailwindcss` key resolves to a shim + * whose only job is to throw ("It looks like you're trying to use `tailwindcss` + * directly as a PostCSS plugin"). Spelled exactly like + * `packages/components/postcss.config.js` — objectui#3852. + */ const APP_POSTCSS_CONFIG = `export default { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, };`; @@ -365,75 +379,227 @@ export function buildRoutedAppPackageJson(): Record { }; } -/** The generated `tailwind.config.js`; content globs widen inside a monorepo. */ -function buildTailwindConfig(context: AppGeneratorContext): string { - // Define Tailwind Content Paths - // Include JSON files specifically - const contentPaths = ["'./index.html'", "'./src/**/*.{js,ts,jsx,tsx,json}'"]; +/** + * The `@source` directives the generated `src/index.css` registers. + * + * Tailwind 4's replacement for v3's `content` globs, and a 1:1 translation of + * the ones the generated `tailwind.config.js` carried until objectui#3852: + * the app's own `index.html` and `src/**`, widened inside a workspace to the + * component library and every `plugin-*` package (the two absolute globs the v3 + * config built from `cwd`, semantics unchanged). + * + * Two things are load-bearing and were measured rather than assumed: + * + * 1. Relative `@source` paths resolve against the directory of the CSS file + * that declares them, NOT the app root — this file is written to + * `src/index.css`, so the app's own sources are `../src/**`, spelled exactly + * as `packages/components/src/index.css` spells its own. + * 2. Outside a workspace the platform packages are installed rather than + * aliased, and their classes live in built JS. v4 does not auto-scan + * `node_modules` (it is gitignored), so an explicit `@source` over the + * installed `dist` is the only thing that compiles the library's utilities — + * the gap the v3 `content` list also had, in the form v4 gives us to close + * it. + */ +function buildAppSourceDirectives(context: AppGeneratorContext): string[] { + const sources = [`@source '../index.html';`, `@source '../src/**/*.{js,ts,jsx,tsx,json}';`]; if (context.isMonorepo) { - const componentsPath = join(context.cwd, 'packages/components/src/**/*.{ts,tsx}'); - const pluginsPath = join(context.cwd, 'packages/plugin-*/src/**/*.{ts,tsx}'); - contentPaths.push(`'${componentsPath}'`); - contentPaths.push(`'${pluginsPath}'`); + sources.push(`@source '${join(context.cwd, 'packages/components/src/**/*.{ts,tsx}')}';`); + sources.push(`@source '${join(context.cwd, 'packages/plugin-*/src/**/*.{ts,tsx}')}';`); + } else { + sources.push(`@source '../node_modules/@object-ui/*/dist/**/*.js';`); } + return sources; +} - return `/** @type {import('tailwindcss').Config} */ -export default { - darkMode: ['class'], - content: [${contentPaths.join(', ')}], - theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - 1: 'hsl(var(--chart-1))', - 2: 'hsl(var(--chart-2))', - 3: 'hsl(var(--chart-3))', - 4: 'hsl(var(--chart-4))', - 5: 'hsl(var(--chart-5))', - }, - }, - }, - }, - plugins: [], -};`; +/** + * The generated `@theme` block: v4's replacement for v3's `theme.extend`. + * + * Token-for-token the set `packages/components/src/index.css` declares, because + * the generated app owns the single Tailwind entrypoint for everything it + * renders — the component library deliberately does NOT inject its own sheet + * (see the note at the top of `packages/components/src/index.ts`), so a token + * the library's classes need and this block omits simply does not compile. The + * v3 config's `theme.extend.colors` omitted the eight `sidebar-*` tokens that + * the generated `src/Layout.tsx` itself uses (`bg-sidebar-primary`, + * `data-[state=open]:bg-sidebar-accent`, …); `app-generator.test.ts` pins the + * two sets equal so the omission cannot come back. + */ +const APP_THEME_TOKENS = `@theme { + /* Border radius tokens */ + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + /* Color tokens mapped to the CSS variables declared below */ + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-sidebar: hsl(var(--sidebar)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); + + /* Chart colors */ + --color-chart-1: hsl(var(--chart-1)); + --color-chart-2: hsl(var(--chart-2)); + --color-chart-3: hsl(var(--chart-3)); + --color-chart-4: hsl(var(--chart-4)); + --color-chart-5: hsl(var(--chart-5)); +}`; + +/** + * The light/dark custom properties every token above resolves through. + * + * Plain CSS at the top level rather than inside `@layer base`, matching + * `packages/components/src/index.css`: `@theme` already emits into `:root`, and + * `.dark` only has to override the values. Every `var(--x)` named by + * `APP_THEME_TOKENS` is declared here — pinned by a test, since a token that + * resolves to nothing is a class that silently renders unstyled. + */ +const APP_THEME_VARIABLES = `:root { + color-scheme: light; + + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + /* Sidebar colors */ + --sidebar: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; +} + +.dark { + color-scheme: dark; + + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + /* Sidebar colors for dark mode */ + --sidebar: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; +}`; + +/** + * The generated `src/index.css` — one Tailwind 4 entrypoint, both generators. + * + * Replaces the v3 trio (`@tailwind base/components/utilities` directives, a + * `tailwind.config.js`, and a `tailwindcss`-keyed PostCSS config) with the + * CSS-first form this repo uses everywhere: `@import 'tailwindcss'`, + * `@custom-variant dark`, `@source`, `@theme`. No `tailwind.config.js` is + * written at all — in v4 a config file is inert unless a stylesheet points + * `@config` at it, and every source of truth it used to hold now lives here + * (objectui#3852; the repo itself has carried zero `tailwind.config.*` files + * since its own v4 migration). + * + * The two generators emitted byte-identical CSS apart from three utilities on + * `body`, so they now share this one builder; the `body` rules below are the + * routed variant's (`font-sans antialiased min-h-screen`), written as the plain + * CSS that `packages/components/src/index.css` uses instead of `@apply`. + */ +function buildAppIndexCss(context: AppGeneratorContext): string { + return `@import 'tailwindcss'; + +/* Class-based dark variant: follow \`.dark\` on — the routed app's + \`src/theme-provider.tsx\` toggles exactly that class — instead of the OS + \`prefers-color-scheme\`. Same spelling as packages/components/src/index.css. */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* Sources to scan for utility classes (v4's replacement for v3 \`content\`) */ +${buildAppSourceDirectives(context).join('\n')} + +${APP_THEME_TOKENS} + +${APP_THEME_VARIABLES} + +* { + border-color: hsl(var(--border)); +} + +body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + min-height: 100vh; +}`; } /** Writes a generated file map onto `tmpDir`, creating nested directories. */ @@ -503,83 +669,11 @@ function App() { export default App;`; - // Create index.css - const indexCss = `@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -}`; - return { 'index.html': html, 'src/main.tsx': mainTsx, 'src/App.tsx': appTsx, - 'src/index.css': indexCss, - 'tailwind.config.js': buildTailwindConfig(context), + 'src/index.css': buildAppIndexCss(context), 'postcss.config.js': APP_POSTCSS_CONFIG, 'package.json': JSON.stringify(buildAppPackageJson(context), null, 2), 'tsconfig.json': JSON.stringify(APP_TSCONFIG, null, 2) @@ -961,80 +1055,7 @@ export default App;`; files['src/App.tsx'] = appTsx; - // Create index.css with Tailwind - const indexCss = `@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground font-sans antialiased min-h-screen; - } -}`; - - files['src/index.css'] = indexCss; - - files['tailwind.config.js'] = buildTailwindConfig(context); + files['src/index.css'] = buildAppIndexCss(context); files['postcss.config.js'] = APP_POSTCSS_CONFIG; files['package.json'] = JSON.stringify(buildRoutedAppPackageJson(), null, 2); files['tsconfig.json'] = JSON.stringify(APP_TSCONFIG, null, 2); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1baccccadf..311e2c7fc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -818,12 +818,18 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@tailwindcss/postcss': + specifier: ^4.3.3 + version: 4.3.3 '@types/glob': specifier: ^9.0.0 version: 9.0.0 '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + autoprefixer: + specifier: ^10.5.4 + version: 10.5.4(postcss@8.5.26) chalk: specifier: ^6.0.0 version: 6.0.0