# Tailwind CSS v4 Styles Guide

CSS architecture for `@djangocfg/ui-core` — **Tailwind v4** with semantic tokens, presets, and macOS-style glass utilities.

## Directory layout

```
styles/
├── full.css               # Golden path (recommended) — Tailwind + tokens + base + utilities, cascade-layer-safe
├── index.css              # Plain entry (no Tailwind, unlayered) — you own layer ordering
├── theme.css              # Imports tokens.css → animations → light → dark
├── base.css               # Resets + `*` border-color + body bg/color + radius scale + native focus-outline reset
├── utilities.css          # Custom utilities entry — imports utilities/*
│   └── utilities/         # display · divider · controls · step · animations · glass · marquee
├── sources.css            # @source directives for monorepo class detection
├── palette/               # JS-readable color access (Canvas/SVG/Mermaid)
└── theme/
    ├── tokens.css         # @theme inline { --color-X: var(--X) } + @theme { spacing/blur/z }
    ├── light.css          # :root — full hsl(...) values
    ├── dark.css           # .dark — full hsl(...) values
    └── animations.css     # @keyframes (float, blob, morph, …)

presets/
├── build.ts               # buildThemeStyleSheet({preset}) → CSS string
├── types.ts               # ThemeStylePresetId, ThemeCssVarMap
├── presets.ts             # THEME_STYLE_PRESETS, THEME_STYLE_PRESET_ORDER
└── themes/                # 8 preset definitions (see Presets section)
```

## Token format (Tailwind v4 `@theme inline` pattern)

Tokens live in `:root` / `.dark` as **fully-wrapped CSS colors**, and `@theme inline` maps them by reference:

```css
:root  { --background: hsl(0 0% 94%); }
.dark  { --background: hsl(0 0% 4%); }

@theme inline { --color-background: var(--background); }
```

This makes opacity modifiers (`bg-card/40`, `border-foreground/20`) resolve through `color-mix(in oklab, …)` — they work for every semantic token, no helpers needed.

> **Do NOT wrap tokens in `hsl(var(--X))`.** Tokens are already full colors, so `hsl(hsl(...))` is invalid and falls back to the default. Use `var(--X)` or `color-mix(in oklab, var(--X) N%, transparent)` for manual opacity.

> **Do NOT set a token to a bare HSL triplet.** The utilities read the token
> **raw** (`.bg-muted { background-color: var(--muted) }`), so `--muted: 0 0% 10%`
> resolves to the *string* `"0 0% 10%"` — not a color — and the declaration is
> silently dropped: **transparent fills + white-fallback borders.** Always write
> the full color: `--muted: hsl(0 0% 10%)`. This bites most often when a component
> overrides tokens **inline** (e.g. an old `ForceTheme` wrapper). If part of a page
> has vanished backgrounds / white borders, that's this — see
> [`../theme/TROUBLESHOOTING.md`](../theme/TROUBLESHOOTING.md) and prefer
> `ThemeOverride` over inline token maps.

## Semantic tokens — never use raw color scales

**Rule:** never write `bg-amber-500`, `text-green-700`, `border-gray-200`. Use semantic tokens — they adapt to both themes and to whatever preset is active.

### Base tokens

| Class | Token | Purpose |
|---|---|---|
| `bg-background` / `text-foreground` | `--background` / `--foreground` | Page surface + body text |
| `bg-card` / `text-card-foreground` | `--card` | Card surface (elevated over background) |
| `bg-popover` / `text-popover-foreground` | `--popover` | Floating menus, tooltips, dropdowns |
| `bg-muted` / `text-muted-foreground` | `--muted` | Subtle surface (input rest, chips, secondary text) |
| `bg-accent` / `text-accent-foreground` | `--accent` | Hover + selected surface — **neutral gray** (no brand tint), quiet like macOS/Claude |
| `bg-primary` / `text-primary-foreground` | `--primary` | Brand CTA (filled buttons, links) — cyan |
| `bg-secondary` / `text-secondary-foreground` | `--secondary` | Neutral filled controls |
| `bg-destructive` / `text-destructive-foreground` | `--destructive` | Error / delete filled controls |
| `border-border` | `--border` | Card outlines, control borders |
| `border-divider` / `.divider-b` | `--divider` | **Hairline between rows** — deliberately *lighter than `--card`* so it stays visible on elevated surfaces (a `--border` line vanishes on a card) |
| `bg-overlay` | `--overlay` | Modal scrim / backdrop behind dialogs, drawers, sheets — black scrim in both themes, the token owns the opacity |
| `bg-input` | `--input` | Input **fill** — a notch off the panel so fields read as real controls (not flush holes). The input *border* uses `--border`, not `--input` |
| `ring-ring` | `--ring` | Focus rings, selected outlines — **blue** (system-accent feel), independent of the cyan brand |

