Skip to main content

Theme validation

Package: @zuilib/cli · npx @zuilib/cli theme check brand.css

A theme is a set of CSS custom-property overrides. That makes it cheap to write and impossible for the browser to validate: a misspelt --primry is silently ignored, --primary: #fde047 with white text ships at 1.3:1, a colour set on :root and forgotten under .dark leaks into dark mode. None of that fails a build. zui theme check does.

The checker knows the token contract that @zuilib/tokens publishes (every name, its type, its light and dark default, which ones are deprecated) and the colour pairs the components put together, derived by @zuilib/tokens/contrast: text on its surface, --<hue>-text on the hue's 10% tint, text on the surfaces it travels onto, and borders on the page. It parses your file, applies the overrides over the shipped defaults for both modes the way the browser cascades them (higher specificity, then later source order: a :root block written after .dark wins in dark mode too), and runs the rules below. Exit code 1 on any error.

A file with several brand scopes (.theme-a { … } .theme-b { … }) is checked scope by scope: each scope is the shipped defaults, plus the :root overrides, plus that scope's light and dark blocks. A problem outside :root names its scope (--primary-foreground (.theme-a, light), "scope": ".theme-a" in JSON), and a broken .theme-a is not hidden by a correct .theme-b declared after it.

The loop

npx @zuilib/cli theme init acme # acme.css: every token, commented out, with its default
# uncomment and edit the tokens the brand changes
npx @zuilib/cli theme check acme.css
acme.css: 8 light + 5 dark override(s) (dark selector .dark) version 1.2.0

