CSS is Awesome

Authoring a theme

Since v0.8, a theme is a single SCSS source at scss/themes/<name>.scss that compiles to one CSS file at public/themes/<name>/theme.css. Both light and dark modes live inside the single file via native light-dark(). The validator guarantees a clean one-file swap.

Overview

Quickstart — a new theme

// scss/themes/midnight.scss
@use '../mixins' as m;

@include m.theme('midnight') {
  /* Surfaces — light-dark() handles both modes */
  --background-default: light-dark(#f5f5f7, #0a0a0e);
  --surface-default:    light-dark(#ffffff, #14141a);
  --text-primary:       light-dark(#0a0a0e, #f5f5f7);
  --text-secondary:     light-dark(#54545e, #b5b5bf);

  /* Primary accent + auto-derived states via color-mix */
  --action-primary-default: light-dark(#3A5FCD, #60a5fa);
  @include m.states(action-primary);

  /* Typography, radius, motion — identical across modes (no light-dark needed) */
  --font-sans: system-ui, sans-serif;
  --r-md: 6px;
  --duration-fast: 120ms;
}
# build + validate
npm run build:css:themes
node scripts/theme-validator.js public/themes/midnight/theme.css

The theme() signature

@mixin theme($name, $scheme: light dark, $standalone: true)

Three shapes inside a single theme file:

From design tokens (Figma → theme.css)

If your palette, spacing and type scale already live in a design tool, skip the SCSS: cia theme from-tokens turns a design-tokens file into a complete, validated theme.css in the exact shape the shipped themes use. No Sass involved — the CLI, the MCP tool theme_from_tokens and the in-process handler are one function (scripts/tokens-to-theme.cjs, shipped in the package).

# DTCG, Tokens Studio, or a flat --token map — format auto-detected
npx cia theme from-tokens tokens.json --name acme --out src/styles/acme.css

# a separate dark file → light-dark() values
npx cia theme from-tokens light.json --dark dark.json --name acme --json

# inherit whatever you did not specify from a different shipped theme
npx cia theme from-tokens brand.json --name acme --base press

Input formats

How a path becomes a token

  1. An explicit table maps Figma-style names that do not spell the cia token: typography.font.body → --font-sans, shape.radius.md → --radius-md, effect.shadow.md → --shadow-md, motion.duration.fast → --duration-fast, layer.modal → --z-modal.
  2. Otherwise a leading color. / colors. is stripped, common group names are rewritten (spacing. → space., border-radius. → radius., elevation. → shadow., zIndex. → z., fontFamilies. / font.family. → font., components. → component.). Per-component overrides are accepted flat or nested, and both land on the same contract token: component.btn-radius and component.button.radius both give --btn-radius, and because the contract names some of these the other way round, component.card-shadow gives --shadow-card, and the segments are joined with -. If that name is in the contract it is used: space.4 → --space-4, brand.primary → --brand-primary, text.primary → --text-primary, code.bg → --code-bg.
  3. Anything else is emitted verbatim as --<joined-path> and listed in report.unmapped, so your own tokens survive and nothing disappears.

Minimum content

None. Every required contract token the file does not supply is inherited from the base theme (--base, default boilerplate; --base list prints the 24 options) and listed in report.inherited. Optional tokens are never inherited — the library default applies. The validator and the WCAG contrast audit run on the result and are returned as validation; the CLI exits non-zero on a failure unless you pass --allow-a11y-fail. In practice a brand palette plus a text colour is enough to get a passing theme; the more of the contract the file supplies, the less it borrows.

Read the mapping as data

If another tool has to map the same token names the same way (a boilerplate registry, a Figma plugin, an agent), do not copy the rules above — read them. npx cia theme map --json prints the explicit table, the prefix rewrites and the generic rule as one JSON object; npx cia theme map --path color.text.primary shows how a single name resolves and whether the target is required or optional. The MCP tool get_token_map returns the same data, and require('css-is-awesome/scripts/tokens-to-theme.cjs') exposes it in-process as tokenMap() and resolvePath(path).

The token contract

The machine-readable source of truth is scripts/theme-contract.json. It lists every CSS custom property a theme must declare. The validator reads this file — if you add a token to the base library, you add it to the contract, and every theme then has to declare it.

The tokens fall into these categories:

127 tokens are required; 49 more are optional, 176 in total. The optional set is the per-component radius and shadow overrides, the logo hooks, the named durations, the touch-target minimum, and the t-shirt spacing aliases.

See /docs/tokens for the full gallery with live swatches and current values for each shipped theme.

Page surfaces — hero and band

A theme reskins components, but the page behind them used to be one flat colour: a landing page and a docs page looked the same underneath. Two optional surfaces fix that — a hero for a front page and a band for a section stripe or a footer. Each has four tokens, all optional.

--page-hero-bg:     light-dark(#f2f6fe, #121d36);
--page-hero-ink:    light-dark(#09090b, #fafafa);
--page-hero-image:  url("/hero.jpg");  /* gradient or url() you host */
--page-hero-scrim:  rgb(0 0 0 / 0.5);  /* only paints over an image */
/* --page-band-* takes the same four */

Nothing happens until you ask

Declaring these changes no pixel. A page only takes a surface when you apply one, which is why every shipped theme can define them without touching an existing site. cia never applies a page surface on its own — not on body, not from a layout mixin, not from the reset — and a test compiles every shipped bundle to prove it.

// SCSS — on any element
.landing { @include cia.surface(hero); }

// …or no SCSS at all, for a whole page
<body data-surface="hero">

The attribute form needs one call to cia.surface-attributes() in a global stylesheet. Both paths read the same custom properties, so there is one place to maintain.

Images and the scrim

A theme is a single CSS file, so an image is a url() you host or a gradient, which needs nothing external. Contrast over a photo cannot be measured, so the scrim is the safety net: pass an image to the mixin and it lays 50% black between the picture and your text. A colour-only surface gets no scrim, because a wash over a flat colour would just darken it.

Every shipped theme already carries a derived bg and ink pair — its own page surface nudged a few percent toward its own brand accent. That keeps the contrast relationship the theme already passes, so all 24 land above 10:1. Override either token to design a hero deliberately.

--background-hero is deprecated in favour of --page-hero-bg, which falls back through it, so an old declaration keeps working until contract 2.

Upgrading a theme

You never have to. cia deprecates a token instead of deleting it, so an old declaration keeps resolving until the next major. When you do want to move, one command does it:

# show me what would change
npx cia fix-theme src/styles/acme.css

# apply it
npx cia fix-theme src/styles/acme.css --write

It renames the property and nothing else — your values, comments, ordering and whitespace survive byte-for-byte, so the theme renders exactly as it did. If a block already declares the replacement, that line is left alone and reported: deciding which of two values you meant is your call, not the tool’s. Agents reach the same function as the MCP fix_theme tool, and npx cia analyze points here when it finds a deprecated token in your stylesheets.

The density knob — three levels of power

Every shipped theme derives its 9 spacing steps from one master variable via calc():

--space-unit: 0.125rem;
--space-1: calc(var(--space-unit) * 4);  /* 0.5rem */
--space-4: calc(var(--space-unit) * 8);  /* 1rem */
/* ...through --space-9 */

Nothing about that is locked — there are three levels, and none of them require editing more than what you actually mean to change:

  1. The unit — override --space-unit alone and all 9 steps rescale together, proportionally, at runtime. This is what the theme editor's density slider edits. Halve it for a noticeably tighter UI; raise it for an airier one — one edit instead of nine.
  2. A step — override a single --space-N with a literal (e.g. --space-5: 2rem;) to deliberately break the ramp at just that rung. A literal always wins over the calc() it replaces, so this keeps working exactly as before — nothing about the density knob changes how an individual override behaves.
  3. Raw passthrough — a component that needs a spacing value outside the 9-step scale entirely isn't stuck: call m.space(0.5rem) (a literal length instead of a scale key) or m.grid(n) (any multiple of cia's 4px geometric grid, deliberately not themed — see the mixin's own doc comment in scss/_mixins.scss). Neither of those touches --space-unit or the numbered scale at all.

Themes get this for free by using the shared mixin instead of hand-writing all 10 values:

@use '../spacing-scale' as ss;

@include m.theme('my-theme') {
  @include ss.space-scale;  /* --space-unit + --space-0..9, default unit 0.125rem */
  /* or: @include ss.space-scale($unit: 0.15rem); for a theme that ships airier by default */
}

A theme.css you download from the editor is a flattened snapshot — every token, --space-unit included, is exported as a plain literal, same as every other token. To keep the knob live in your own fork, swap the nine --space-N lines back for calc(var(--space-unit) * N), matching the multipliers in scss/_spacing-scale.scss.

File structure

A compiled theme file is a font @import (optional) plus a single :root, :root[data-theme="name"] block that sets every required token. Preserve the commented section headers — they make the file scannable and keep the contract visually grouped.

/* ============================================================
   THEME — My Brand
   ============================================================ */

@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300..700&display=swap');

:root, :root[data-theme="my-brand"] {
  /* Surfaces */
  --paper:        #FFFFFF;
  --paper-raised: #F7F7F5;
  --paper-sunk:   #EEEEEA;
  --paper-glass:  rgba(255,255,255,0.80);

  /* Ink */
  --ink:       #0A0A0A;
  --ink-soft:  #4A4A4A;
  --ink-faint: #8A8A8A;

  /* Lines, Primary, Seal, Accent, Code, Type, */
  /* Radius, Shadow, Blur, Glow, Motion... */

  /* Semantic aliases — bridge to library names */
  --surface-default: var(--paper);
  --surface-raised:  var(--paper-raised);
  --text-primary:    var(--ink);
  --border-default:  var(--hair-soft);
}

In the shipped consolidated file the body is identical, minus the bare :root — inside a bundle every theme would match :root at once and the last one would win, so the attribute is the only thing telling them apart. You do not write this by hand: npm run build:css:themes regenerates public/theme.css from every built theme and strips the bare :root for you.

:root[data-theme="my-brand"] {
  /* same 127 required token declarations */
  --paper: #FFFFFF;
  /* ... */
}

Step by step

Edit the SCSS source, never the built CSS. public/themes/*/theme.css is a build artifact, and npm run check:theme-drift fails CI if it stops matching its source.

  1. Copy scss/themes/press-light.scss (or any shipped theme) as a starting point. Press is a good editorial baseline; Cupertino is a good rounded/soft baseline; Terminal is a good dark-mode baseline.
  2. Rename the file to scss/themes/<your-theme>.scss and change the name you pass to m.theme('…') to match. npm run build:css:themes compiles it to public/themes/<your-theme>/theme.css.
  3. Change every token value — leave every token name. Work category by category: start with surfaces (--paper, --paper-raised, --paper-sunk), then ink (--ink, --ink-soft, --ink-faint), then the rest of the palette, then type, then radii and shadows.
  4. Keep the semantic aliases intact: --surface-default: var(--paper), --text-primary: var(--ink), etc. These bridge library names to your native palette. Change the right-hand side only if your mood genuinely requires a different mapping (e.g. you want --surface-default to resolve to --paper-raised).
  5. Build, then run the validator against the compiled file:
    $ npm run build:css:themes
    $ node scripts/theme-validator.js public/themes/<your-theme>/theme.css
    It prints every missing token and every contrast failure. Fix them in the SCSS source, rebuild, and re-run until the output reads OK.
  6. Once the single file validates, register the theme in the picker. Add an entry (id + label) to the THEMES array in src/components/ThemePicker/ThemePicker.tsx, and — if you want a tile in the gallery — to the THEMES array in src/app/themes/gallery/page.tsx.
  7. Nothing else to do for the bundle: npm run build:css:themes regenerates public/theme.css from every built theme. The per-theme file is kept for standalone deploys; the bundle powers the docs site picker.

The validator

scripts/theme-validator.js is zero-dependency Node. It auto-detects per-file vs consolidated input and reports missing tokens with a non-zero exit code on failure.

Exit codes:

Wire npm run validate-themes into your pre-commit or CI step and authorship becomes a closed loop: if it passes, it ships.

Design guidance

Testing

Shipping

For a standalone deploy, drop your theme file in your own public/themes/<your-theme>/theme.css and link it from your HTML before cia.css. No registration and no data-theme attribute required — the file emits a bare :root alongside its own attribute selector, and the library’s defaults sit at zero specificity under :where(:root), so your theme wins regardless of load order.

To contribute your theme upstream, open a PR using the Theme Submission template at .github/ISSUE_TEMPLATE/theme_submission.yml. See the full checklist in CONTRIBUTING-THEMES.md at the repo root — it covers validator output, screenshots per page, contrast notes and the picker registration diff.

Theme