### Status surface tokens (light + dark)

Each status has the full 4-token set in **both** themes for banners and alerts:

| Role | Class | Token |
|---|---|---|
| Icon / accent | `text-warning` | `--warning` |
| Banner background | `bg-warning-background` | `--warning-background` |
| Readable text | `text-warning-foreground` | `--warning-foreground` |
| Border ring | `border-warning-border` | `--warning-border` |

Available statuses: **`warning`** · **`success`** · **`destructive`** · **`info`**. Reference consumers: `feedback/banner`, `specialized/copy`, `forms/button-download`, `data/status`, `data/stat`.

```tsx
<div className="flex items-center gap-3 rounded-md border border-warning-border/40
                bg-warning-background px-3 py-2 text-xs text-warning-foreground">
  <Icon className="h-4 w-4 text-warning" />
  <span>You're on a preview plan.</span>
</div>
```

#### On-fill text — `*-foreground` vs `on-*` (read this before styling a badge)

There are **two** text tokens per status and they are NOT interchangeable:

| Token | Class | Sits on | Use for |
|---|---|---|---|
| `--{status}-foreground` | `text-success-foreground` | the tinted **`*-background`** | banner / alert copy (a *colored* tint) |
| `--on-{status}` | `text-on-success` | the **solid `*` fill** | text/icon on a filled badge, unread pill, filled chip |

`*-foreground` is a *status-colored* tint tuned for the faint banner surface — on the solid fill it produces **green-text-on-green-fill** (unreadable). `on-*` is a near-black / near-white contrast ink (the WhatsApp/Telegram pattern: dark text on the green pill) that meets WCAG AA against the fill in both themes.

```tsx
{/* ✅ unread count pill — dark ink on the green fill */}
<span className="rounded-full bg-success px-2 py-0.5 text-xs font-semibold text-on-success">
  {unread}
</span>

{/* ❌ green-on-green — *-foreground is for banners, not the fill */}
<span className="bg-success text-success-foreground">{unread}</span>
```

