G · 08 guides

Guides / Theming

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 in oklch() (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 .dark and [data-theme="dark"], so token values flip either way. The dark: variant compiles to a :root.dark / :host([data-theme]) gate, so a page that sets only data-theme="dark" on <html> gets correct token values but not dark:-variant rules. If you use dark: variants, set the .dark class as well.

The packs as JS objects

governedtsts204 B
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

governedtsts322 B
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:

governedtsts336 B
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:

governedtsts100 B
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:

governedtsts172 B
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:

governedtsts290 B
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:

governedcsscss134 B
/* 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:

governedcsscss74 B
/* 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