-
Notifications
You must be signed in to change notification settings - Fork 0
Theming
SCSS modules + design tokens. No Tailwind, no CSS-in-JS, no second CSS system. Build-time only, zero runtime.
As of 2026-08. Stable API — semver from here (Upgrading).
Every colour is a semantic token. A raw hex in any component or stylesheet is a lint failure and fails x verify's lint step.
Source of truth: packages/ui/src/tokens/_colors.scss. tokens.ts is a hand-maintained typed mirror for consumers that cannot read CSS — charts, <canvas>, OG images, transactional mail — and tokens.test.ts fails the build if the two disagree. Values on this page are transcribed from those files; when they differ, the files win.
Named by role, not by value: --color-bg, never --blue-500. Stored as space-separated RGB channels so rgb(var(--color-accent) / 0.5) composites without a second token.
24 roles, in the order COLOR_ROLES declares them. A component that needs a 25th is asking for a design decision, not a variable.
| Role | Light | Dark | Used for |
|---|---|---|---|
bg |
253 246 240 |
18 18 20 |
page background |
bg-soft |
245 237 230 |
28 28 32 |
subtle zones, hovers |
surface |
250 245 241 |
34 34 39 |
cards, sheets |
surface-raised |
255 255 255 |
44 44 50 |
popovers, dialogs, inputs |
fg |
38 34 31 |
228 226 222 |
body text |
fg-strong |
17 15 13 |
248 247 245 |
headings, emphasis |
fg-muted |
110 102 94 |
155 151 145 |
captions, placeholders |
line |
208 198 188 |
72 72 80 |
borders, dividers |
scrim |
17 15 13 |
0 0 0 |
modal backdrops — the darkest role in each theme |
accent |
31 110 178 |
96 170 240 |
primary action, links |
accent-strong |
21 92 152 |
130 190 248 |
hover/active of accent |
accent-fg |
255 255 255 |
16 20 26 |
text on accent |
success |
21 123 80 |
74 190 130 |
the success tone |
success-soft |
222 244 232 |
22 46 34 |
its filled background |
success-fg |
255 255 255 |
12 26 18 |
text on success
|
warning |
155 93 7 |
226 170 66 |
the warning tone |
warning-soft |
253 240 213 |
52 42 20 |
its filled background |
warning-fg |
255 255 255 |
28 20 6 |
text on warning
|
danger |
190 42 42 |
240 110 110 |
the danger tone |
danger-soft |
253 227 227 |
56 26 26 |
its filled background |
danger-fg |
255 255 255 |
30 12 12 |
text on danger
|
info |
31 110 178 |
96 170 240 |
the info tone |
info-soft |
224 239 252 |
22 38 56 |
its filled background |
info-fg |
255 255 255 |
12 20 30 |
text on info
|
The six-name tone vocabulary components expose — neutral, accent, success, warning, danger, info — is $tones in _colors.scss, mirroring TONES in components/variants.ts; variants.test.ts fails on drift.
As of 2026-08 seven channels moved, because eight pairings failed WCAG AA. The worst was line on surface-raised in dark at 1.16:1 — an input border nobody can see.
| Role | Theme | Was | Now |
|---|---|---|---|
line |
dark | 54 54 60 |
72 72 80 |
line |
light | 224 216 208 |
208 198 188 |
accent |
light | 34 122 197 |
31 110 178 |
fg-muted |
dark | 150 146 140 |
155 151 145 |
surface |
light | 255 255 255 |
250 245 241 |
Anything still carrying the old numbers is stale — including any copy of the palette outside packages/ui/src/tokens/. There is no gate that finds one, which is exactly how the /_x dashboard shipped the 1.16:1 border of its own.
Every scale is a SCSS map in packages/ui/src/tokens/, emitted as a custom property by theme.scss — the only stylesheet that emits any — and read back through the function in the third column below. t.tracking(wide), never a hand-written var(--tracking-wide) — the property name is spelled in one file, and a scale with no function is one every author has to spell for themselves.
Most scales carry a typed mirror in tokens.ts as well, for consumers that cannot read CSS. Two do not, and the table says which; tokens.test.ts fails the build on drift in the ones that do.
| Scale | Custom property | Read it with | Values |
|---|---|---|---|
| colour | --color-accent |
t.role('accent', $alpha) |
the 24 roles above, as RGB channels |
| space | --space-4 |
t.space(4) |
0 1 2 3 4 5 6 8 10 12 16 → 0 … 4rem
|
| radius | --radius-md |
t.radius(md) |
none sm md lg xl pill full |
| z-index | --z-dialog |
t.z(dialog) |
base raised sticky dropdown drawer dialog popover tooltip toast skip-nav |
| duration | --duration-fast |
t.duration(fast) |
instant 0ms, fast 120ms, base 220ms, slow 400ms, slower 640ms
|
| easing | --easing-out |
t.easing(out) |
out in in-out spring |
| shadow | --shadow-md |
t.shadow(md) |
xs sm md lg xl — themed, like colour: separate light and dark maps |
| font family |
--font-sans, --font-mono
|
var(--font-sans) |
the two slots defineTheme() overrides. The one scale with no function and no TS mirror: a stack is replaced whole, not picked off a rung, and a comma list is not one value per key |
| font size | --text-md |
t.text(md) |
xs … 3xl, every one a clamp()
|
| font weight | --weight-semibold |
t.weight(semibold) |
normal medium semibold bold |
| line height | --leading-normal |
t.leading(normal) |
tight snug normal loose |
| letter spacing | --tracking-tight |
t.tracking(tight) |
tight normal wide — SCSS only, no TS mirror |
| breakpoint | none | @include t.respond-to(md) |
sm 480px … 2xl 1536px; never emitted as a custom property, because a media query cannot read one |
Note the naming: font size is --text-*, weight is --weight-*, line height is --leading-*, tracking is --tracking-* — not --font-size-*.
Light in :root, dark behind the media query, and both mirrored under html[data-theme] so an explicit user choice always beats the OS. _colors.scss's emit mixin writes all four blocks from the same two maps, so a role cannot be defined in one block and forgotten in another.
:root { color-scheme: light; /* $light */ }
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; /* $dark */ } }
html[data-theme='dark'] { color-scheme: dark; /* $dark */ }
html[data-theme='light'] { color-scheme: light; /* $light */ }color-scheme rides along, so form controls, scrollbars, and the UA's own ::selection follow the theme without a second declaration.
Channels, not #rrggbb, so any opacity is rgb(var(--token) / a). Inside @ultimat3/ui the wrapper is t.role('<name>', $alpha).
/* apps/web/app/nav/toolbar.module.scss */
.toolbar {
background: rgb(var(--color-bg) / 0.8);
backdrop-filter: blur(12px);
color: rgb(var(--color-fg));
border-bottom: 1px solid rgb(var(--color-line));
}
.toolbar__action {
background: rgb(var(--color-accent));
color: rgb(var(--color-accent-fg));
&:hover { background: rgb(var(--color-accent-strong)); }
&[disabled] { color: rgb(var(--color-fg-muted) / 0.6); }
}Text on a filled surface takes that surface's -fg role — accent-fg on accent, danger-fg on danger. Hardcoding accent-fg for every tone is the bug IconButton shipped with before 1.1.0: a danger icon button wore accent's on-colour.
No dark: variants, no @media in a component. A component is bg + fg + line; the theme flip happens above it.
For anything that cannot read a custom property — a chart, a <canvas>, an OG image, an email:
import { color, colorRgb, colorVar } from '@ultimat3/ui';
colorVar('accent'); // 'var(--color-accent)' — the channel list
color('accent', 0.5); // 'rgb(var(--color-accent) / 0.5)'
colorRgb('dark', 'accent'); // 'rgb(96 170 240)' — resolved, no indirectionAn unknown role throws X_TOKEN_UNKNOWN naming every role that exists.
The one seam for restyling. Not a forked stylesheet, not an SCSS @use ... with () override — there is no second path.
import { brandStyleTag, defineTheme } from '@ultimat3/ui';
const brand = defineTheme({
colors: { light: { accent: '31 110 178' }, dark: { accent: '96 170 240' } },
radius: { md: '0.375rem' },
font: { sans: 'Inter, system-ui, sans-serif' },
});
brand.css; // the four CSS blocks, as a string
brandStyleTag(brand); // '<style>…</style>' — ship it after global.scss| Field | Shape | Notes |
|---|---|---|
colors |
Partial<Record<'light' | 'dark', Partial<Record<ColorRole, string>>>> |
any subset of the 24 roles, per theme |
radius |
Partial<Record<RadiusName, string>> |
none sm md lg xl pill full |
font |
Partial<Record<'sans' | 'mono', string>> |
the two slots |
Returns a frozen { css: string }. It emits :root, html[data-theme='light'], the prefers-color-scheme: dark block and html[data-theme='dark'] — radius and font ride :root only. Output is ordered by the canonical scale arrays rather than by your object, so re-rendering the same input is byte-identical. Empty input gives css: ''.
Nothing is applied automatically. defineTheme() returns a string; you ship it.
The output lands in a <style> element, so a value that could close it is refused rather than sanitised.
| Slot | Accepted | Refused |
|---|---|---|
| colour |
^\d{1,3} \d{1,3} \d{1,3}$, each channel ≤ 255 |
#1e6eb2, rgb(1,2,3), 1 1 1; } html { display: none }
|
| radius | ^(0|\d+(\.\d+)?(px|rem|em|ch|%))$ |
calc(…), var(…), 1 with no unit |
| font | ^[\w\s,'"-]{1,200}$ |
anything with ; } < > ( ) / : — so Menlo</style><script> cannot get through |
| Failure | Code | Means |
|---|---|---|
| unknown role, radius name or font slot | X_TOKEN_UNKNOWN |
the key is not in the scale; cause lists every name that is |
| known key, unusable value | X_UI_INVALID_VALUE |
cause names the slot and what was expected |
Order: explicit localStorage choice → theme.defaultMode, where 'system' (the default) means the OS preference and 'dark' or 'light' is the app's own opinion. Applied by a blocking inline <head> script, before first paint and before the render-blocking stylesheet, so there is no flash of the wrong theme.
The boot writes the script; the app writes nothing (As of 20.2.0). x dev, the container and x build's static export all inline themeScript({ fallback }) from @ultimat3/render with theme.defaultMode from app.config.ts as the fallback, and the served processes admit its sha256 to script-src from the same string — packages/cli/src/theme-boot.ts. An app that wants to open dark sets theme: { defaultMode: 'dark' } and is done. The storage key is ultimate.theme on both sides: the boot reads it, ThemeToggle writes it, and a test pins the two literals equal. Before this release neither of the framework's two theme scripts was inlined by anything, both fell back to light, and they disagreed on the key.
packages/ui/src/theme/inline-script.ts is deprecated and removed in 21 — an app that inlined it by hand keeps building and should delete that code.
| Concern | Rule |
|---|---|
| Persist | only when the user explicitly picks. clearTheme() removes the key and returns to OS-following |
| OS flip | a matchMedia change listener re-applies only when no explicit choice is stored |
| Determinism |
data-theme beating the media query is what makes Playwright screenshots reproducible — set the attribute, don't emulate |
| SSR | the server never guesses a theme; it emits the boot script and neutral markup |
| No flash | the script is blocking and inline. An async or deferred theme script is a regression, not an optimization |
| Bad value |
X_THEME_INVALID — light or dark, or clear the attribute to follow the OS |
| Path | Contents |
|---|---|
packages/ui/src/tokens/_colors.scss |
the two colour maps, the tone list, the emit mixin — canonical |
packages/ui/src/tokens/tokens.ts |
the typed mirror, gated by tokens.test.ts
|
packages/ui/src/tokens/theme.scss |
the only stylesheet emitting global custom properties |
packages/ui/src/tokens/contrast.ts |
WCAG ratios over the channel tokens |
packages/ui/src/theme/brand.ts |
defineTheme() — the one brand-override seam |
packages/ui/src/tokens/_index.scss |
what @use '@ultimat3/ui/tokens' as t forwards: maps, t.role(), t.space(), the mixins. Emits no CSS |
apps/web/shared/tokens.scss |
the generated app's own layer. One line — @forward '@ultimat3/ui/tokens' — and it emits zero bytes of CSS by design: every module is its own Sass compilation, so a :root block here would be inlined once per stylesheet. Compiles as scaffolded, verified As of 2026-08-19; the bare specifier is resolved by css-modules.ts's package importer, since ./tokens is an exports entry only the module resolver can place |
shared/ is importable by site/, app/, and api/. site/ importing from app/ stays a build error — see Project layout. The /_x dev dashboard reads its six channels from colorTokens at render time rather than keeping a copy → Admin dashboard.
| Surface | Derived from |
|---|---|
PWA manifest theme_color
|
--color-bg of the light theme, resolved to hex at build time |
PWA manifest background_color
|
same token, so the splash screen matches the shell → PWA and offline |
<meta name="theme-color"> |
emitted twice, one per prefers-color-scheme media attribute |
| Maskable icon background | --color-surface |
| OG image background |
--color-bg, --color-fg-strong for text |
The manifest is generated. Hand-editing a colour there drifts from the tokens and fails x verify's manifest step.
:focus-visible {
outline: 2px solid rgb(var(--color-accent));
outline-offset: 2px;
}
::selection {
background: rgb(var(--color-accent) / 0.25);
color: rgb(var(--color-fg-strong));
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}contrast.test.ts measures every pairing below in both themes, over the four surfaces (bg, bg-soft, surface, surface-raised) and the four status tones. A failure reports the measured ratio, not just a boolean.
| Pairing | Threshold |
|---|---|
fg, fg-strong, fg-muted on every surface |
AA_TEXT 4.5:1
|
accent, accent-strong on every surface |
4.5:1 |
accent-fg on accent / accent-strong; each <tone>-fg on its tone |
4.5:1 |
each <tone> on its own -soft; fg-muted on every -soft
|
4.5:1 |
each <tone> on every surface |
4.5:1 |
accent (the focus ring) on every surface |
AA_LARGE 3:1
|
line on every surface |
1.4:1 — a framework floor, not a WCAG level: a border is not text, but 1.16 is invisible |
scrim |
must be the darkest role in its theme — a luminance ordering, not a ratio |
shadow is not contrast-gated.
| Check | Enforcement |
|---|---|
| Contrast, shipped pairings | the table above, run by x verify's unit step |
Contrast, a defineTheme() override |
nothing. defineTheme() validates channel syntax — three 0–255 integers — and measures no ratio. Assert it yourself: contrastRatio and AA_TEXT are exported for it |
| Focus ring |
:focus-visible from --color-accent in reset.scss, and @include t.focus-ring per control. Taking one away without painting one back is refused in an app by guards/focus-visible.ts, on x verify's boundaries step — not by lint, which ignores .scss entirely |
| Reduced motion | honored globally, not per component — and it deletes rather than reduces, see below |
| Lighthouse a11y | minimum threshold in app.config.ts, default 95 → Testing
|
The global guard collapses motion to 0.01ms !important on *. Substituting a cross-fade for
a movement therefore needs the component's own prefers-reduced-motion block, !important, on a
selector more specific than * — otherwise the feedback is gone rather than calmed. The rule and
the rest of the motion vocabulary: Interface rules.
- Semantic tokens everywhere. A raw hex — or an
rgb()/hsl()called with numbers — in any.scssthis package ships is a failing test,packages/ui/src/tokens/tokens.test.ts, onx verify'sunitstep. Not lint: biome ignores.scss, and the rule said "lint" while nothing read it. Two files are exempt, because a literal is supposed to live in them:tokens/_colors.scssandtokens/_shadow.scss. In an app the same rule isguards/raw-colour.ts. - Each token defined once per theme, by the
emitmixin::root, the media query, and bothdata-thememirrors. -
html[data-theme]always beatsprefers-color-scheme. - Theme applied before first paint by a blocking inline script.
- 24 colour roles. Adding one is a design-system change, reviewed as one, and it lands in
_colors.scssandtokens.tsin the same commit. - Text on a filled surface uses that surface's
-fgrole, neveraccent-fgby default. - Restyle through
defineTheme(). A forked stylesheet is the wrong answer to every brand question. - Components never contain a media query for theme. They read tokens.
- Contrast verified in light and dark; a token pair that passes in one theme only is a failure.
- Colours are themed; numbers, dates, and money are localized → I18n, Money, Timezones and dates.
Component-by-component props and the token vocabulary each one accepts: UI components. What a screen has to do with those tokens — motion, focus, contrast, the four loading states: Interface rules.
Ultimate — v22.5.1 As of 2026-09. Stable API, semver from here. MIT licensed. What npm serves is npm view @ultimat3/core version, never this line.
This footer is the only page that stamps a version. It renders under every wiki page, so one release bumps one line; a stamp on a second page is 46 hand-copies of one fact, and every one of them goes stale on the next tag.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Building your own base
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Client data
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
- Realtime
- Caching and invalidation
- Batching and preloading
- N+1 detection
- PWA and offline
- MCP and AI
- Agents
- Admin dashboard
- Scraping
- Auth
- Notify
- Storage and uploads
- Feature flags
- SEO
- Static assets
Cross-cutting
- I18n
- Theming
- UI components
- Interface rules
- Timezones and dates
- Money
- Resource management
- Migrations and backfills
- Testing
Reference