On-fill tokens exist for **`success`** · **`warning`** · **`info`** · **`destructive`** in both themes. (`--primary-foreground` / `--secondary-foreground` / `--destructive-foreground` already play the on-fill role for those base fills — they're genuine contrast colors, not tints, so no `on-*` is needed for them.) The base `on-*` is a near-black; a preset only re-declares it when it retints a fill dark enough that near-black fails — e.g. **`windows`** light mode sets `on-success` / `on-info` to white because Fluent's light green/blue fills are very dark.

The **brand presets** (`macos` / `ios` / `windows` / `django-cfg`) retint the full
status set to their own canvas, so banners read correctly on their custom
backgrounds. The **modifier presets** (`soft` / `dense` / `high-contrast`) and
`default` inherit the base status surfaces. Either way the four-token set is
always defined, so `bg-warning-background` etc. are safe everywhere. For a fully
preset-agnostic surface you can still derive from the base color with opacity:

```tsx
<div className="rounded-md border border-warning/30 bg-warning/10 text-warning">…</div>
```

### Code & sidebar tokens

| Class | Purpose |
|---|---|
| `bg-code` / `text-code-foreground` / `border-code-border` | Code block panels (markdown fences, terminal blocks) |
| `bg-code-inline` / `text-code-inline-foreground` | Inline `<code>` chips |
| `bg-sidebar` / `text-sidebar-foreground` / `border-sidebar-border` | App sidebar chrome |
| `bg-sidebar-accent` / `text-sidebar-accent-foreground` | Sidebar hover state |

### Chart tokens (categorical palette)

`--chart-1 … --chart-5` are the categorical series colors (chart-1 = brand hue).
Like every color token they are **fully-wrapped `hsl(...)`** and bound to
Tailwind via `--color-chart-*` in `tokens.css`, so the utilities work with
opacity modifiers:

```tsx
<div className="bg-chart-1" />          {/* solid */}
<div className="bg-chart-3/40" />       {/* 40% via color-mix */}
<span className="text-chart-2" />
```

For Recharts / SVG / Canvas, pass the variable directly — **never** wrap it:

```tsx
<Bar fill="var(--chart-1)" />          {/* ✅ */}
<Bar fill="hsl(var(--chart-1))" />     {/* ❌ hsl(hsl(...)) — invalid */}
```

> **JIT-scan gotcha (charts/status).** `bg-chart-${n}` / `bg-${status}-background`
> built from template literals are invisible to Tailwind's static scanner —
> only literal classes get a CSS rule. Use literal class names (or inline
> `style={{ background: 'var(--chart-N)' }}`) when the index is dynamic.

### Typography tokens

`--font-sans` / `--font-mono` and the size scale (`--font-size-xs … -xl`,
`--line-height-base`, `--letter-spacing-base`) live in `base.css` and are
**overridable per preset** (e.g. `macos` → SF Pro, `windows` → Segoe UI
Variable). `tokens.css` bridges the size scale onto Tailwind's `--text-*` tokens,
so `font-sans` / `font-mono` and `text-xs … text-xl` follow the active preset
instead of Tailwind's hardcoded defaults. `body` applies font-sans + base
size/line-height/tracking directly.

#### The font-size scale → `text-*` bridge (one source of truth)

The size tokens are plain rem values. The `macos` preset
(`presets/themes/macos.ts`) sets them to the Apple HIG point scale:

| Token | macos value | px (@1×) | Used for |
|---|---|---|---|
| `--font-size-xs` | `0.6875rem` | 11px | captions, timestamps |
| `--font-size-sm` | `0.75rem` | 12px | footnotes, secondary labels |
| `--font-size-base` | `0.8125rem` | 13px | HIG default body |
| `--font-size-lg` | `0.9375rem` | 15px | subheadings |
| `--font-size-xl` | `1.0625rem` | 17px | titles, nav bar |

**The key fact:** Tailwind's `text-*` utilities don't read their own hardcoded
sizes — they're bridged to these vars in `tokens.css` via `@theme inline`:

```css
@theme inline {
  --text-xs:   var(--font-size-xs);
  --text-sm:   var(--font-size-sm);
  --text-base: var(--font-size-base);
  --text-lg:   var(--font-size-lg);
  --text-xl:   var(--font-size-xl);
}
```

So `--font-size-*` is the **single source of truth** for text sizing: change one
var and **every** `text-sm` / `text-base` / … in that scope re-sizes uniformly —
no per-component edits, no chasing `text-[15px]` literals across the tree.

#### Override recipe (a) — global bump via `buildThemeStyleSheet`

To lift the whole UI a notch (e.g. a desktop consumer that wants 15px body),
pass `vars` alongside the preset — they merge on top of the preset's values per
mode (`buildThemeStyleSheet` → `mergeLayer`), emitting `:root` (light) and
`.dark` blocks. Every `text-*` utility moves with them:

```ts
import { buildThemeStyleSheet } from '@djangocfg/ui-core/styles/presets';

const css = buildThemeStyleSheet({
  preset: 'macos',
  vars: {
    light: { 'font-size-base': '0.9375rem', 'font-size-sm': '0.8125rem' },
    dark:  { 'font-size-base': '0.9375rem', 'font-size-sm': '0.8125rem' },
  },
});
// inject `css` after ui-core/styles (cmdop does exactly this)
```

> Keys are the bare token name (no `--` prefix); `buildThemeStyleSheet` adds it.

#### Override recipe (b) — scoped bump on a selector

To re-size only a subtree, re-declare the font-size vars on a selector. The
bridge re-points `text-*` for everything inside it — no preset rebuild:

```css
.compact-panel {
  --font-size-base: 0.75rem;   /* 12px */
  --font-size-sm:   0.6875rem; /* 11px */
}
```

**Prefer either recipe over per-component `text-[15px]` hacks** — those drift
from the scale and don't follow the preset or theme.

## Radius tokens

The radius **scale** (`--radius`, `--radius-sm`, …) is theme-independent and lives in `base.css`; presets that set a semantic `radius` regenerate the scale via `presets/build.ts`. A few named radii are fixed defaults (default preset only):

| Token | Value | Used by |
|---|---|---|
| `--radius-control` | `0.625rem` | **Interactive controls** (inputs, nav items, search, value chips) — one shared corner so they round consistently. Class: `.rounded-control` |
| `--radius-dialog` | `1rem` | Dialog panels. Applied at **all** sizes (was `sm:`-gated, which left phones square) |
| `--radius-popover` | `0.75rem` | Popovers / menus |

## Focus rings (Vercel / Linear pattern)

Inputs use a **crisp thin accent outline**, not a blurry halo: the border turns `--ring` (blue) plus a tight `ring-1 ring-ring`, `:focus-visible` only.

```
focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
```

The native browser focus outline (often white/auto, which used to pierce through on click) is reset app-wide in `base.css`:

```css
:where(input, textarea, select, button, a, [tabindex], [role="button"], [contenteditable]):focus { outline: none; }
```

So every interactive control relies on its own `focus-visible:ring-*` — keyboard a11y is preserved, the stray native outline is gone. The single source for input styling is `INPUT_CLASS` / `inputClass(size)` exported from `components/forms/input` and **reused by `Editable`**, so standalone inputs and inline-edits focus identically.

## Scan-independent utility classes

Some classes are authored as **plain CSS** in `utilities/*.css` (not Tailwind utilities):

| Class | File | Why plain CSS |
|---|---|---|
| `.divider-b` / `.divider-t` | `utilities/divider.css` | Hairline via `--divider` |
| `.rounded-control` | `utilities/controls.css` | Shared control radius |

> **JIT-scan gotcha.** Tailwind's content scan covers `ui-core/src` but **not always every consumer package** (e.g. the `layouts` package source isn't always scanned by Storybook/apps). A *new* arbitrary Tailwind class used **only** in a consumer (`border-zinc-500/25`, `border-foreground/[0.12]`, `rounded-[var(--x)]`) then produces **no CSS rule** and silently falls back to the global `* { border-color: var(--border) }` — the class is in the DOM but the computed color is wrong. **Fix pattern:** for any token-driven visual that consumers need, add a plain `.class` in `ui-core/styles/utilities/*` and `@import` it, then use that class downstream. Don't invent new Tailwind classes in the `layouts` package.

