Color Rooms
A case study in scoped theming: the token-only overlay that drives the look of this docs site. Schibsted Grotesk on warm cream paper, with the accent set to the component family the page documents. Activated via a single root class; everything else is CSS custom properties.
What it is
Color Rooms is the visual identity of this docs site: one grotesk (Schibsted Grotesk) for both
display and body, JetBrains Mono for meta and code, on warm cream paper. Its signature is that the accent is the room you are in: each component family owns a saturated colour, and the component-page and section-landing
headers become a full-width colour field that spans everything right of the app sidebar. It is
implemented as a single CSS file (apps/docs/src/lib/style/rooms-docs.css) that defines a private --docs-* token namespace and
re-derives the library's primary-token family from the active room when .docs-rooms is present on the <html> root.
Three things here generalise to any scoped theme: a private token namespace, an accent selected by a data attribute, and re-declared derived roles.
Every primitive still ships with its library-default look; Color Rooms is a thin
token-override sheet over the top. Without .docs-rooms the same page renders in the library
defaults. That is the test of whether the docs site really is theme-only, and the Docs theme toggle in the sidebar footer (Rooms / Library) removes the class live
to prove it.
Color Rooms vs. Consumer Apps
Color Rooms is docs-only. Consumer apps that depend on @urbicon-ui/blocks do not import rooms-docs.css and do not set .docs-rooms; they live on the library defaults and
customise via the regular customization ladder (class / slotClasses / preset / defaults / overrides / unstyled).
The Rooms
A room is a component family, not a product area. A component's doc page
wears the channel of its family — so Button and Tab are different colours even though both live under /blocks, and Table and the auth NotificationListener share one. Pages that document
no single component (the overviews, /recipes, /icons, /getting-started) fall back to the channel
of their product area. It is the same register the landing page runs on, so a component's row in
the landing index and its doc page carry the same colour.
The two custom properties (--room-accent and its foreground --room-accent-fg) resolve from a data-room stamp on the .docs-room-scope wrapper, and the whole primary-token
family is re-derived from them via color-mix().
Navigating therefore repaints every real component on the page — segment indicator, slider,
toggle, buttons, TOC-active, badges — with no per-component override.
| Channel | Family | Area fallback |
|---|---|---|
| orange (default) | action | /blocks/** + everything unclaimed |
| cyan | data | /table/** |
| magenta | ai | /ai/** |
| blue | form | /auth/** (8 of its 14 are form) |
| teal | navigation | — |
| azure | display | — |
| purple | overlay | — |
| red | feedback | — |
| ink | layout | — |
Both tables — route → channel and channel → accent triple — are generated by apps/docs/scripts/channels-gen.ts from the docs-gen catalogues
and the channel register. No hex is hand-kept: each channel is measured, not picked. The accent
step is the lightest one that still clears 3:1 against the paper (the threshold for a fill, a line
or a mark); the text step is the lightest one that clears 4.5:1 (the threshold for small body text).
The generator refuses to emit a channel whose foreground does not clear 4.5:1 on its own fill, or
whose text step does not clear AA on the paper it stands on — in both light and dark.
Route → channel
// apps/docs/src/routes/+layout.svelte — "Farbe = Familie"
// the page's component family picks the channel, the product area is only the
// fallback; the layout stamps the NAME, never a colour
const room = $derived(channelNameForRoute(page.url.pathname));
// <div class="docs-room-scope" data-room={room}> …
/* route-channel.gen.ts — generated from the docs-gen catalogues */
export const ROUTE_CHANNEL = {
'/blocks/primitives/button': 'orange', // action
'/blocks/primitives/tab': 'teal', // navigation
'/auth/components/login-page': 'blue', // form
// … one entry per documented component
};
/* rooms-channels.gen.css — generated from the channel register */
:is(.docs-rooms, .docs-room-scope)[data-room='teal'] {
--room-accent: oklch(0.632 0.119 175); /* 3:1 — fills, lines, marks */
--room-accent-fg: oklch(0.2 0.038 175); /* on the accent fill */
--room-accent-text: oklch(0.532 0.1 175); /* 4.5:1 — small body text */
}The derivation is re-declared in two scopes on purpose. Content reads the room from the .docs-room-scope wrapper — SSR-correct, so the first
paint already carries the right accent with no flash. Portaled popovers (the Select / Combobox
/ Menu dropdowns) mount at <body>, outside that
wrapper, so the same accent is mirrored onto <html> after mount — they only open on interaction, always post-hydration, so there is no first-paint concern
for them.
color-mix derivation
/* rooms-docs.css — the whole primary family is re-derived from the room.
* Re-declared on BOTH the theme root (so portaled popovers read the room too)
* AND the .docs-room-scope wrapper (so content is correct at first paint).
*
* Two anchors, because the ramp spans two roles: 50–500 hang off the fresh
* accent (surfaces, lines), 600–950 off the AA text step (ink). */
.docs-rooms,
.docs-rooms .docs-room-scope {
--color-primary-500: var(--room-accent);
--color-primary-600: var(--room-accent-text);
--color-primary-700: color-mix(in oklab, var(--room-accent-text) 80%, #17150f);
/* … 50–950 via color-mix … */
--color-primary: light-dark(var(--color-primary-600), var(--color-primary-500));
--color-text-on-primary: light-dark(#fbfaf6, var(--room-accent-fg));
}A scoped theme has to re-declare its derived semantic tokens: a var() inside a :root token definition substitutes at the cascade level where it is defined, so overriding the ramp alone
won't re-resolve --color-primary & co. The general
pattern is Scoped Themes.
Token Catalogue
The --docs-* namespace holds the paper/ink hierarchy;
the primary family is derived from the room (above). Color Rooms also binds a handful of
library semantic tokens to the --docs-* values so
components rendered inside .docs-rooms pick up the warm palette
without per-component opt-in.
Docs-private tokens
| Token | Default (light · dark) | Used for |
|---|---|---|
--room-accent | per channel | The fresh room colour (≥3:1) — header bands, lines, marks, focus ring, charts. |
--room-accent-fg | per channel | On-accent ink/cream — text on the field + on dark-mode primary fills. |
--room-accent-text | per channel | The same channel one step deeper (≥4.5:1) — small body text in light mode: links, the active nav entry, the breadcrumb. |
--docs-bg | #f7f5f0 · #1a1816 | Ground — the page surface outside content blocks. |
--docs-paper | #fbfaf6 · #232220 | Content surface — where library components sit. |
--docs-lifted | #fefdfa · #2a2826 | Elevated surface — the cream ladder one step above paper. |
--docs-floating | #ffffff · #322f2c | Highest surface — dialogs, drawers, toasts. |
--docs-ink | #17150f · #f0ede5 | Primary text ink. |
--docs-soft | #635f58 · #aaa79d | Body-soft / meta ink (ON THIS PAGE labels, descriptions) — carries secondary and tertiary text. |
--docs-softer | #b8b5ad · #5a574f | Decoration ink — kicker separators. Never body text. |
--docs-soft-paper | #635f58 · #aaa79d | The on-paper value the two above alias. A colour field re-points --docs-soft at its own foreground; an overlay opened from inside one paints
paper, so it needs these back. |
--docs-softer-paper | #b8b5ad · #5a574f | Same, for the decoration step. |
--docs-hair | ink/8% · cream/8% | Hairline — barely-visible structural lines. |
--docs-line | ink/14% · cream/14% | The visible rule — one step up from the hairline. |
--docs-accent | var(--color-primary) | Link colour, section markers — couples to the room primary. |
--docs-radius-pill | var(--radius-commit) | Pill geometry for the editorial-lineage cards. |
--docs-radius-card | var(--radius-contain) | Bento-cards, recipe-stages — tight for the hard-edge poster. |
--docs-shadow-page | var(--blocks-shadow-lg) | Lift shadow — bento and recipe stages. |
--docs-measure | 46rem | The reading edge — prose only, not the exhibit column. |
--docs-prose-size | 1.0625rem | Body copy — set here, not per page. |
--docs-prose-leading | 1.7 | Body leading, paired with the size above. |
--font-display / --font-sans | Schibsted Grotesk Variable | Display + body — one grotesk (self-hosted via @fontsource). |
--font-mono | JetBrains Mono · system mono | Code, kbd, meta kickers (self-hosted via @fontsource). |
Library semantic overrides
Color Rooms rebinds these library semantic tokens inside .docs-rooms so library components automatically pick up the
warm palette and the room accent. The surface ladder, warm-neutral border/state ramp and warm-tuned
intent colours (secondary/success/warning/danger) are re-pointed too so the whole page reads warm
rather than cool.
| Library token | Color Rooms value |
|---|---|
--color-primary | light-dark(var(--room-accent-text), var(--room-accent)) |
--color-text-on-primary | light-dark(#fbfaf6, var(--room-accent-fg)) |
--color-surface-base | var(--docs-paper) |
--color-surface-quiet | var(--docs-bg) |
--color-surface-elevated | var(--docs-lifted) |
--color-surface-overlay | var(--docs-floating) |
--color-border-hairline | var(--docs-hair) |
--color-text-primary | var(--docs-ink) |
--color-text-secondary | var(--docs-soft) |
How It Is Wired Up
The theme activates when .docs-rooms sits on a parent
of the content. The docs app puts it on <html> so the whole app inherits the warm canvas, and so the app.html head script can flip it before first paint
(the root element exists there, <body> does not
yet). The per-route room accent then lives on the .docs-room-scope wrapper in the layout.
The root class
<!-- apps/docs/src/app.html — Color Rooms is the shipped default -->
<html lang="en" class="docs-rooms">
<head>…</head>
<body>%sveltekit.body%</body>
</html>The stylesheet is imported after the library base:
The import
/* apps/docs/src/app.css */
@import '@urbicon-ui/blocks/style/index.css';
@import './lib/style/rooms-docs.css'; /* Schibsted, cream paper, room accent */Remove the class (the sidebar's Docs theme → Library toggle does exactly this) and the page falls back to the bare library skin: no field header, no Schibsted, the library's blue primary. The underlying mechanism is the scoped-theme pattern from Scoped Themes; Color Rooms just rebinds a wider set of tokens and derives them from the room.
Light & Dark
Color Rooms supports both light and dark out of the box: Light is warm cream paper with warm dark ink, Dark is warm coffee paper with warm cream ink. The room accent is orthogonal to the mode: it repaints the primary family, not the paper, so a section stays the same colour in both modes and the docs' ThemeSwitcher keeps working.
Implementation uses CSS's light-dark() function. Each
paper/ink token resolves at use-site based on the page's color-scheme, which the library's semantic layer sets
via :root.light / :root.dark. A single rule block covers both modes — no
second sheet, no JS-driven swap.
One thing the accent cannot be orthogonal about is text contrast, and that is
why a room carries two accent steps rather than one. No single colour clears AA on both
papers: 4.5:1 against the cream needs a relative luminance of at most 0.173, 4.5:1 against the
coffee needs at least 0.247. So the text role is itself a light-dark() pair — the deeper --room-accent-text on cream, and the fresh --room-accent on coffee, where it already measures 4.9:1.
Surfaces, lines and marks keep the fresh step in both modes.
Light/Dark via light-dark()
/* rooms-docs.css uses light-dark() so every token carries both modes —
* the room accent is orthogonal, it repaints primary, not the paper. */
.docs-rooms {
--docs-bg: light-dark(#f7f5f0, #1a1816); /* warm cream → warm coffee */
--docs-paper: light-dark(#fbfaf6, #232220); /* lighter cream → lighter coffee */
--docs-ink: light-dark(#17150f, #f0ede5); /* warm near-black → warm cream */
/* … */
}The same mechanism powers semantic.css: see Themes → Dark Mode for the library-side details.
Override Recipes
Every value lives on a CSS custom property, so the theme is adjustable at any scope: globally
inside .docs-rooms, per-room on .docs-room-scope, or inline. Two moves worth copying
into a scoped theme of your own: repaint one scope's accent (both steps, or the generated text
step keeps the old hue), and re-point the geometry handles.
Common overrides
/* Repaint one room (e.g. a warmer navigation teal) — the whole primary family,
every segment/toggle/button/field on a navigation page follows. Override BOTH
steps: --room-accent alone would leave the generated text step on the old hue. */
:is(.docs-rooms, .docs-room-scope)[data-room='teal'] {
--room-accent: #1f6f66;
--room-accent-text: #17544e; /* the same hue, deep enough for 4.5:1 on cream */
}
/* Square cards / quieter lift — same --docs-* handles as before. The library
shadow scale tops out at lg, which is what Rooms already uses, so a LOUDER
lift means your own value rather than a token. */
.docs-rooms {
--docs-radius-card: 0;
--docs-shadow-page: var(--blocks-shadow-sm);
}For brand-wide theming (the library's own primary colour, the tier radii) reach for the Theme Builder or write a custom @theme block. Color Rooms sits on top of that and, per room, overrides the primary chain with the room accent.