✓ unknown-token every overridden name is in the token contract
✓ invalid-value values parse for their token type (colour, length, number, …)
✓ var-cycle no var() reference loops back to itself
✓ missing-dark colour tokens that differ per mode are overridden in both light and dark
✗ contrast 2 problem(s)
error --primary-foreground (dark): 3.67:1 --primary-foreground (#ffffff) on --primary (#7a73ff); needs 4.5:1
error --success-foreground (dark): 2.00:1 --success-foreground (#ffffff) on --success (#3ecf8e); needs 4.5:1
✗ tint-contrast 1 problem(s)
error --primary-text (light): 4.09:1 --primary-text (#d02b7f) on --primary at 10% over --background (#fde6f1); needs 4.5:1
✓ cross-surface-contrast --foreground and --muted-foreground reach 4.5:1 on the surfaces they travel onto (--muted, --accent, --secondary, --card, --popover)
! ui-contrast 1 problem(s)
warn --border (light): 1.41:1 --border (#d0d4c6) on --background (#fbf7e9); needs 3:1
✓ uncheckable-contrast every text and border pair resolves to a colour the contrast rules can evaluate
✓ radius-extreme --radius stays at or below 32px (Card reads it too)
✓ inert-override no override of a deprecated token (no component reads it)

contrast of the pairs this theme changes
✓ light 4.70:1 --primary-foreground on --primary
✗ light 4.09:1 --primary-text on --primary at 10%
! light 1.41:1 --border on --background
✗ dark 3.67:1 --primary-foreground on --primary
✗ dark 2.00:1 --success-foreground on --success

✗ 3 error(s), 1 warning(s)

The file can use any of the selector shapes a real theme uses. Light blocks: :root, .theme-acme, html[data-zui-theme="acme"]. Dark blocks: anything carrying the dark selector in any compound (.dark, .dark .theme-acme, .theme-acme.dark, html.dark[data-zui-theme="acme"]) or a @media (prefers-color-scheme: dark) block. A different dark convention is one flag away: --dark-selector '[data-theme="dark"]'.

Rules

Every contrast pair is evaluated once per scope, before the rules run, over the theme merged with the shipped defaults; the four contrast rules then read those ratios. The pairs are the ones the tokens package holds its own defaults to, so the shipped values pass every rule.

RuleSeverityWhat it catches
unknown-tokenerrorA --name that is not in the contract, with the nearest real name when it is within two edits (--primry → did you mean --primary?). A property the file reads itself with var() is a private helper and is not reported, unless it is within two edits of a real token (--primry: red; --x: var(--primry) is still a typo)
invalid-valueerrorA colour token whose value is not a colour (hex, rgb(), hsl(), oklch(), oklab(), the CSS named colours, light-dark(), color-mix()); a length token that is not a length; a number, font weight or duration that is not one; a --zui-theme-version that is not a quoted semver string. var(), calc(), lab(), lch(), hwb(), color() and relative colours (rgb(from …)) are valid and pass through
var-cycleerror--a: var(--a), or a loop through several overrides
missing-darkwarnA colour that has different shipped values per mode, overridden in light only: a :root override is declared after the shipped .dark block, so it wins in dark mode too and the dark default is gone. Also a colour overridden in dark only
contrasterrorText on its own surface below WCAG AA 4.5:1, in each mode: every --<x>-foreground on --<x>, --sidebar-foreground on --sidebar, and --foreground, --muted-foreground and every --<hue>-text on --background. Translucent values are composited over the page; light-dark() takes the mode's branch; color-mix() is evaluated in srgb, oklab and oklch
tint-contrasterrorA --<hue>-text below 4.5:1 on its hue at 10% alpha over --background (bg-primary/10 text-primary-text, the tinted badge or callout the text token exists for). The wash is modelled by alpha compositing, so a dark page lifts a pale tint and a pale page lowers a dark one
cross-surface-contrasterror--foreground below 4.5:1 on --muted, --accent, --secondary, --card or --popover, or --muted-foreground below 4.5:1 on --card, --popover or --accent (on --muted it is a contrast pair): the surfaces body and secondary text travel onto (a hover row, a neutral alert, a tab list, a card description)
ui-contrastwarn--border, --input or --ring below 3:1 on --background (WCAG 1.4.11), or --sidebar-border on --sidebar, when the theme changes a token of the pair (or the page under it). The shipped hairline border is a documented exception in the tokens package and is not reported for a theme that leaves it alone. --strict makes it an error
uncheckable-contrastwarnA pair the contrast rules could not evaluate because a value is valid CSS the checker cannot reduce to one colour: color-mix() in another space, lab()/lch()/hwb()/color(), a relative colour, currentColor, or a var() the file does not declare. The pair is listed with a · in the ratio table. --strict makes it an error, so a pipeline cannot pass on a colour it never measured
radius-extremewarn--radius above 32px. Every rounded-* utility and --radius-* step follows it, so cards and popovers become ellipses; for pill controls set --radius on the controls' subtree, or redeclare --radius-md in your own @theme inline
inert-overridewarnAn override that changes nothing: a deprecated token (the sidebar and gradient tokens, --shadow-glow; no component reads them)

--strict promotes every warning to an error. --min-contrast 3 lowers the text threshold (large-text AA) for a theme that is only used at display sizes; the 3:1 bar of ui-contrast is fixed.

A pair of untouched shipped aliases (--zui-table-rail-accent-foreground on --zui-table-rail-accent, which are var(--primary-foreground) on var(--primary)) is folded into the pair it aliases and reported once.

The contrast maths and the pair derivations are @zuilib/tokens/contrast, the same module the tokens package holds its own defaults to, so a theme that passes here passes the same bar as the shipped defaults.

JSON output

--json prints one object for a pipeline to read:

{
"ok": false,
"file": "acme.css",
"version": "1.2.0",
"problems": [
{
"rule": "contrast",
"severity": "error",
"token": "--primary-foreground",
"message": "3.67:1 --primary-foreground (#ffffff) on --primary (#7a73ff); needs 4.5:1",
"mode": "dark"
},
{
"rule": "unknown-token",
"severity": "error",
"token": "--primry",
"message": "not a ZUI token; did you mean --primary?",
"line": 4
}
]
}

ok is false when any problem has severity error. version is the file's --zui-theme-version (the contract version when it sets none). mode is present for problems that belong to one mode, line for problems tied to a declaration, scope for problems in a selector scope other than :root.

Diff

zui theme diff <a.css> <b.css> is the semantic changelog between two revisions of a theme: both files are checked with the same contract and pairs, then compared.

brand@1.0.css → brand@1.1.css
version 1.0.0 → 1.1.0

tokens 1 added, 1 removed, 2 changed

Border & input
~ --border (light): #cbd5e1 → #64748b

Primary
~ --primary (light): #1d4ed8 → #fde047

Card
- --card (light): #ffffff

Control density
+ --control-height-md (light): 2.25rem

contrast 4 pair(s) moved, 1 regression(s)
✗ light 6.70 → 1.32 --primary-foreground on --primary pass → fail (needs 4.5:1)
light 5.74 → 6.50 --primary-text on --primary at 10%
✓ light 1.48 → 4.76 --border on --background fail → pass
dark 13.33 → 4.16 --border on --background

✗ 1 new contrast failure(s)

Tokens are grouped by the section of the contract they belong to (a name outside the contract lands under "Not in the contract"), per scope and mode. The contrast table lists every pair whose ratio or status moved; a pair that passed in a (or did not exist there) and fails in b is a regression, and the command exits 1 when there is one, whatever else changed. --json prints { ok, version: { from, to }, sections, contrast, regressions, counts, files }; --min-contrast and --dark-selector apply as in check. Set --zui-theme-version: "x.y.z" in each revision so the header names the versions.

In CI

# .github/workflows/theme.yml
name: theme
on:
pull_request:
paths: ['src/styles/brand.css']
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npx @zuilib/cli theme check src/styles/brand.css --strict

The exit code fails the job; the table is in the log. For a report that another step consumes, add --json and parse problems.

Library use

The command is a thin wrapper over an exported function, for a custom pipeline or a test:

import { checkTheme } from '@zuilib/cli/src/theme/check.mjs'
import { diffThemes } from '@zuilib/cli/src/theme/diff.mjs'

const { ok, version, problems, ratios } = await checkTheme(css, { darkSelector: '.dark', strict: false })
const { regressions, sections, contrast } = await diffThemes(cssBefore, cssAfter)

ratios lists every pair (kind: content, tint, cross or ui) with its ratio, threshold and status per mode, affected when the theme touches it (those are the rows the report prints) and scope outside :root; a skip entry carries the reason the pair could not be evaluated. The colour maths and pair derivations are importable on their own from @zuilib/tokens/contrast.