Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .changeset/untitled-ui-tw4-adoption.md
Original file line number Diff line number Diff line change
@@ -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(--<token>)`, 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.
154 changes: 154 additions & 0 deletions docs/pages/untitled-ui-tailwind-4.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Meta, Unstyled } from '@storybook/blocks'
import { InlineNotification } from '~components/Notification'

<Meta title="Guides/Untitled UI on Tailwind 4" />

# 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`.

<Unstyled>
<InlineNotification persistent variant="informative">
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.
</InlineNotification>
</Unstyled>

## 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.

<Unstyled>
<InlineNotification persistent variant="informative">
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.
</InlineNotification>
</Unstyled>

## 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.
42 changes: 42 additions & 0 deletions packages/design-tokens/bin/buildSemanticTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `--<name>` 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
Expand Down
Loading
Loading