Skip to main content
Urbicon UI

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.

ChannelFamilyArea fallback
orange (default)action/blocks/** + everything unclaimed
cyandata/table/**
magentaai/ai/**
blueform/auth/** (8 of its 14 are form)
tealnavigation
azuredisplay
purpleoverlay
redfeedback
inklayout

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

TokenDefault (light · dark)Used for
--room-accentper channelThe fresh room colour (≥3:1) — header bands, lines, marks, focus ring, charts.
--room-accent-fgper channelOn-accent ink/cream — text on the field + on dark-mode primary fills.
--room-accent-textper channelThe same channel one step deeper (≥4.5:1) — small body text in light mode: links, the active nav entry, the breadcrumb.
--docs-bg#f7f5f0 · #1a1816Ground — the page surface outside content blocks.
--docs-paper#fbfaf6 · #232220Content surface — where library components sit.
--docs-lifted#fefdfa · #2a2826Elevated surface — the cream ladder one step above paper.
--docs-floating#ffffff · #322f2cHighest surface — dialogs, drawers, toasts.
--docs-ink#17150f · #f0ede5Primary text ink.
--docs-soft#635f58 · #aaa79dBody-soft / meta ink (ON THIS PAGE labels, descriptions) — carries secondary and tertiary text.
--docs-softer#b8b5ad · #5a574fDecoration ink — kicker separators. Never body text.
--docs-soft-paper#635f58 · #aaa79dThe 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 · #5a574fSame, for the decoration step.
--docs-hairink/8% · cream/8%Hairline — barely-visible structural lines.
--docs-lineink/14% · cream/14%The visible rule — one step up from the hairline.
--docs-accentvar(--color-primary)Link colour, section markers — couples to the room primary.
--docs-radius-pillvar(--radius-commit)Pill geometry for the editorial-lineage cards.
--docs-radius-cardvar(--radius-contain)Bento-cards, recipe-stages — tight for the hard-edge poster.
--docs-shadow-pagevar(--blocks-shadow-lg)Lift shadow — bento and recipe stages.
--docs-measure46remThe reading edge — prose only, not the exhibit column.
--docs-prose-size1.0625remBody copy — set here, not per page.
--docs-prose-leading1.7Body leading, paired with the size above.
--font-display / --font-sansSchibsted Grotesk VariableDisplay + body — one grotesk (self-hosted via @fontsource).
--font-monoJetBrains Mono · system monoCode, 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 tokenColor Rooms value
--color-primarylight-dark(var(--room-accent-text), var(--room-accent))
--color-text-on-primarylight-dark(#fbfaf6, var(--room-accent-fg))
--color-surface-basevar(--docs-paper)
--color-surface-quietvar(--docs-bg)
--color-surface-elevatedvar(--docs-lifted)
--color-surface-overlayvar(--docs-floating)
--color-border-hairlinevar(--docs-hair)
--color-text-primaryvar(--docs-ink)
--color-text-secondaryvar(--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.