## Theme presets — 8 production-ready

Two families, with different coverage by design:

- **Brand / OS presets** declare a *full* token set — colors, sidebar, charts,
  status surfaces, divider, and (macos/windows) typography — so the identity is
  self-contained and survives layering over any base.
- **Modifier presets** override only the chrome they're about (radius / borders /
  contrast) and **inherit** brand colors, charts, and status from whatever is
  active beneath them — so they compose (`django-cfg` + `dense`, etc.).

| ID | Family | Use case |
|---|---|---|
| `default` | base | Default ui-core theme — cyan brand |
| `django-cfg` | brand | Brand identity — brand-washed accent/sidebar + on-brand `info` |
| `macos` | brand | Pixel-accurate Apple HIG (Sequoia / Tahoe 26) — SF Pro, Wails/Electron desktop |
| `ios` | brand | iOS app feel — 0.75rem radius, systemBlue, iOS status colors |
| `windows` | brand | Microsoft Fluent 2 — Segoe UI Variable, 0.375rem radius |
| `soft` | modifier | Larger radius (1rem) — friendly marketing surfaces |
| `dense` | modifier | Smaller radius (0.25rem) — data-heavy admin UIs |
| `high-contrast` | modifier | A11y boost — stronger borders, harder text, pure canvas |

### Apply a preset

