From 92b03e936b6b26e383f27ef07e62029525de57cd Mon Sep 17 00:00:00 2001 From: Kitty Allen Date: Wed, 1 Jul 2026 16:18:13 +1000 Subject: [PATCH 1/4] feat(tailwind,design-tokens): add Untitled UI Tailwind 4 adoption path Layer a UUI-on-TW4 adoption path on top of the existing semantic colour tokens. Current consumers (TW3, TW4-via-@config, SCSS, CSS, JS) are unchanged. - @kaizen/tailwind: transform-untitled-ui-classes.mjs codemod strips UUI's doubled class form to Kaizen's clean form (bg-bg-*->bg-*, text-fg-*->fg-*, border-border-*->border-*, text-text-*->text-*), preserving variants, important and any TW prefix. Unit-tested (30 cases). - @kaizen/design-tokens: generate css/untitled-ui-vars.css (aliases UUI's --color-* var names to Kaizen semantic vars) and css/tailwind-v4.css (TW4.1+ @utility entrypoint for pure CSS-first consumers). All generated from semanticColorTokens.ts, so no drift. tailwind-v4.css verified compiling on Tailwind 4.2.4. Utilities point at the semantic var, not the primitive, to keep the override seam for dark mode / palette flip. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/untitled-ui-tw4-adoption.md | 14 + .../design-tokens/bin/buildSemanticTokens.ts | 36 +++ packages/design-tokens/css/tailwind-v4.css | 205 +++++++++++++++ .../design-tokens/css/untitled-ui-vars.css | 55 ++++ packages/design-tokens/package.json | 2 +- packages/tailwind/package.json | 6 +- .../scripts/transform-untitled-ui-classes.mjs | 241 ++++++++++++++++++ .../transform-untitled-ui-classes.spec.ts | 105 ++++++++ 8 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 .changeset/untitled-ui-tw4-adoption.md create mode 100644 packages/design-tokens/css/tailwind-v4.css create mode 100644 packages/design-tokens/css/untitled-ui-vars.css create mode 100644 packages/tailwind/scripts/transform-untitled-ui-classes.mjs create mode 100644 packages/tailwind/scripts/transform-untitled-ui-classes.spec.ts diff --git a/.changeset/untitled-ui-tw4-adoption.md b/.changeset/untitled-ui-tw4-adoption.md new file mode 100644 index 00000000000..fb9823101e5 --- /dev/null +++ b/.changeset/untitled-ui-tw4-adoption.md @@ -0,0 +1,14 @@ +--- +'@kaizen/design-tokens': minor +'@kaizen/tailwind': minor +--- + +Add an Untitled UI (UUI) adoption path for Tailwind 4 consumers, layered on top of the existing semantic colour tokens. Everything current consumers use (TW3, TW4-via-`@config`, SCSS, CSS, JS) is unchanged. + +- **`@kaizen/tailwind` — UUI class transform**: `scripts/transform-untitled-ui-classes.mjs` (`transform:untitled-ui`) rewrites UUI's doubled class form to Kaizen's clean form on adoption — `bg-bg-*`→`bg-*`, `text-text-*`→`text-*`, `border-border-*`→`border-*`, `text-fg-*`→`fg-*` — preserving variants, `!important` and any Tailwind prefix. +- **`@kaizen/design-tokens` — `css/untitled-ui-vars.css`**: aliases UUI's `--color-*` var names to Kaizen semantic vars (`--color-bg-primary: var(--bg-primary);`) so UUI code referencing vars directly resolves to Kaizen values. +- **`@kaizen/design-tokens` — `css/tailwind-v4.css`**: TW4-native (`>=4.1`) `@utility` entrypoint so pure CSS-first consumers get Kaizen's clean semantic utilities without the `@config` bridge. + +All three files are generated from the same token source (`semanticColorTokens.ts`), so they never drift. Verified compiling on Tailwind 4.2.4. + +The `@utility` blocks intentionally point each class at its **semantic var** (`border-color: var(--border-brand_alt)`), not the underlying primitive. This indirection is the seam that future theming relies on: the class name and var name stay stable, while the primitive the var resolves to changes per context. Dark mode (and the palette flip) will override the semantic var — e.g. `[data-color-mode="dark"] { --border-brand_alt: var(--color-blue-300); }` — so the same utility repaints without any change to consumer markup. Baking the primitive into the utility would remove that override point. diff --git a/packages/design-tokens/bin/buildSemanticTokens.ts b/packages/design-tokens/bin/buildSemanticTokens.ts index 0627eb00fd9..fa2f4119c81 100644 --- a/packages/design-tokens/bin/buildSemanticTokens.ts +++ b/packages/design-tokens/bin/buildSemanticTokens.ts @@ -35,6 +35,42 @@ 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 cssProperty = (key: string): string => { + const category = key.slice(0, key.indexOf('-')) + switch (category) { + case 'bg': + return 'background-color' + case 'border': + return 'border-color' + // `text` and `fg` (foreground/icon) both set the text colour. + default: + return 'color' + } + } + const utilityBlocks = flatEntries.map( + ([key]) => `@utility ${key} {\n ${cssProperty(key)}: var(--${key});\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..1fb3713ddef --- /dev/null +++ b/packages/design-tokens/css/tailwind-v4.css @@ -0,0 +1,205 @@ +/** THIS IS AN AUTOGENERATED FILE **/ + +@utility bg-primary { + background-color: var(--bg-primary); +} + +@utility bg-secondary { + background-color: var(--bg-secondary); +} + +@utility bg-secondary_hover { + background-color: var(--bg-secondary_hover); +} + +@utility bg-tertiary { + background-color: var(--bg-tertiary); +} + +@utility bg-primary-solid { + background-color: var(--bg-primary-solid); +} + +@utility bg-secondary-solid { + background-color: var(--bg-secondary-solid); +} + +@utility bg-overlay { + background-color: var(--bg-overlay); +} + +@utility bg-brand-primary { + background-color: var(--bg-brand-primary); +} + +@utility bg-brand-secondary { + background-color: var(--bg-brand-secondary); +} + +@utility bg-brand-solid { + background-color: var(--bg-brand-solid); +} + +@utility bg-brand-solid_hover { + background-color: var(--bg-brand-solid_hover); +} + +@utility bg-error-primary { + background-color: var(--bg-error-primary); +} + +@utility bg-error-secondary { + background-color: var(--bg-error-secondary); +} + +@utility bg-error-solid { + background-color: var(--bg-error-solid); +} + +@utility bg-success-primary { + background-color: var(--bg-success-primary); +} + +@utility bg-success-secondary { + background-color: var(--bg-success-secondary); +} + +@utility bg-success-solid { + background-color: var(--bg-success-solid); +} + +@utility bg-warning-primary { + background-color: var(--bg-warning-primary); +} + +@utility bg-warning-secondary { + background-color: var(--bg-warning-secondary); +} + +@utility bg-warning-solid { + background-color: var(--bg-warning-solid); +} + +@utility text-primary { + color: var(--text-primary); +} + +@utility text-secondary { + color: var(--text-secondary); +} + +@utility text-tertiary { + color: var(--text-tertiary); +} + +@utility text-quaternary { + color: var(--text-quaternary); +} + +@utility text-placeholder { + color: var(--text-placeholder); +} + +@utility text-secondary_on-brand { + color: var(--text-secondary_on-brand); +} + +@utility text-quaternary_on-brand { + color: var(--text-quaternary_on-brand); +} + +@utility text-brand-primary { + color: var(--text-brand-primary); +} + +@utility text-brand-secondary { + color: var(--text-brand-secondary); +} + +@utility text-brand-secondary_hover { + color: var(--text-brand-secondary_hover); +} + +@utility text-error-primary { + color: var(--text-error-primary); +} + +@utility text-success-primary { + color: var(--text-success-primary); +} + +@utility fg-primary { + color: var(--fg-primary); +} + +@utility fg-secondary { + color: var(--fg-secondary); +} + +@utility fg-secondary_hover { + color: var(--fg-secondary_hover); +} + +@utility fg-tertiary { + color: var(--fg-tertiary); +} + +@utility fg-quaternary { + color: var(--fg-quaternary); +} + +@utility fg-white { + color: var(--fg-white); +} + +@utility fg-brand-primary { + color: var(--fg-brand-primary); +} + +@utility fg-error-primary { + color: var(--fg-error-primary); +} + +@utility fg-success-primary { + color: var(--fg-success-primary); +} + +@utility fg-success-secondary { + color: var(--fg-success-secondary); +} + +@utility fg-warning-primary { + color: var(--fg-warning-primary); +} + +@utility border-primary { + border-color: var(--border-primary); +} + +@utility border-secondary { + border-color: var(--border-secondary); +} + +@utility border-secondary_alt { + border-color: var(--border-secondary_alt); +} + +@utility border-tertiary { + border-color: var(--border-tertiary); +} + +@utility border-brand { + border-color: var(--border-brand); +} + +@utility border-brand_alt { + border-color: var(--border-brand_alt); +} + +@utility border-error { + border-color: var(--border-error); +} + +@utility 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/package.json b/packages/tailwind/package.json index f259f55f52d..b6197a31c16 100644 --- a/packages/tailwind/package.json +++ b/packages/tailwind/package.json @@ -6,7 +6,8 @@ "build": "pnpm package-bundler build", "test": "vitest --config ../../vite.config.ts", "clean": "rimraf 'dist' 'node_modules' '.turbo'", - "lint:ts": "tsc --noEmit" + "lint:ts": "tsc --noEmit", + "transform:untitled-ui": "node ./scripts/transform-untitled-ui-classes.mjs" }, "repository": { "type": "git", @@ -19,7 +20,8 @@ "files": [ "src", "dist", - "js" + "js", + "scripts" ], "main": "dist/cjs/index.cjs", "module": "dist/esm/index.mjs", diff --git a/packages/tailwind/scripts/transform-untitled-ui-classes.mjs b/packages/tailwind/scripts/transform-untitled-ui-classes.mjs new file mode 100644 index 00000000000..08110ac57f7 --- /dev/null +++ b/packages/tailwind/scripts/transform-untitled-ui-classes.mjs @@ -0,0 +1,241 @@ +/** + * Transforms Untitled UI (UUI) Tailwind colour classes into Kaizen's clean + * semantic class form, so UUI components can be dropped into a Kaizen-preset app. + * + * WHY: UUI declares its semantic tokens as `--color-bg-primary`, + * `--color-text-primary`, `--color-fg-primary`, `--color-border-primary` inside a + * TW4 `@theme` block. 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 instead: `bg-primary`, `text-primary`, + * `border-primary`, and a real `fg-*` utility. This script strips the doubling: + * + * bg-bg- -> bg- + * text-text- -> text- + * border-border- -> border- + * text-fg- -> fg- (category shift) + * + * It is colour-classes only (single responsibility). Variant prefixes (`hover:`, + * `md:`, `group-hover/x:`), an optional Tailwind prefix, and `!important` are + * preserved; already-clean classes, spacing/layout classes, primitive utilities + * (`text-purple-600`) and arbitrary values (`bg-[var(--x)]`) are left untouched. + * + * Usage: + * node transform-untitled-ui-classes.mjs [--prefix

] [--write] + * (dry run by default; pass --write to apply changes) + */ + +import fs from "node:fs" +import path from "node:path" + +const DOUBLED_PREFIX_RE = /(^|:)(bg-bg-|text-text-|border-border-|text-fg-)/ + +function parseImportant(token) { + if (!token) return { token, important: false } + // TW3 important: `!class`. + if (token.startsWith("!")) return { token: token.slice(1), important: true } + // Variant important like `hover:!bg-red`. + if (token.includes(":!")) return { token: token.replaceAll(":!", ":"), important: true } + // TW4 important: `class!`. + if (token.endsWith("!")) return { token: token.slice(0, -1), important: true } + return { token, important: false } +} + +function parseExistingPrefix(token, prefix) { + if (!prefix) return { token, hadPrefix: false } + const prefixMarker = `${prefix}:` + if (!token.startsWith(prefixMarker)) return { token, hadPrefix: false } + return { token: token.slice(prefixMarker.length), hadPrefix: true } +} + +/** + * Strip the UUI doubling on the utility segment. The `(^|:)` anchor means variant + * prefixes (e.g. `hover:bg-bg-primary`) are handled inline. + */ +function applyColourMapping(base) { + if (!base) return base + // Leave arbitrary values intact (e.g. text-fg-[#fff], bg-[var(--x)]). + if (base.includes("-[")) return base + + return base + .replace(/(^|:)bg-bg-/g, "$1bg-") + .replace(/(^|:)text-text-/g, "$1text-") + .replace(/(^|:)border-border-/g, "$1border-") + .replace(/(^|:)text-fg-/g, "$1fg-") +} + +export function transformClassToken(token, { prefix } = {}) { + if (!token) return token + if (token === "{" || token === "}" || token === "(" || token === ")") return token + // Don't touch arbitrary selector blocks like `[&>*]:...`. + if (token.startsWith("[&")) return token + + const { token: withoutImportant, important } = parseImportant(token) + const { token: withoutPrefix } = parseExistingPrefix(withoutImportant, prefix) + + const mapped = applyColourMapping(withoutPrefix) + const finalToken = prefix ? `${prefix}:${mapped}` : mapped + + return important ? `${finalToken}!` : finalToken +} + +export function transformClassString(classList, { prefix } = {}) { + return classList.replace(/[\S]+/g, (tok) => transformClassToken(tok, { prefix })) +} + +export function transformSource(source, { prefix } = {}) { + return source.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (match, inner, offset) => { + const before = source.slice(Math.max(0, offset - 80), offset) + + // Avoid transforming import/module specifiers. + if ( + /\bfrom\s*$/.test(before) || + /\bimport\s*$/.test(before) || + /\bimport\s*\(\s*$/.test(before) || + /\brequire\s*\(\s*$/.test(before) + ) { + return match + } + + const trimmed = inner.trim() + if (!trimmed) return match + if (!/[a-z0-9]/i.test(trimmed)) return match + + // Obvious non-class strings. + if ( + trimmed.startsWith("./") || + trimmed.startsWith("../") || + trimmed.startsWith("/") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) + ) { + return match + } + + const hasWhitespace = /\s/.test(trimmed) + + // Attribute / event names (only for single-token strings). + if (!hasWhitespace) { + if (/^(data|aria)-[a-z0-9-]+$/.test(trimmed)) return match + if (/^on[A-Z]/.test(trimmed)) return match + } + + const tokens = hasWhitespace ? trimmed.split(/\s+/).filter(Boolean) : [trimmed] + + const hasStrongSignal = tokens.some((token) => { + const { token: withoutImportant } = parseImportant(token) + if (prefix && withoutImportant.startsWith(`${prefix}:`)) return true + // A UUI doubled colour class is itself a strong signal. + if (DOUBLED_PREFIX_RE.test(withoutImportant)) return true + if (withoutImportant.includes("[")) return true + if (withoutImportant.includes(":")) return true + return /(^|:)-?[a-z-]+-\d+(?:\.\d+)?$/.test(withoutImportant) + }) + + const looksTailwind = hasWhitespace + ? hasStrongSignal + : DOUBLED_PREFIX_RE.test(trimmed) || /[-[]/.test(trimmed) + + if (!looksTailwind) return match + + return `"${transformClassString(inner, { prefix })}"` + }) +} + +export function transformFile(filePath, { prefix, write = false, fsModule = fs } = {}) { + const src = fsModule.readFileSync(filePath, "utf8") + const out = transformSource(src, { prefix }) + const changed = out !== src + if (changed && write) fsModule.writeFileSync(filePath, out) + return changed +} + +const DEFAULT_DIR_IGNORES = new Set([".git", "build", "coverage", "dist", "node_modules", ".next"]) +const DEFAULT_FILE_EXTS = new Set([".tsx", ".jsx", ".ts", ".js"]) + +function collectFiles(targets, { cwd, fsModule, logger }) { + const out = [] + for (const target of targets) { + const abs = path.resolve(cwd, target) + let stat + try { + stat = fsModule.statSync(abs) + } catch { + logger.error(`Path not found: ${target}`) + throw new Error(`Path not found: ${target}`) + } + if (stat.isFile()) { + out.push(abs) + continue + } + if (stat.isDirectory()) { + const stack = [abs] + while (stack.length > 0) { + const dir = stack.pop() + for (const entry of fsModule.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!DEFAULT_DIR_IGNORES.has(entry.name)) stack.push(path.join(dir, entry.name)) + continue + } + if (entry.isFile() && DEFAULT_FILE_EXTS.has(path.extname(entry.name))) { + out.push(path.join(dir, entry.name)) + } + } + } + continue + } + logger.error(`Unsupported path type: ${target}`) + throw new Error(`Unsupported path type: ${target}`) + } + return out +} + +export function runCli({ argv = process.argv, cwd = process.cwd(), fsModule = fs, logger = console } = {}) { + const scriptName = path.basename(argv[1] ?? "transform-untitled-ui-classes.mjs") + const rest = argv.slice(2) + + let prefix + let write = false + const targets = [] + for (let i = 0; i < rest.length; i++) { + const arg = rest[i] + if (arg === "--write") write = true + else if (arg === "--prefix") prefix = rest[++i] + else if (arg.startsWith("--prefix=")) prefix = arg.slice("--prefix=".length) + else targets.push(arg) + } + + if (targets.length === 0) { + logger.error(`Usage: ${scriptName} [--prefix

] [--write]`) + return 1 + } + + let files + try { + files = collectFiles(targets, { cwd, fsModule, logger }) + } catch { + return 1 + } + if (files.length === 0) { + logger.log("No matching files found.") + return 0 + } + + let changed = 0 + for (const filePath of files) { + if (transformFile(filePath, { prefix, write, fsModule })) changed++ + } + + if (write) { + logger.log(`Transformed Untitled UI colour classes in ${changed}/${files.length} file(s).`) + } else { + logger.log(`Dry run: ${changed}/${files.length} file(s) would change. Pass --write to apply.`) + } + return 0 +} + +// Run as CLI when invoked directly (not when imported by tests). +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) { + process.exit(runCli()) +} diff --git a/packages/tailwind/scripts/transform-untitled-ui-classes.spec.ts b/packages/tailwind/scripts/transform-untitled-ui-classes.spec.ts new file mode 100644 index 00000000000..2814c45232a --- /dev/null +++ b/packages/tailwind/scripts/transform-untitled-ui-classes.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest" +// @ts-expect-error -- plain .mjs script, no type declarations +import { transformClassToken, transformClassString } from "./transform-untitled-ui-classes.mjs" + +describe("transformClassToken", () => { + describe("bg-bg-* mapping", () => { + it.each([ + ["bg-bg-primary", "bg-primary"], + ["bg-bg-brand-solid", "bg-brand-solid"], + ["bg-bg-primary_hover", "bg-primary_hover"], + ])("maps %s -> %s", (input, expected) => { + expect(transformClassToken(input)).toBe(expected) + }) + }) + + describe("text-text-* mapping", () => { + it.each([ + ["text-text-primary", "text-primary"], + ["text-text-secondary", "text-secondary"], + ])("maps %s -> %s", (input, expected) => { + expect(transformClassToken(input)).toBe(expected) + }) + }) + + describe("border-border-* mapping", () => { + it.each([ + ["border-border-secondary", "border-secondary"], + ["border-border-secondary_alt", "border-secondary_alt"], + ])("maps %s -> %s", (input, expected) => { + expect(transformClassToken(input)).toBe(expected) + }) + }) + + describe("text-fg-* category shift", () => { + it.each([ + ["text-fg-primary", "fg-primary"], + ["text-fg-brand-primary", "fg-brand-primary"], + ["text-fg-quaternary_hover", "fg-quaternary_hover"], + ])("maps %s -> %s", (input, expected) => { + expect(transformClassToken(input)).toBe(expected) + }) + }) + + describe("variant preservation", () => { + it.each([ + ["hover:bg-bg-primary", "hover:bg-primary"], + ["md:text-text-secondary", "md:text-secondary"], + ["group-hover/foo:border-border-primary", "group-hover/foo:border-primary"], + ["dark:hover:bg-bg-brand-solid", "dark:hover:bg-brand-solid"], + ["hover:text-fg-primary", "hover:fg-primary"], + ])("preserves variants in %s", (input, expected) => { + expect(transformClassToken(input)).toBe(expected) + }) + }) + + describe("important preservation", () => { + it.each([ + ["bg-bg-primary!", "bg-primary!"], + ["!bg-bg-primary", "bg-primary!"], + ["hover:bg-bg-primary!", "hover:bg-primary!"], + ])("preserves important in %s", (input, expected) => { + expect(transformClassToken(input)).toBe(expected) + }) + }) + + describe("prefix option", () => { + it("adds prefix to an unprefixed class", () => { + expect(transformClassToken("bg-bg-primary", { prefix: "un" })).toBe("un:bg-primary") + }) + it("re-uses an existing prefix", () => { + expect(transformClassToken("un:bg-bg-primary", { prefix: "un" })).toBe("un:bg-primary") + }) + it("keeps variants under the prefix", () => { + expect(transformClassToken("un:hover:bg-bg-primary", { prefix: "un" })).toBe("un:hover:bg-primary") + }) + }) + + describe("no-ops", () => { + it.each([ + "bg-primary", + "bg-brand-solid", + "text-purple-600", + "p-4", + "flex", + "bg-[var(--x)]", + "text-fg-[#fff]", + ])("leaves %s unchanged", (input) => { + expect(transformClassToken(input)).toBe(input) + }) + }) +}) + +describe("transformClassString", () => { + it("transforms every token in a multi-class string", () => { + expect(transformClassString("bg-bg-primary text-text-secondary border-border-primary")).toBe( + "bg-primary text-secondary border-primary", + ) + }) + + it("only touches colour classes, leaving layout/spacing/primitives alone", () => { + expect(transformClassString("flex bg-bg-brand-solid hover:text-fg-primary p-4")).toBe( + "flex bg-brand-solid hover:fg-primary p-4", + ) + }) +}) From 190326d51da774218b82403d97caec03ad64b53c Mon Sep 17 00:00:00 2001 From: Kitty Allen Date: Wed, 1 Jul 2026 16:22:27 +1000 Subject: [PATCH 2/4] docs(guides): add "Untitled UI on Tailwind 4" Storybook page Storybook MDX guide (Guides/Untitled UI on Tailwind 4) covering the UUI adoption path: the class transform, the var-compat file, the pure-TW4 @utility entrypoint, and why utilities point at the semantic var (dark-mode override seam). Replaces the earlier standalone markdown draft. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/pages/untitled-ui-tailwind-4.mdx | 166 ++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/pages/untitled-ui-tailwind-4.mdx diff --git a/docs/pages/untitled-ui-tailwind-4.mdx b/docs/pages/untitled-ui-tailwind-4.mdx new file mode 100644 index 00000000000..d55c8eee9d6 --- /dev/null +++ b/docs/pages/untitled-ui-tailwind-4.mdx @@ -0,0 +1,166 @@ +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`). To drop UUI components into a Kaizen app, run them through the + **class transform** shipped in `@kaizen/tailwind` — it rewrites them to Kaizen's clean form. +- 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 deliberately ships the **clean** form instead (`bg-primary`, `text-primary`, +`border-primary`, and a real `fg-*` utility). Clean class names are the design goal; we do not want +`bg-bg-primary` in application code. The transform below is the wall that keeps the doubling out. + +The name a designer sees in Figma (`bg-primary`, `text-primary`) is exactly the utility you write in +Kaizen — Figma↔code parity holds at the class layer. + +## 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 — Transform Untitled UI components on the way in + +When you add a UUI component (e.g. via `untitledui add`), run the Kaizen transform over it. It +strips the UUI doubling to Kaizen's clean form: + +| UUI (doubled) | Kaizen (clean) | +| ------------------- | -------------- | +| `bg-bg-{x}` | `bg-{x}` | +| `text-text-{x}` | `text-{x}` | +| `text-fg-{x}` | `fg-{x}` | +| `border-border-{x}` | `border-{x}` | + +Variant prefixes (`hover:`, `md:`, `group-hover/x:`), `!important`, an optional TW prefix, and +`_hover`-style token suffixes are all preserved. Already-clean classes, spacing/layout classes and +primitive utilities (`text-purple-600`) are left untouched. + +```sh +# dry run (prints a diff summary) +node ./node_modules/@kaizen/tailwind/scripts/transform-untitled-ui-classes.mjs ./src/untitled-ui + +# apply +node ./node_modules/@kaizen/tailwind/scripts/transform-untitled-ui-classes.mjs ./src/untitled-ui --write + +# if your project uses a Tailwind prefix (e.g. `un`) +node ./node_modules/@kaizen/tailwind/scripts/transform-untitled-ui-classes.mjs ./src/untitled-ui --prefix un --write +``` + +The transform is colour-classes only (single responsibility). If you also need UUI's rem-based +spacing normalised to your scale, compose it with your own spacing transform — 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 class transform does **not** touch + spacing — handle it with a separate spacing transform if needed. This is the main remaining + friction for a transform-free 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.** UUI mostly uses `text-fg-*` (→ `fg-*`). Rare `bg-fg-*`/`fill-fg-*` + cases aren't remapped by the transform — 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. From 514364a9a076e920ae9ae9726786845fc3a1dadb Mon Sep 17 00:00:00 2001 From: Kitty Allen Date: Thu, 2 Jul 2026 10:05:11 +1000 Subject: [PATCH 3/4] refactor(tailwind,design-tokens): UUI compat via build-time aliasing, drop codemod Replace the one-time class codemod with build-time aliasing so raw Untitled UI components resolve against Kaizen colours with no consumer action. - tailwind-presets.ts: each semantic theme map now emits both the clean class (bg-primary) and the UUI doubled class (bg-bg-primary) via a fullKeyMap helper; foreground keys exposed under textColor so text-fg-* resolves. - buildSemanticTokens.ts: tailwind-v4.css emits both clean and doubled @utility blocks (fg via text-*). - Remove transform-untitled-ui-classes.mjs + spec and its package.json wiring. - Docs + changeset updated to the no-codemod approach. Both forms point at the same var(--token), preserving the semantic-var seam for dark mode / palette flip. Verified on Tailwind 4.2.4 (clean + doubled, incl. hover). Preset typechecks; package tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/untitled-ui-tw4-adoption.md | 12 +- docs/pages/untitled-ui-tailwind-4.mdx | 66 ++--- .../design-tokens/bin/buildSemanticTokens.ts | 19 +- packages/design-tokens/css/tailwind-v4.css | 204 +++++++++++++++ packages/tailwind/package.json | 6 +- .../scripts/transform-untitled-ui-classes.mjs | 241 ------------------ .../transform-untitled-ui-classes.spec.ts | 105 -------- packages/tailwind/src/tailwind-presets.ts | 40 ++- 8 files changed, 290 insertions(+), 403 deletions(-) delete mode 100644 packages/tailwind/scripts/transform-untitled-ui-classes.mjs delete mode 100644 packages/tailwind/scripts/transform-untitled-ui-classes.spec.ts diff --git a/.changeset/untitled-ui-tw4-adoption.md b/.changeset/untitled-ui-tw4-adoption.md index fb9823101e5..f3a04f01151 100644 --- a/.changeset/untitled-ui-tw4-adoption.md +++ b/.changeset/untitled-ui-tw4-adoption.md @@ -3,12 +3,12 @@ '@kaizen/tailwind': minor --- -Add an Untitled UI (UUI) adoption path for Tailwind 4 consumers, layered on top of the existing semantic colour tokens. Everything current consumers use (TW3, TW4-via-`@config`, SCSS, CSS, JS) is unchanged. +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`). -- **`@kaizen/tailwind` — UUI class transform**: `scripts/transform-untitled-ui-classes.mjs` (`transform:untitled-ui`) rewrites UUI's doubled class form to Kaizen's clean form on adoption — `bg-bg-*`→`bg-*`, `text-text-*`→`text-*`, `border-border-*`→`border-*`, `text-fg-*`→`fg-*` — preserving variants, `!important` and any Tailwind prefix. -- **`@kaizen/design-tokens` — `css/untitled-ui-vars.css`**: aliases UUI's `--color-*` var names to Kaizen semantic vars (`--color-bg-primary: var(--bg-primary);`) so UUI code referencing vars directly resolves to Kaizen values. -- **`@kaizen/design-tokens` — `css/tailwind-v4.css`**: TW4-native (`>=4.1`) `@utility` entrypoint so pure CSS-first consumers get Kaizen's clean semantic utilities without the `@config` bridge. +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: -All three files are generated from the same token source (`semanticColorTokens.ts`), so they never drift. Verified compiling on Tailwind 4.2.4. +- **`@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. -The `@utility` blocks intentionally point each class at its **semantic var** (`border-color: var(--border-brand_alt)`), not the underlying primitive. This indirection is the seam that future theming relies on: the class name and var name stay stable, while the primitive the var resolves to changes per context. Dark mode (and the palette flip) will override the semantic var — e.g. `[data-color-mode="dark"] { --border-brand_alt: var(--color-blue-300); }` — so the same utility repaints without any change to consumer markup. Baking the primitive into the utility would remove that override point. +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 index d55c8eee9d6..33cffb9b35e 100644 --- a/docs/pages/untitled-ui-tailwind-4.mdx +++ b/docs/pages/untitled-ui-tailwind-4.mdx @@ -22,8 +22,9 @@ TW4-via-`@config`. - 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`). To drop UUI components into a Kaizen app, run them through the - **class transform** shipped in `@kaizen/tailwind` — it rewrites them to Kaizen's clean form. + `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. @@ -45,12 +46,10 @@ Tailwind 4 reads the colour _name_ as the whole string after `--color-`, so it g **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 deliberately ships the **clean** form instead (`bg-primary`, `text-primary`, -`border-primary`, and a real `fg-*` utility). Clean class names are the design goal; we do not want -`bg-bg-primary` in application code. The transform below is the wall that keeps the doubling out. - -The name a designer sees in Figma (`bg-primary`, `text-primary`) is exactly the utility you write in -Kaizen — Figma↔code parity holds at the class layer. +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 @@ -58,36 +57,24 @@ No change. Keep the Kaizen preset (`presets: [Preset]`) via your `tailwind.confi 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 — Transform Untitled UI components on the way in - -When you add a UUI component (e.g. via `untitledui add`), run the Kaizen transform over it. It -strips the UUI doubling to Kaizen's clean form: - -| UUI (doubled) | Kaizen (clean) | -| ------------------- | -------------- | -| `bg-bg-{x}` | `bg-{x}` | -| `text-text-{x}` | `text-{x}` | -| `text-fg-{x}` | `fg-{x}` | -| `border-border-{x}` | `border-{x}` | +## Step 2 — Drop in Untitled UI components (no codemod) -Variant prefixes (`hover:`, `md:`, `group-hover/x:`), `!important`, an optional TW prefix, and -`_hover`-style token suffixes are all preserved. Already-clean classes, spacing/layout classes and -primitive utilities (`text-purple-600`) are left untouched. +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: -```sh -# dry run (prints a diff summary) -node ./node_modules/@kaizen/tailwind/scripts/transform-untitled-ui-classes.mjs ./src/untitled-ui +| 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})` | -# apply -node ./node_modules/@kaizen/tailwind/scripts/transform-untitled-ui-classes.mjs ./src/untitled-ui --write - -# if your project uses a Tailwind prefix (e.g. `un`) -node ./node_modules/@kaizen/tailwind/scripts/transform-untitled-ui-classes.mjs ./src/untitled-ui --prefix un --write -``` +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. -The transform is colour-classes only (single responsibility). If you also need UUI's rem-based -spacing normalised to your scale, compose it with your own spacing transform — see -"Known adoption costs" below. +This covers colour only. UUI's rem-based **spacing** is a separate concern — see "Known adoption +costs" below. ## Step 3 (optional) — CSS var compatibility @@ -155,12 +142,13 @@ semantic layer, not primitives. ## Known adoption costs -- **rem vs px.** UUI is rem-based; Kaizen spacing is px. The class transform does **not** touch - spacing — handle it with a separate spacing transform if needed. This is the main remaining - friction for a transform-free drop-in. +- **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.** UUI mostly uses `text-fg-*` (→ `fg-*`). Rare `bg-fg-*`/`fill-fg-*` - cases aren't remapped by the transform — the compat var file (Step 3) covers direct var usage. +- **`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 fa2f4119c81..da968e05241 100644 --- a/packages/design-tokens/bin/buildSemanticTokens.ts +++ b/packages/design-tokens/bin/buildSemanticTokens.ts @@ -51,9 +51,9 @@ const run = (): void => { // 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 => { - const category = key.slice(0, key.indexOf('-')) - switch (category) { + switch (category(key)) { case 'bg': return 'background-color' case 'border': @@ -63,9 +63,18 @@ const run = (): void => { return 'color' } } - const utilityBlocks = flatEntries.map( - ([key]) => `@utility ${key} {\n ${cssProperty(key)}: var(--${key});\n}`, - ) + // 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`, diff --git a/packages/design-tokens/css/tailwind-v4.css b/packages/design-tokens/css/tailwind-v4.css index 1fb3713ddef..2502b9d7f99 100644 --- a/packages/design-tokens/css/tailwind-v4.css +++ b/packages/design-tokens/css/tailwind-v4.css @@ -4,202 +4,406 @@ 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/tailwind/package.json b/packages/tailwind/package.json index b6197a31c16..f259f55f52d 100644 --- a/packages/tailwind/package.json +++ b/packages/tailwind/package.json @@ -6,8 +6,7 @@ "build": "pnpm package-bundler build", "test": "vitest --config ../../vite.config.ts", "clean": "rimraf 'dist' 'node_modules' '.turbo'", - "lint:ts": "tsc --noEmit", - "transform:untitled-ui": "node ./scripts/transform-untitled-ui-classes.mjs" + "lint:ts": "tsc --noEmit" }, "repository": { "type": "git", @@ -20,8 +19,7 @@ "files": [ "src", "dist", - "js", - "scripts" + "js" ], "main": "dist/cjs/index.cjs", "module": "dist/esm/index.mjs", diff --git a/packages/tailwind/scripts/transform-untitled-ui-classes.mjs b/packages/tailwind/scripts/transform-untitled-ui-classes.mjs deleted file mode 100644 index 08110ac57f7..00000000000 --- a/packages/tailwind/scripts/transform-untitled-ui-classes.mjs +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Transforms Untitled UI (UUI) Tailwind colour classes into Kaizen's clean - * semantic class form, so UUI components can be dropped into a Kaizen-preset app. - * - * WHY: UUI declares its semantic tokens as `--color-bg-primary`, - * `--color-text-primary`, `--color-fg-primary`, `--color-border-primary` inside a - * TW4 `@theme` block. 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 instead: `bg-primary`, `text-primary`, - * `border-primary`, and a real `fg-*` utility. This script strips the doubling: - * - * bg-bg- -> bg- - * text-text- -> text- - * border-border- -> border- - * text-fg- -> fg- (category shift) - * - * It is colour-classes only (single responsibility). Variant prefixes (`hover:`, - * `md:`, `group-hover/x:`), an optional Tailwind prefix, and `!important` are - * preserved; already-clean classes, spacing/layout classes, primitive utilities - * (`text-purple-600`) and arbitrary values (`bg-[var(--x)]`) are left untouched. - * - * Usage: - * node transform-untitled-ui-classes.mjs [--prefix

] [--write] - * (dry run by default; pass --write to apply changes) - */ - -import fs from "node:fs" -import path from "node:path" - -const DOUBLED_PREFIX_RE = /(^|:)(bg-bg-|text-text-|border-border-|text-fg-)/ - -function parseImportant(token) { - if (!token) return { token, important: false } - // TW3 important: `!class`. - if (token.startsWith("!")) return { token: token.slice(1), important: true } - // Variant important like `hover:!bg-red`. - if (token.includes(":!")) return { token: token.replaceAll(":!", ":"), important: true } - // TW4 important: `class!`. - if (token.endsWith("!")) return { token: token.slice(0, -1), important: true } - return { token, important: false } -} - -function parseExistingPrefix(token, prefix) { - if (!prefix) return { token, hadPrefix: false } - const prefixMarker = `${prefix}:` - if (!token.startsWith(prefixMarker)) return { token, hadPrefix: false } - return { token: token.slice(prefixMarker.length), hadPrefix: true } -} - -/** - * Strip the UUI doubling on the utility segment. The `(^|:)` anchor means variant - * prefixes (e.g. `hover:bg-bg-primary`) are handled inline. - */ -function applyColourMapping(base) { - if (!base) return base - // Leave arbitrary values intact (e.g. text-fg-[#fff], bg-[var(--x)]). - if (base.includes("-[")) return base - - return base - .replace(/(^|:)bg-bg-/g, "$1bg-") - .replace(/(^|:)text-text-/g, "$1text-") - .replace(/(^|:)border-border-/g, "$1border-") - .replace(/(^|:)text-fg-/g, "$1fg-") -} - -export function transformClassToken(token, { prefix } = {}) { - if (!token) return token - if (token === "{" || token === "}" || token === "(" || token === ")") return token - // Don't touch arbitrary selector blocks like `[&>*]:...`. - if (token.startsWith("[&")) return token - - const { token: withoutImportant, important } = parseImportant(token) - const { token: withoutPrefix } = parseExistingPrefix(withoutImportant, prefix) - - const mapped = applyColourMapping(withoutPrefix) - const finalToken = prefix ? `${prefix}:${mapped}` : mapped - - return important ? `${finalToken}!` : finalToken -} - -export function transformClassString(classList, { prefix } = {}) { - return classList.replace(/[\S]+/g, (tok) => transformClassToken(tok, { prefix })) -} - -export function transformSource(source, { prefix } = {}) { - return source.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (match, inner, offset) => { - const before = source.slice(Math.max(0, offset - 80), offset) - - // Avoid transforming import/module specifiers. - if ( - /\bfrom\s*$/.test(before) || - /\bimport\s*$/.test(before) || - /\bimport\s*\(\s*$/.test(before) || - /\brequire\s*\(\s*$/.test(before) - ) { - return match - } - - const trimmed = inner.trim() - if (!trimmed) return match - if (!/[a-z0-9]/i.test(trimmed)) return match - - // Obvious non-class strings. - if ( - trimmed.startsWith("./") || - trimmed.startsWith("../") || - trimmed.startsWith("/") || - /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) - ) { - return match - } - - const hasWhitespace = /\s/.test(trimmed) - - // Attribute / event names (only for single-token strings). - if (!hasWhitespace) { - if (/^(data|aria)-[a-z0-9-]+$/.test(trimmed)) return match - if (/^on[A-Z]/.test(trimmed)) return match - } - - const tokens = hasWhitespace ? trimmed.split(/\s+/).filter(Boolean) : [trimmed] - - const hasStrongSignal = tokens.some((token) => { - const { token: withoutImportant } = parseImportant(token) - if (prefix && withoutImportant.startsWith(`${prefix}:`)) return true - // A UUI doubled colour class is itself a strong signal. - if (DOUBLED_PREFIX_RE.test(withoutImportant)) return true - if (withoutImportant.includes("[")) return true - if (withoutImportant.includes(":")) return true - return /(^|:)-?[a-z-]+-\d+(?:\.\d+)?$/.test(withoutImportant) - }) - - const looksTailwind = hasWhitespace - ? hasStrongSignal - : DOUBLED_PREFIX_RE.test(trimmed) || /[-[]/.test(trimmed) - - if (!looksTailwind) return match - - return `"${transformClassString(inner, { prefix })}"` - }) -} - -export function transformFile(filePath, { prefix, write = false, fsModule = fs } = {}) { - const src = fsModule.readFileSync(filePath, "utf8") - const out = transformSource(src, { prefix }) - const changed = out !== src - if (changed && write) fsModule.writeFileSync(filePath, out) - return changed -} - -const DEFAULT_DIR_IGNORES = new Set([".git", "build", "coverage", "dist", "node_modules", ".next"]) -const DEFAULT_FILE_EXTS = new Set([".tsx", ".jsx", ".ts", ".js"]) - -function collectFiles(targets, { cwd, fsModule, logger }) { - const out = [] - for (const target of targets) { - const abs = path.resolve(cwd, target) - let stat - try { - stat = fsModule.statSync(abs) - } catch { - logger.error(`Path not found: ${target}`) - throw new Error(`Path not found: ${target}`) - } - if (stat.isFile()) { - out.push(abs) - continue - } - if (stat.isDirectory()) { - const stack = [abs] - while (stack.length > 0) { - const dir = stack.pop() - for (const entry of fsModule.readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory()) { - if (!DEFAULT_DIR_IGNORES.has(entry.name)) stack.push(path.join(dir, entry.name)) - continue - } - if (entry.isFile() && DEFAULT_FILE_EXTS.has(path.extname(entry.name))) { - out.push(path.join(dir, entry.name)) - } - } - } - continue - } - logger.error(`Unsupported path type: ${target}`) - throw new Error(`Unsupported path type: ${target}`) - } - return out -} - -export function runCli({ argv = process.argv, cwd = process.cwd(), fsModule = fs, logger = console } = {}) { - const scriptName = path.basename(argv[1] ?? "transform-untitled-ui-classes.mjs") - const rest = argv.slice(2) - - let prefix - let write = false - const targets = [] - for (let i = 0; i < rest.length; i++) { - const arg = rest[i] - if (arg === "--write") write = true - else if (arg === "--prefix") prefix = rest[++i] - else if (arg.startsWith("--prefix=")) prefix = arg.slice("--prefix=".length) - else targets.push(arg) - } - - if (targets.length === 0) { - logger.error(`Usage: ${scriptName} [--prefix

] [--write]`) - return 1 - } - - let files - try { - files = collectFiles(targets, { cwd, fsModule, logger }) - } catch { - return 1 - } - if (files.length === 0) { - logger.log("No matching files found.") - return 0 - } - - let changed = 0 - for (const filePath of files) { - if (transformFile(filePath, { prefix, write, fsModule })) changed++ - } - - if (write) { - logger.log(`Transformed Untitled UI colour classes in ${changed}/${files.length} file(s).`) - } else { - logger.log(`Dry run: ${changed}/${files.length} file(s) would change. Pass --write to apply.`) - } - return 0 -} - -// Run as CLI when invoked directly (not when imported by tests). -if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) { - process.exit(runCli()) -} diff --git a/packages/tailwind/scripts/transform-untitled-ui-classes.spec.ts b/packages/tailwind/scripts/transform-untitled-ui-classes.spec.ts deleted file mode 100644 index 2814c45232a..00000000000 --- a/packages/tailwind/scripts/transform-untitled-ui-classes.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from "vitest" -// @ts-expect-error -- plain .mjs script, no type declarations -import { transformClassToken, transformClassString } from "./transform-untitled-ui-classes.mjs" - -describe("transformClassToken", () => { - describe("bg-bg-* mapping", () => { - it.each([ - ["bg-bg-primary", "bg-primary"], - ["bg-bg-brand-solid", "bg-brand-solid"], - ["bg-bg-primary_hover", "bg-primary_hover"], - ])("maps %s -> %s", (input, expected) => { - expect(transformClassToken(input)).toBe(expected) - }) - }) - - describe("text-text-* mapping", () => { - it.each([ - ["text-text-primary", "text-primary"], - ["text-text-secondary", "text-secondary"], - ])("maps %s -> %s", (input, expected) => { - expect(transformClassToken(input)).toBe(expected) - }) - }) - - describe("border-border-* mapping", () => { - it.each([ - ["border-border-secondary", "border-secondary"], - ["border-border-secondary_alt", "border-secondary_alt"], - ])("maps %s -> %s", (input, expected) => { - expect(transformClassToken(input)).toBe(expected) - }) - }) - - describe("text-fg-* category shift", () => { - it.each([ - ["text-fg-primary", "fg-primary"], - ["text-fg-brand-primary", "fg-brand-primary"], - ["text-fg-quaternary_hover", "fg-quaternary_hover"], - ])("maps %s -> %s", (input, expected) => { - expect(transformClassToken(input)).toBe(expected) - }) - }) - - describe("variant preservation", () => { - it.each([ - ["hover:bg-bg-primary", "hover:bg-primary"], - ["md:text-text-secondary", "md:text-secondary"], - ["group-hover/foo:border-border-primary", "group-hover/foo:border-primary"], - ["dark:hover:bg-bg-brand-solid", "dark:hover:bg-brand-solid"], - ["hover:text-fg-primary", "hover:fg-primary"], - ])("preserves variants in %s", (input, expected) => { - expect(transformClassToken(input)).toBe(expected) - }) - }) - - describe("important preservation", () => { - it.each([ - ["bg-bg-primary!", "bg-primary!"], - ["!bg-bg-primary", "bg-primary!"], - ["hover:bg-bg-primary!", "hover:bg-primary!"], - ])("preserves important in %s", (input, expected) => { - expect(transformClassToken(input)).toBe(expected) - }) - }) - - describe("prefix option", () => { - it("adds prefix to an unprefixed class", () => { - expect(transformClassToken("bg-bg-primary", { prefix: "un" })).toBe("un:bg-primary") - }) - it("re-uses an existing prefix", () => { - expect(transformClassToken("un:bg-bg-primary", { prefix: "un" })).toBe("un:bg-primary") - }) - it("keeps variants under the prefix", () => { - expect(transformClassToken("un:hover:bg-bg-primary", { prefix: "un" })).toBe("un:hover:bg-primary") - }) - }) - - describe("no-ops", () => { - it.each([ - "bg-primary", - "bg-brand-solid", - "text-purple-600", - "p-4", - "flex", - "bg-[var(--x)]", - "text-fg-[#fff]", - ])("leaves %s unchanged", (input) => { - expect(transformClassToken(input)).toBe(input) - }) - }) -}) - -describe("transformClassString", () => { - it("transforms every token in a multi-class string", () => { - expect(transformClassString("bg-bg-primary text-text-secondary border-border-primary")).toBe( - "bg-primary text-secondary border-primary", - ) - }) - - it("only touches colour classes, leaving layout/spacing/primitives alone", () => { - expect(transformClassString("flex bg-bg-brand-solid hover:text-fg-primary p-4")).toBe( - "flex bg-brand-solid hover:fg-primary p-4", - ) - }) -}) 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 From 6ee78bdd5d651c089b01c27d6e5f624a37fad806 Mon Sep 17 00:00:00 2001 From: Kitty Allen Date: Thu, 2 Jul 2026 10:08:58 +1000 Subject: [PATCH 4/4] style(design-tokens): satisfy prettier in tailwind-v4 generator Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/design-tokens/bin/buildSemanticTokens.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/design-tokens/bin/buildSemanticTokens.ts b/packages/design-tokens/bin/buildSemanticTokens.ts index da968e05241..b7fbbefdaca 100644 --- a/packages/design-tokens/bin/buildSemanticTokens.ts +++ b/packages/design-tokens/bin/buildSemanticTokens.ts @@ -70,10 +70,7 @@ const run = (): void => { 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}`, - ] + return [`@utility ${key} {\n${decl}\n}`, `@utility ${uuiUtilityName(key)} {\n${decl}\n}`] }) fs.writeFileSync( path.resolve(CSS_OUTPUT_DIR, 'tailwind-v4.css'),