Theming
aihu themes components with design tokens — CSS custom properties the @aihu/css-engine utility table resolves against. A utility like bg-primary compiles to background-color: var(--color-primary); the value of that token comes from a style pack. Swap the pack and every component re-themes with no markup change, because the token names are the contract and the values are interchangeable.
For the broader styling model — scoped output, WC-native variants, cn() — see Styling.
The design-token contract
A token is a CSS custom property. Throughout this page and the defineStylePack() API, token names are written without the leading --; the engine adds it.
Color tokens
These are the brand tokens: the ones the utility table resolves bg-, text- and border-* against. Every pack must declare all 24, or some utility somewhere resolves to a dangling var().
| Role | Tokens |
|---|---|
| Core | color-primary, color-primary-foreground, color-secondary, color-secondary-foreground, color-accent, color-accent-foreground |
| Surfaces | color-surface, color-surface-foreground, color-background, color-foreground |
| Support | color-muted, color-muted-foreground, color-border, color-ring |
| Semantic state | color-destructive, color-destructive-foreground, color-info, color-info-foreground, color-success, color-success-foreground, color-warning, color-warning-foreground |
| Filled surface | color-neutral, color-neutral-foreground |
Every color role is paired with a -foreground. That is not decoration: the pairing is what makes a role safe to use as a background, and the contrast of every pair is verified against WCAG tiers by .tastemaker/check_contrast.py --pairings. If you author a pack, run it — a role whose foreground fails contrast is a bug in the pack, not a matter of taste.
Non-color scalars
Packs also declare scalars that are not part of the bg-/text-/border- utility path. Recipes and components reference them directly as var(--border) and friends, so a pack that omits them leaves those components unstyled rather than mis-styled:
| Group | Tokens |
|---|---|
| Radius | radius-sm, radius-md, radius-lg, radius-pill |
| Spacing | space-1, space-2, space-3, space-4, space-6, space-8, space-12, space-16 |
| Typography | font-sans, font-mono, font-serif |
| Control metrics | size-selector, size-field, border, depth, noise |
| Expressive | gradient-brand, ease-brand |
font-serif is worth calling out. The font-serif utility has existed in the table for a long time, emitting font-family: var(--font-serif) — but no pack defined the token, so the utility was silently dangling. It is part of the contract now.
The two shipped packs
Both declare the same token names — only values differ — so they are drop-in interchangeable:
aihu-default— the aihu brand palette (warm paper and ink, accent#c8543a). Light values in:root, dark overrides in.dark, [data-theme="dark"].aihu-graphite— a neutral monochrome ramp inoklch()(chroma ≈ 0), same names, same structure.
A pack emits a light block under :root, an optional dark block under .dark, [data-theme="dark"], and one [data-theme="<name>"] block per named theme.
How tokens reach scoped components
Custom properties inherit through the shadow boundary. A pack declared once on :root flows into every component on the page, shadow-scoped or not. There is no per-component theme wiring.
That inheritance is the whole mechanism, and it is why the aliasing pitfall below bites.
Caveat —
dark:utilities. The dark block is dual-keyed on both.darkand[data-theme="dark"], so token values flip either way. Thedark:variant compiles to a:root.dark/:host([data-theme])gate, so a page that sets onlydata-theme="dark"on<html>gets correct token values but notdark:-variant rules. If you usedark:variants, set the.darkclass as well.
The packs as JS objects
import { aihuDefault, aihuGraphite } from '@aihu/css-engine/packs'
aihuDefault.tokens['color-accent'] // '#c8543a'
aihuDefault.toCss() // ':root { … } .dark, [data-theme="dark"] { … }'These objects are the source of truth for the shipped styles/*.css bundles — the CSS is generated from them via pack.toCss(), so the two access paths cannot drift. They are produced by defineStylePack(), the same API you would use, so the built-ins carry no privileged shape.
defineStylePack() — custom packs
import { defineStylePack } from '@aihu/css-engine'
const acme = defineStylePack({
name: 'acme',
tokens: { 'color-primary': '#0a7', 'radius-md': '6px' },
dark: { 'color-primary': '#3fc' },
})
acme.toCss()
// :root { --color-primary: #0a7; --radius-md: 6px; }
// .dark, [data-theme="dark"] { --color-primary: #3fc; }The returned StylePack carries name, tokens, dark, themes, themeNames, and toCss().
Token names normalize whether or not you write the leading --. An empty name or empty tokens map throws. Declare only what you override — or the full contract above if you want a stand-alone pack with no dangling utilities.
Named themes
Beyond light and dark, a pack can declare any number of named themes. Each emits its own [data-theme="<name>"] block:
const acme = defineStylePack({
name: 'acme',
tokens: { 'color-primary': '#0a7', 'color-background': '#fff' },
dark: { 'color-primary': '#3fc' },
themes: {
cupcake: { 'color-primary': '#65c3c8', 'color-background': '#faf7f5' },
dracula: { 'color-primary': '#ff79c6' },
},
})
acme.themeNames // ['cupcake', 'dracula']A named theme is an override layer over tokens, not a standalone theme — list only what differs, exactly as dark works.
Order is the cascade. :root, the dark block and every [data-theme] block have identical (0,1,0) specificity, so the last match wins. toCss() emits them in that order deliberately: with <html class="dark" data-theme="cupcake"> you get cupcake, because an explicit selection should beat an inherited one.
Theme names must match /^[a-z][a-z0-9-]*$/, since they become attribute selectors. dark is reserved — use the dark field.
Applying a pack
1. Import a built-in CSS bundle. Both bundles are declared in the package exports, so Vite inlines them:
import '@aihu/css-engine/styles/aihu-default.css'
document.documentElement.classList.toggle('dark')2. Inject toCss() yourself — for runtime-generated <style>, or when you need to read individual tokens:
import { aihuDefault } from '@aihu/css-engine/packs'
const style = document.createElement('style')
style.textContent = aihuDefault.toCss()
document.head.appendChild(style)3. A custom pack — same shape, your tokens:
import { defineStylePack } from '@aihu/css-engine'
import { appTokens, appDark } from './tokens.ts'
const pack = defineStylePack({ name: 'app', tokens: appTokens, dark: appDark })
document.head.appendChild(
Object.assign(document.createElement('style'), { textContent: pack.toCss() }),
)Aliasing pack tokens
If you layer your own semantic properties over the pack's --color-* tokens, declare the alias under every theme selector the pack uses — not just :root:
/* re-resolves per theme */
:root, .dark, [data-theme="dark"] {
--surface: var(--color-surface);
--ink: var(--color-foreground);
}A :root-only alias silently breaks dark mode:
/* freezes the LIGHT value */
:root {
--surface: var(--color-surface);
}Custom properties are computed where they are declared. A :root-only alias resolves var(--color-surface) against the light value once, and that fixed value then inherits into .dark containers and every shadow root beneath them. The symptom is a half-dark page: elements using --color-* directly flip, while elements using the alias stay light.
See also
- Styling — scoped output, WC-native variants,
cn() - Utility Classes — the full utility index
- Primitives — headless behaviors that consume these tokens
- @aihu/css-engine — the export tables