diff --git a/.changeset/untitled-ui-tw4-adoption.md b/.changeset/untitled-ui-tw4-adoption.md new file mode 100644 index 00000000000..f3a04f01151 --- /dev/null +++ b/.changeset/untitled-ui-tw4-adoption.md @@ -0,0 +1,14 @@ +--- +'@kaizen/design-tokens': minor +'@kaizen/tailwind': minor +--- + +Add Untitled UI (UUI) compatibility for Tailwind consumers, layered on top of the existing semantic colour tokens. Everything current consumers use (TW3, TW4-via-`@config`, SCSS, CSS, JS) is unchanged, and authored code keeps using Kaizen's clean class names (`bg-primary`, `fg-primary`). + +UUI ships a doubled class form (`bg-bg-primary`, `text-fg-primary`, `border-border-secondary`). Rather than requiring a codemod, Kaizen now recognises those names **at build time** so raw UUI components resolve against Kaizen colours with no consumer action: + +- **`@kaizen/tailwind` preset**: each semantic theme map (`backgroundColor`/`textColor`/`borderColor`) now emits both the clean class and the UUI doubled class, pointing at the same semantic var. Foreground keys are exposed under `textColor` so UUI's `text-fg-*` resolves. +- **`@kaizen/design-tokens` — `css/tailwind-v4.css`**: TW4-native (`>=4.1`) `@utility` entrypoint emitting both clean and doubled utilities for pure CSS-first consumers. +- **`@kaizen/design-tokens` — `css/untitled-ui-vars.css`**: aliases UUI's `--color-*` var names to Kaizen semantic vars for UUI code that references vars directly. + +Both class forms resolve to the same `var(--)`, so the semantic-var indirection is preserved — dark mode and the palette flip override the var and every utility repaints without any consumer markup change. All outputs are generated from the same token source (`semanticColorTokens.ts`), so they never drift. diff --git a/docs/pages/untitled-ui-tailwind-4.mdx b/docs/pages/untitled-ui-tailwind-4.mdx new file mode 100644 index 00000000000..33cffb9b35e --- /dev/null +++ b/docs/pages/untitled-ui-tailwind-4.mdx @@ -0,0 +1,154 @@ +import { Meta, Unstyled } from '@storybook/blocks' +import { InlineNotification } from '~components/Notification' + + + +# Untitled UI on Tailwind 4 + +Guidance for Kaizen consumers who want to use **Untitled UI (UUI)** components on **Tailwind 4** +while keeping everything they already have — Kaizen components, CSS/SCSS/JS tokens, and TW3 or +TW4-via-`@config`. + + + + UUI is a **side path today** and the **eventual primary path**: as `@kaizen/components` migrates + to UUI and consumers standardise on TW4, this becomes the main way to consume Kaizen. Nothing + you use today changes. + + + +## TL;DR + +- Kaizen's semantic colour layer (`bg-primary`, `text-primary`, `fg-primary`, `border-primary`) + already works on TW3, TW4-via-`@config`, SCSS, CSS and JS. +- Untitled UI is authored with a **doubled** class form (`bg-bg-primary`, `text-fg-primary`, + `border-border-secondary`). Kaizen recognises those names **at build time**, so you can drop UUI + components in unchanged — **no codemod, no script**. Both `bg-primary` (what you author) and + `bg-bg-primary` (what UUI ships) resolve to the same colour. +- If any UUI code references UUI's CSS vars directly (`var(--color-bg-primary)`), import the + **compat var file** so those resolve to Kaizen values. +- On TW4.1+ you can skip the `@config` bridge entirely with the TW4-native `@utility` entrypoint. + +## Why the two conventions differ + +UUI declares its semantic tokens inside a TW4 `@theme` block using the `--color-` prefix: + +```css +@theme { + --color-bg-primary: var(--color-white); + --color-text-primary: var(--color-neutral-900); + --color-fg-primary: var(--color-neutral-900); + --color-border-primary: var(--color-neutral-300); +} +``` + +Tailwind 4 reads the colour _name_ as the whole string after `--color-`, so it generates +**doubled** utilities — `bg-bg-primary`, `text-text-primary`, `border-border-primary` — and there is +**no `fg-*` utility** (foreground colour is applied via `text-fg-*`). + +Kaizen ships the **clean** form as the authored API (`bg-primary`, `text-primary`, `border-primary`, +and a real `fg-*` utility) — that's what you write, and it matches the name a designer sees in Figma +(Figma↔code parity at the class layer). To let raw UUI components work too, Kaizen **also** emits the +doubled names as build-time compatibility aliases pointing at the same colour. + +## Step 1 — Keep your current Kaizen setup + +No change. Keep the Kaizen preset (`presets: [Preset]`) via your `tailwind.config.js`, referenced +from TW4 with `@config`, or used directly on TW3. Semantic utilities and CSS/SCSS/JS tokens keep +working exactly as before. This is the backwards-compatibility guarantee. + +## Step 2 — Drop in Untitled UI components (no codemod) + +Add a UUI component however you like (e.g. `untitledui add`) and use it as-is. Its doubled class +names resolve against Kaizen because the preset (and `tailwind-v4.css`) emit both forms — the clean +one you author and the doubled one UUI ships — pointing at the same semantic var: + +| UUI class (as shipped) | Authored equivalent | Both resolve to | +| ---------------------- | ------------------- | ------------------- | +| `bg-bg-{x}` | `bg-{x}` | `var(--bg-{x})` | +| `text-text-{x}` | `text-{x}` | `var(--text-{x})` | +| `text-fg-{x}` | `fg-{x}` | `var(--fg-{x})` | +| `border-border-{x}` | `border-{x}` | `var(--border-{x})` | + +No script, no source rewrite. The doubled names exist only as generated compatibility utilities — +you never hand-write them; new code uses the clean form. + +This covers colour only. UUI's rem-based **spacing** is a separate concern — see "Known adoption +costs" below. + +## Step 3 (optional) — CSS var compatibility + +If a UUI component references UUI's CSS vars directly (inline styles or raw CSS, e.g. +`var(--color-bg-primary)`), import the compat file so those names resolve to Kaizen values: + +```css +@import '@kaizen/design-tokens/css/untitled-ui-vars.css'; +``` + +It aliases every Kaizen semantic token to its UUI name — `--color-bg-primary: var(--bg-primary);` — +generated from the same token source, so it never drifts. Class-based components (the common case) +are already handled by Step 2 and don't need this. + +## Pure TW4 CSS-first (no `@config`) + +On **Tailwind 4.1+** you can get Kaizen's semantic utilities without the JS preset / `@config` +bridge by importing the generated TW4-native entrypoint alongside Tailwind: + +```css +@import 'tailwindcss'; +@import '@kaizen/design-tokens/css/variables.css'; /* semantic + primitive vars */ +@import '@kaizen/design-tokens/css/tailwind-v4.css'; /* @utility bg-primary { … } etc. */ +``` + +`tailwind-v4.css` is generated from the same token source as everything else. It authors explicit +`@utility` blocks (`@utility bg-primary { background-color: var(--bg-primary); }`, `fg-*` sets +`color`) so you get Kaizen's **clean** names — a stock `@theme --color-*` block would emit the +doubled `bg-bg-primary` form. + + + + Verified on Tailwind 4.2.4: `bg-primary`, `text-primary`, `fg-primary`, `border-primary`, + underscore names like `bg-secondary_hover`, and variants like `hover:bg-primary` all emit + correctly. Requires TW4.1+ for the `@utility` directive. + + + +## Toward dark mode — why utilities point at the semantic var + +Each `@utility` intentionally points at its **semantic var**, not the underlying primitive: + +```css +@utility border-brand_alt { + border-color: var(--border-brand_alt); /* stable — never changes */ +} +``` + +That indirection is a deliberate seam. The class name and the var name stay fixed; only _what the +var resolves to_ flips per context: + +```css +:root { + --border-brand_alt: var(--color-blue-500); +} +[data-color-mode='dark'] { + --border-brand_alt: var(--color-blue-300); /* same var, different primitive */ +} +``` + +When dark mode (and the colour palette flip) land, they override the **semantic var** and every +utility repaints with **no change to consumer markup**. Baking the primitive into the utility +(`border-color: var(--color-blue-500)`) would remove that override point — dark mode overrides the +semantic layer, not primitives. + +## Known adoption costs + +- **rem vs px.** UUI is rem-based; Kaizen spacing is px. The build-time aliasing covers **colour + only** — it does not touch spacing. Normalise UUI's spacing separately if needed. This is the main + remaining friction for a drop-in. +- **Prefix + `@layer` order.** Running UUI and Kaizen utilities in one app is two token spaces; keep + a Tailwind prefix and an explicit `@layer` order to avoid cascade surprises. +- **`fg` on non-text properties.** The compat aliases cover UUI's `text-fg-*` (foreground via text). + Rare `bg-fg-*`/`fill-fg-*` cases aren't aliased — the compat var file (Step 3) covers direct var + usage. +- **`_hover` underscores** in token names are valid CSS and compile as `@utility` names (verified on + TW4.2.4), but can trip stylelint/prettier — check your linters. diff --git a/packages/design-tokens/bin/buildSemanticTokens.ts b/packages/design-tokens/bin/buildSemanticTokens.ts index 0627eb00fd9..b7fbbefdaca 100644 --- a/packages/design-tokens/bin/buildSemanticTokens.ts +++ b/packages/design-tokens/bin/buildSemanticTokens.ts @@ -35,6 +35,48 @@ const run = (): void => { `${AUTOGENERATED_HEADER}\n\n${scssLines.join('\n')}\n`, ) + // css/untitled-ui-vars.css — aliases Untitled UI's `--color-*` var names to + // Kaizen's semantic vars, so UUI components/code referencing `var(--color-bg-primary)` + // resolve to Kaizen values. Derived from the same token source — never drifts. + const uuiAliasLines = flatEntries.map(([key]) => ` --color-${key}: var(--${key});`) + fs.writeFileSync( + path.resolve(CSS_OUTPUT_DIR, 'untitled-ui-vars.css'), + `${AUTOGENERATED_HEADER}\n\n:root {\n${uuiAliasLines.join('\n')}\n}\n`, + ) + + // css/tailwind-v4.css — TW4-native (>=4.1) `@utility` blocks so pure CSS-first + // consumers get Kaizen's CLEAN semantic utilities (`bg-primary`, `text-primary`, + // `fg-primary`, `border-primary`) without the TW3 preset / `@config` bridge. + // Clean names cannot come from a stock `@theme --color-*` block (that would emit + // the doubled `bg-bg-primary` form), so we author explicit @utility blocks. + // The `--` vars themselves come from semantic-color.css / variables.css — + // load one of those alongside this file. + const category = (key: string): string => key.slice(0, key.indexOf('-')) + const cssProperty = (key: string): string => { + switch (category(key)) { + case 'bg': + return 'background-color' + case 'border': + return 'border-color' + // `text` and `fg` (foreground/icon) both set the text colour. + default: + return 'color' + } + } + // Untitled UI ships "doubled" class names (`bg-bg-primary`, `text-fg-primary`). + // Emit those as compatibility utilities alongside the clean ones so raw UUI + // components resolve with no codemod. `fg` is applied via `text-*` in UUI. + const uuiUtilityName = (key: string): string => + category(key) === 'fg' ? `text-${key}` : `${category(key)}-${key}` + const utilityBlocks = flatEntries.flatMap(([key]) => { + const decl = ` ${cssProperty(key)}: var(--${key});` + return [`@utility ${key} {\n${decl}\n}`, `@utility ${uuiUtilityName(key)} {\n${decl}\n}`] + }) + fs.writeFileSync( + path.resolve(CSS_OUTPUT_DIR, 'tailwind-v4.css'), + `${AUTOGENERATED_HEADER}\n\n${utilityBlocks.join('\n\n')}\n`, + ) + const totalTokens = Object.values(semanticColorTokens).flatMap((group) => Object.values(group), ).length diff --git a/packages/design-tokens/css/tailwind-v4.css b/packages/design-tokens/css/tailwind-v4.css new file mode 100644 index 00000000000..2502b9d7f99 --- /dev/null +++ b/packages/design-tokens/css/tailwind-v4.css @@ -0,0 +1,409 @@ +/** THIS IS AN AUTOGENERATED FILE **/ + +@utility bg-primary { + background-color: var(--bg-primary); +} + +@utility bg-bg-primary { + background-color: var(--bg-primary); +} + +@utility bg-secondary { + background-color: var(--bg-secondary); +} + +@utility bg-bg-secondary { + background-color: var(--bg-secondary); +} + +@utility bg-secondary_hover { + background-color: var(--bg-secondary_hover); +} + +@utility bg-bg-secondary_hover { + background-color: var(--bg-secondary_hover); +} + +@utility bg-tertiary { + background-color: var(--bg-tertiary); +} + +@utility bg-bg-tertiary { + background-color: var(--bg-tertiary); +} + +@utility bg-primary-solid { + background-color: var(--bg-primary-solid); +} + +@utility bg-bg-primary-solid { + background-color: var(--bg-primary-solid); +} + +@utility bg-secondary-solid { + background-color: var(--bg-secondary-solid); +} + +@utility bg-bg-secondary-solid { + background-color: var(--bg-secondary-solid); +} + +@utility bg-overlay { + background-color: var(--bg-overlay); +} + +@utility bg-bg-overlay { + background-color: var(--bg-overlay); +} + +@utility bg-brand-primary { + background-color: var(--bg-brand-primary); +} + +@utility bg-bg-brand-primary { + background-color: var(--bg-brand-primary); +} + +@utility bg-brand-secondary { + background-color: var(--bg-brand-secondary); +} + +@utility bg-bg-brand-secondary { + background-color: var(--bg-brand-secondary); +} + +@utility bg-brand-solid { + background-color: var(--bg-brand-solid); +} + +@utility bg-bg-brand-solid { + background-color: var(--bg-brand-solid); +} + +@utility bg-brand-solid_hover { + background-color: var(--bg-brand-solid_hover); +} + +@utility bg-bg-brand-solid_hover { + background-color: var(--bg-brand-solid_hover); +} + +@utility bg-error-primary { + background-color: var(--bg-error-primary); +} + +@utility bg-bg-error-primary { + background-color: var(--bg-error-primary); +} + +@utility bg-error-secondary { + background-color: var(--bg-error-secondary); +} + +@utility bg-bg-error-secondary { + background-color: var(--bg-error-secondary); +} + +@utility bg-error-solid { + background-color: var(--bg-error-solid); +} + +@utility bg-bg-error-solid { + background-color: var(--bg-error-solid); +} + +@utility bg-success-primary { + background-color: var(--bg-success-primary); +} + +@utility bg-bg-success-primary { + background-color: var(--bg-success-primary); +} + +@utility bg-success-secondary { + background-color: var(--bg-success-secondary); +} + +@utility bg-bg-success-secondary { + background-color: var(--bg-success-secondary); +} + +@utility bg-success-solid { + background-color: var(--bg-success-solid); +} + +@utility bg-bg-success-solid { + background-color: var(--bg-success-solid); +} + +@utility bg-warning-primary { + background-color: var(--bg-warning-primary); +} + +@utility bg-bg-warning-primary { + background-color: var(--bg-warning-primary); +} + +@utility bg-warning-secondary { + background-color: var(--bg-warning-secondary); +} + +@utility bg-bg-warning-secondary { + background-color: var(--bg-warning-secondary); +} + +@utility bg-warning-solid { + background-color: var(--bg-warning-solid); +} + +@utility bg-bg-warning-solid { + background-color: var(--bg-warning-solid); +} + +@utility text-primary { + color: var(--text-primary); +} + +@utility text-text-primary { + color: var(--text-primary); +} + +@utility text-secondary { + color: var(--text-secondary); +} + +@utility text-text-secondary { + color: var(--text-secondary); +} + +@utility text-tertiary { + color: var(--text-tertiary); +} + +@utility text-text-tertiary { + color: var(--text-tertiary); +} + +@utility text-quaternary { + color: var(--text-quaternary); +} + +@utility text-text-quaternary { + color: var(--text-quaternary); +} + +@utility text-placeholder { + color: var(--text-placeholder); +} + +@utility text-text-placeholder { + color: var(--text-placeholder); +} + +@utility text-secondary_on-brand { + color: var(--text-secondary_on-brand); +} + +@utility text-text-secondary_on-brand { + color: var(--text-secondary_on-brand); +} + +@utility text-quaternary_on-brand { + color: var(--text-quaternary_on-brand); +} + +@utility text-text-quaternary_on-brand { + color: var(--text-quaternary_on-brand); +} + +@utility text-brand-primary { + color: var(--text-brand-primary); +} + +@utility text-text-brand-primary { + color: var(--text-brand-primary); +} + +@utility text-brand-secondary { + color: var(--text-brand-secondary); +} + +@utility text-text-brand-secondary { + color: var(--text-brand-secondary); +} + +@utility text-brand-secondary_hover { + color: var(--text-brand-secondary_hover); +} + +@utility text-text-brand-secondary_hover { + color: var(--text-brand-secondary_hover); +} + +@utility text-error-primary { + color: var(--text-error-primary); +} + +@utility text-text-error-primary { + color: var(--text-error-primary); +} + +@utility text-success-primary { + color: var(--text-success-primary); +} + +@utility text-text-success-primary { + color: var(--text-success-primary); +} + +@utility fg-primary { + color: var(--fg-primary); +} + +@utility text-fg-primary { + color: var(--fg-primary); +} + +@utility fg-secondary { + color: var(--fg-secondary); +} + +@utility text-fg-secondary { + color: var(--fg-secondary); +} + +@utility fg-secondary_hover { + color: var(--fg-secondary_hover); +} + +@utility text-fg-secondary_hover { + color: var(--fg-secondary_hover); +} + +@utility fg-tertiary { + color: var(--fg-tertiary); +} + +@utility text-fg-tertiary { + color: var(--fg-tertiary); +} + +@utility fg-quaternary { + color: var(--fg-quaternary); +} + +@utility text-fg-quaternary { + color: var(--fg-quaternary); +} + +@utility fg-white { + color: var(--fg-white); +} + +@utility text-fg-white { + color: var(--fg-white); +} + +@utility fg-brand-primary { + color: var(--fg-brand-primary); +} + +@utility text-fg-brand-primary { + color: var(--fg-brand-primary); +} + +@utility fg-error-primary { + color: var(--fg-error-primary); +} + +@utility text-fg-error-primary { + color: var(--fg-error-primary); +} + +@utility fg-success-primary { + color: var(--fg-success-primary); +} + +@utility text-fg-success-primary { + color: var(--fg-success-primary); +} + +@utility fg-success-secondary { + color: var(--fg-success-secondary); +} + +@utility text-fg-success-secondary { + color: var(--fg-success-secondary); +} + +@utility fg-warning-primary { + color: var(--fg-warning-primary); +} + +@utility text-fg-warning-primary { + color: var(--fg-warning-primary); +} + +@utility border-primary { + border-color: var(--border-primary); +} + +@utility border-border-primary { + border-color: var(--border-primary); +} + +@utility border-secondary { + border-color: var(--border-secondary); +} + +@utility border-border-secondary { + border-color: var(--border-secondary); +} + +@utility border-secondary_alt { + border-color: var(--border-secondary_alt); +} + +@utility border-border-secondary_alt { + border-color: var(--border-secondary_alt); +} + +@utility border-tertiary { + border-color: var(--border-tertiary); +} + +@utility border-border-tertiary { + border-color: var(--border-tertiary); +} + +@utility border-brand { + border-color: var(--border-brand); +} + +@utility border-border-brand { + border-color: var(--border-brand); +} + +@utility border-brand_alt { + border-color: var(--border-brand_alt); +} + +@utility border-border-brand_alt { + border-color: var(--border-brand_alt); +} + +@utility border-error { + border-color: var(--border-error); +} + +@utility border-border-error { + border-color: var(--border-error); +} + +@utility border-error_subtle { + border-color: var(--border-error_subtle); +} + +@utility border-border-error_subtle { + border-color: var(--border-error_subtle); +} diff --git a/packages/design-tokens/css/untitled-ui-vars.css b/packages/design-tokens/css/untitled-ui-vars.css new file mode 100644 index 00000000000..1886565c783 --- /dev/null +++ b/packages/design-tokens/css/untitled-ui-vars.css @@ -0,0 +1,55 @@ +/** THIS IS AN AUTOGENERATED FILE **/ + +:root { + --color-bg-primary: var(--bg-primary); + --color-bg-secondary: var(--bg-secondary); + --color-bg-secondary_hover: var(--bg-secondary_hover); + --color-bg-tertiary: var(--bg-tertiary); + --color-bg-primary-solid: var(--bg-primary-solid); + --color-bg-secondary-solid: var(--bg-secondary-solid); + --color-bg-overlay: var(--bg-overlay); + --color-bg-brand-primary: var(--bg-brand-primary); + --color-bg-brand-secondary: var(--bg-brand-secondary); + --color-bg-brand-solid: var(--bg-brand-solid); + --color-bg-brand-solid_hover: var(--bg-brand-solid_hover); + --color-bg-error-primary: var(--bg-error-primary); + --color-bg-error-secondary: var(--bg-error-secondary); + --color-bg-error-solid: var(--bg-error-solid); + --color-bg-success-primary: var(--bg-success-primary); + --color-bg-success-secondary: var(--bg-success-secondary); + --color-bg-success-solid: var(--bg-success-solid); + --color-bg-warning-primary: var(--bg-warning-primary); + --color-bg-warning-secondary: var(--bg-warning-secondary); + --color-bg-warning-solid: var(--bg-warning-solid); + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-tertiary: var(--text-tertiary); + --color-text-quaternary: var(--text-quaternary); + --color-text-placeholder: var(--text-placeholder); + --color-text-secondary_on-brand: var(--text-secondary_on-brand); + --color-text-quaternary_on-brand: var(--text-quaternary_on-brand); + --color-text-brand-primary: var(--text-brand-primary); + --color-text-brand-secondary: var(--text-brand-secondary); + --color-text-brand-secondary_hover: var(--text-brand-secondary_hover); + --color-text-error-primary: var(--text-error-primary); + --color-text-success-primary: var(--text-success-primary); + --color-fg-primary: var(--fg-primary); + --color-fg-secondary: var(--fg-secondary); + --color-fg-secondary_hover: var(--fg-secondary_hover); + --color-fg-tertiary: var(--fg-tertiary); + --color-fg-quaternary: var(--fg-quaternary); + --color-fg-white: var(--fg-white); + --color-fg-brand-primary: var(--fg-brand-primary); + --color-fg-error-primary: var(--fg-error-primary); + --color-fg-success-primary: var(--fg-success-primary); + --color-fg-success-secondary: var(--fg-success-secondary); + --color-fg-warning-primary: var(--fg-warning-primary); + --color-border-primary: var(--border-primary); + --color-border-secondary: var(--border-secondary); + --color-border-secondary_alt: var(--border-secondary_alt); + --color-border-tertiary: var(--border-tertiary); + --color-border-brand: var(--border-brand); + --color-border-brand_alt: var(--border-brand_alt); + --color-border-error: var(--border-error); + --color-border-error_subtle: var(--border-error_subtle); +} diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index 709ca3ad01c..4f63d99a72f 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -31,7 +31,7 @@ "build:ts": "pnpm package-bundler build", "build:less": "json-to-flat-sass './tokens/*.json' 'less' --extension 'less' --caseType 'kebab' && prettier less/* --write", "build:sass": "json-to-flat-sass './tokens/*.json' 'sass' --extension 'scss' --caseType 'kebab' && prettier sass/* --write", - "build:semantic": "tsx ./bin/buildSemanticTokens.ts && prettier css/semantic-color.css sass/semantic-color.scss --write", + "build:semantic": "tsx ./bin/buildSemanticTokens.ts && prettier css/semantic-color.css css/untitled-ui-vars.css css/tailwind-v4.css sass/semantic-color.scss --write", "clean": "rimraf 'dist' 'node_modules' '.turbo'", "clean:dist": "rimraf 'dist'" }, diff --git a/packages/tailwind/src/tailwind-presets.ts b/packages/tailwind/src/tailwind-presets.ts index eb061a049db..5b1a669acd0 100644 --- a/packages/tailwind/src/tailwind-presets.ts +++ b/packages/tailwind/src/tailwind-presets.ts @@ -23,6 +23,26 @@ function stripAndMap(group: Record, prefix: string): Reco return result } +/** + * Map each non-null token to its full (category-prefixed) key, so Tailwind emits + * the Untitled UI "doubled" class as a build-time compatibility alias. + * + * e.g. fullKeyMap(semanticColorTokens.background) produces: + * { 'bg-primary': 'var(--bg-primary)', ... } + * Placed under `backgroundColor`, Tailwind re-adds the `bg-` prefix → class + * `bg-bg-primary` (what raw UUI components ship), resolving to the same var as + * the clean `bg-primary`. Placed under `textColor`, the foreground group's keys + * (`fg-primary`) become `text-fg-primary` — UUI applies foreground via `text-*`. + */ +function fullKeyMap(group: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(group)) { + if (value === null) continue + result[key] = `var(--${key})` + } + return result +} + /** * `tokens.color` merges in the flat semantic colour tokens, some of which are * `null` (no confident mapping yet). Tailwind's colour config rejects `null`, @@ -41,9 +61,23 @@ function stripNulls>( const nonNullColors = stripNulls(tokens.color) -const semanticBackgroundColors = stripAndMap(semanticColorTokens.background, 'bg-') -const semanticTextColors = stripAndMap(semanticColorTokens.text, 'text-') -const semanticBorderColors = stripAndMap(semanticColorTokens.border, 'border-') +// Each map carries both the clean (stripped) key and the full key, so Tailwind +// emits both `bg-primary` (authored) and `bg-bg-primary` (raw UUI) → same var. +const semanticBackgroundColors = { + ...stripAndMap(semanticColorTokens.background, 'bg-'), + ...fullKeyMap(semanticColorTokens.background), +} +const semanticTextColors = { + ...stripAndMap(semanticColorTokens.text, 'text-'), + ...fullKeyMap(semanticColorTokens.text), + // UUI applies foreground via `text-fg-*`; expose the fg keys under textColor. + ...fullKeyMap(semanticColorTokens.foreground), +} +const semanticBorderColors = { + ...stripAndMap(semanticColorTokens.border, 'border-'), + ...fullKeyMap(semanticColorTokens.border), +} +// Clean `fg-*` utility (authored form) is added via the fgPlugin below. const semanticForegroundColors = stripAndMap(semanticColorTokens.foreground, 'fg-') export type KaizenTailwindTheme = Partial