```tsx
import { buildThemeStyleSheet } from '@djangocfg/ui-core/styles/presets';

// Static: bake into your app's CSS at build time
const css = buildThemeStyleSheet({ preset: 'macos' });
// then write `css` to a file and `@import` it after ui-core/styles

// Runtime: inject into <head> (use case: settings picker)
useEffect(() => {
  const el = document.createElement('style');
  el.textContent = buildThemeStyleSheet({ preset });
  document.head.appendChild(el);
  return () => el.remove();
}, [preset]);
```

### Override individual tokens

To re-tint only a couple of variables without copying a whole preset:

```css
:root.my-brand {
  --primary: hsl(280 80% 55%);
  --ring: hsl(280 80% 55%);
}
```

Toggle `<html class="my-brand">` and the override applies on top of whichever preset is active.

## Glass utilities — macOS / Windows / Liquid

`utilities.css` ships four backdrop-blur classes. Each is built with `color-mix` over a token, so they work in any preset / theme.

| Class | Recipe | When |
|---|---|---|
| `.glass-macos` | `blur(20px) saturate(180%)` · 72% `--background` | Sidebar, popovers, sheet headers |
| `.glass-liquid` | `blur(12px) saturate(180%)` · 60% `--card` + inner highlight + border | Floating chrome (macOS 26 Dock, FAB) |
| `.glass-header` | `blur(8px) saturate(160%)` · 80% `--background` | Nav bars, status strips |
| `.glass-win11` | `blur(60px) saturate(125%)` · 85% `--background` | Windows Mica / Acrylic |

Each requires a **non-transparent parent** (something for the blur to chew on). Avoid stacking — blur compounds.

## App setup

### Golden path (recommended) — one import, layer-safe

```css
/* Single line. Imports Tailwind + tokens + base + utilities in the
   correct cascade layers. Put it FIRST; other package CSS after. */
@import "@djangocfg/ui-core/styles/full";

@import "@djangocfg/layouts/styles";
@import "@djangocfg/ui-tools/styles";
```

`…/styles/full` (`full.css`) pins ui-core's base resets to `@layer base`
and its custom utilities to `@layer utilities` via `@import "…" layer(name)`.
A `layer()`-qualified import is folded into that layer **regardless of
import order or build tool**, so you cannot get the ordering wrong, and
you do not need to import `tailwindcss` yourself.

### The cascade-layer rule (why ordering matters)

Tailwind v4 emits its utilities inside `@layer utilities`. **Unlayered
CSS beats any layered rule** in the cascade. So if a package's base
resets (here `* { border-color }` and the `body` background/font rules in
`base.css`) are emitted *unlayered*, they sit above `@layer utilities`
and silently defeat layout utilities (`gap`, `space-y`, `divide`, `flex`,
`border`, `padding`). Colors usually survive because they flow through
CSS vars (no cascade conflict), so the breakage is invisible in Chrome
but shows up in stricter engines (WKWebView).

Whether a *plain*, unlayered `@import` lands in a layer depends on its
position relative to `@import "tailwindcss"` **and** on the build tool
(Vite vs Next.js resolve `@import` differently). `full.css` removes that
dependency by binding each file to a layer explicitly. **Use `…/styles/full`
and this whole class of bug cannot occur.**

### Manual ordering (only if you manage layers yourself)

The plain `@djangocfg/ui-core/styles` entry imports `theme.css` +
`sources.css` + `base.css` + `utilities.css` **unlayered** (it does not
import Tailwind). If you use it, you own the layer ordering. The Next.js
demo does this and works because PostCSS folds the trailing
`@import "tailwindcss"` such that the resets still end up benign — but a
Vite consumer that puts `@import "tailwindcss"` last hit exactly the bug
above. If you must hand-order, import `tailwindcss` **first**:

```css
@import "tailwindcss";              /* establishes the layers first */
@import "@djangocfg/ui-core/styles";
@import "@djangocfg/layouts/styles";
@import "@djangocfg/ui-tools/styles";
```

> **No `@plugin "tailwindcss-animate"` needed** in v4 — the keyframes ship via `theme/animations.css`. Use `tw-animate-css` instead if you need extra utilities.

### Why `@source` is required

Tailwind v4 doesn't scan across npm packages automatically. Each consumer needs either a `@source` directive or to import a `sources.css` from the package — `@djangocfg/ui-core/styles` already chains its own `sources.css`, so importing `…/styles` is enough.

### Wiring a custom display font (consumer recipe)

`ui-core` ships `.font-display` and a display type ramp (`.text-display-xl` / `.text-display-lg` / `.text-display`) that read from a `--font-display` CSS variable. The variable itself is **not** set — apps pick the font.

In a Next.js app with `next/font`:

```tsx
// app/layout.tsx
import { Plus_Jakarta_Sans } from 'next/font/google';

const display = Plus_Jakarta_Sans({
  subsets: ['latin'],
  weight: ['700', '800'],
  variable: '--font-display',  // exposes as CSS var
  display: 'swap',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={display.variable}>
      <body>{children}</body>
    </html>
  );
}
```

Then in `globals.css` register it as a Tailwind token so the `font-display` utility class works:

```css
@theme {
  --font-display: var(--font-display), ui-sans-serif, system-ui, sans-serif;
}
```

Now any of these work:

```tsx
<h1 className="text-7xl text-display-xl">Hero title</h1>
<h2 className="text-4xl text-display-lg">Section title</h2>
<h3 className="text-xl text-display">Card title</h3>
<span className="font-display">Plain display family</span>
```

## Theme Showcase story

The `UI Core/Theme Showcase` story in djangocfg storybook renders every base
token (incl. `divider`), the real status surfaces (`*-background`/`*-foreground`/
`*-border`), the chart palette (solid + /40 opacity), the typography scale,
button variants, cards, form controls, glass utilities, and an opacity
sanity-check on one page. Switch the `preset` control to flip across all 8
themes; flip light/dark from the toolbar.

Use it to validate any token change before publishing — every regression shows up on one screen.

## Local edits → consumer sync (debugging only)

These packages are usually consumed via pinned npm versions. To hot-test a change without publishing:

```bash
CONSUMER=/path/to/consumer/frontend/node_modules/.pnpm/@djangocfg+ui-core@…/node_modules/@djangocfg/ui-core/src/styles/theme
SRC=./src/styles/theme

cp "$SRC/light.css"  "$CONSUMER/light.css"
cp "$SRC/dark.css"   "$CONSUMER/dark.css"
cp "$SRC/tokens.css" "$CONSUMER/tokens.css"
```

Restart the consumer's dev server (Next.js / Vite cache module resolution). Revert before committing — `file:` paths in `package.json` cause `@types/react` dedup failures.

## Gotchas

### Arbitrary Tailwind values

`h-[80px]`, `z-[100]` etc. may not work in v4. Prefer scaled tokens (`h-20`, `z-100`) — they're already registered in `tokens.css` under `--spacing-*` / `--z-index-*`.

### Opacity in arbitrary classes

`shadow-[0_0_0_1px_var(--ring)]` works (underscores get parsed as spaces). For nested `color-mix`, use underscores everywhere:

```tsx
className="bg-[color-mix(in_oklab,var(--primary)_30%,transparent)]"
```

### Glass over transparent background

`.glass-*` needs an opaque parent to blur. On a fully transparent canvas (e.g. Wails translucent window) put a base layer first or the blur is invisible.

## Programmatic palette access (JS / Canvas / SVG)

For contexts that can't read CSS vars (Canvas 2D, Mermaid, react-pdf, SVG fill attrs):

```tsx
import {
  useThemePalette,
  useStylePresets,
  useBoxColors,
  alpha,
} from '@djangocfg/ui-core/styles/palette';

const palette = useThemePalette();
ctx.fillStyle = palette.primary;              // '#0989aa'
ctx.fillStyle = alpha(palette.warning, 0.15); // 'rgba(…, 0.15)'

const presets = useStylePresets();
presets.success // { fill: '#…', stroke: '#…', color: '#fff' }
```

These hooks resolve the live CSS values via `getComputedStyle` and re-run on theme changes.
