Brand and colour
Source: 8020IQ Brand Guide v1.1, sections 06, 06b, 07. Implemented in
styles/ds/colors.css (raw values) and styles/ds/themes.css (semantics and
themes). Never reference a raw value in a component. Reference the semantic
token, so the light/dark flip happens for you.
Two attributes carry everything
<html data-brand="iq" data-theme="light">
data-brand—iq|rei|roof. Picks the accent.data-theme—light|dark. Picks the surfaces and the ink.
Both work on any subtree, not just <html>. A charcoal band inside a cream
page is a <section data-theme="dark">. A Roof section on an IQ page is a
<section data-brand="roof">. That is how a mixed surface stays correct without
a single hard-coded value.
Light is declared on bare :root as well as [data-theme="light"] — that
is what makes the server-rendered frame (before any attribute is written) render
as light rather than unstyled.
Switching the two attributes: AppearanceToggle
components/brand/appearance-toggle.tsx is that claim made operable. It writes
data-brand and data-theme on <html> and does nothing else — no context, no
CSS-in-JS, no re-mount, nothing below it subscribing to a provider. If a
component needs to be told which brand it is in, that component is wrong.
AppearanceToggle is the pair. BrandToggle and ThemeToggle are its halves,
usable on their own, and useAppearance is the state behind them — it also
rewrites the favicon, which is the one part of the identity CSS cannot reach
(<img src>, same constraint as the logo). Appearance is { brand, theme },
ThemeId is "light" | "dark", and AppearanceOptions carries the defaults,
the localStorage key and the favicon resolver.
Where it belongs, and where it does not. On a surface that DEMONSTRATES or
previews the system — this design site, a brand preview screen, a template
gallery. A product app ships one brand: it sets data-brand once in its root
layout and never offers the choice, because a user of the coverage tool
switching it to Roof has not changed anything true. Such an app takes
ThemeToggle alone, or more often puts the theme in the account menu with the
rest of the reader's preferences (05-sidebar-and-shell.md).
One owner of the attribute per surface. ThemeProvider (next-themes,
pointed at data-theme) resolves the system preference, remembers the choice
and blocks the wrong-colour first paint — it is the right answer for an app.
useAppearance writes the attribute directly, because a preview surface has to
be able to force a theme that is NOT the reader's system setting. Run one or the
other, never both.
The mark follows the same attribute
data-brand decides the accent through CSS and the LOGO through one small
effect, because an <img src> is not something a custom property can swap. The
Logo component defaults to brand="auto": it resolves the nearest
data-brand ancestor, so a Roof section inside an IQ page shows the Roof mark —
which is what the attribute has always promised and what the logo did not
honour until 2026-08-20. Pass an explicit brand id only to PIN one: a comparison
table, a footer naming all three, an architecture diagram.
The first paint uses the app's own DEFAULT_BRAND and the effect corrects after
mount; on a real app, one brand set once, the two agree and nothing changes.
The accent, and it is the only thing a brand changes
| Token | iq | rei | roof | Job |
|---|---|---|---|---|
--brand-accent | #10B981 | #0495FE | #FF6723 | Primary buttons, the one emphasis |
--brand-accent-strong | #059669 | #0479CC | #E0521A | Hover / pressed |
--brand-accent-soft | #34D399 | #0495FE | #FF6723 | Tints, dark-mode accent text |
--brand-accent-rgb | 16,185,129 | 4,149,254 | 255,103,35 | For rgba() tints |
--accent-ink (light) | #036b4e | #0495FE | #FF6723 | Accent as TEXT on cream |
--accent-ink (dark) | #34D399 | #0495FE | #FF6723 | Accent as TEXT on charcoal |
Accent-as-fill and accent-as-ink are different values. Emerald #10B981 on
cream is a fine button fill and an illegible label; that is why --accent-ink
exists and why you must never write color: var(--brand-accent).
The accent tint used across the system is rgba(var(--brand-accent-rgb), 0.12)
— the switcher's icon chip, the active rail row, a role badge. One tint, one
alpha, everywhere.
Never combine two vertical accents on one surface. Sky and orange together break the brand architecture. If a surface must show both verticals, use the parent emerald or a neutral and name the verticals in words.
Light mode
| Token | Value | Job |
|---|---|---|
--surface-page | #FAF8F5 cream | The page. The spine of the system |
--surface-card | #F5F3EF warm white | Cards, inputs, alternating rows |
--text-primary | #1C1C1E | Body and headings. Never pure black |
--text-secondary | #636366 | Labels, eyebrows, captions, column heads |
--text-tertiary | #8E8E93 | Disabled, hint, placeholder |
--card-border | #E8E4DE | Default divider, card edge |
--card-border-strong | #DDD7CE | Table outline, stronger separator |
Never a pure white card on cream. Cream or warm white, always.
Dark mode
A four-step titanium ramp, plus white alphas. Never pure #000 or #FFF as a
surface.
| Token | Value | Job |
|---|---|---|
--surface-page | #18181B charcoal | The floor. Ramp step 1 |
--surface-card | #2C2C2E titanium | Cards on charcoal. Step 2 |
(hover) | #3A3A3C titanium mid | Hover, deeper hierarchy. Step 3 |
(edges) | #48484A titanium light | Borders on titanium. Step 4 |
--text-primary | #F5F3EF warm white | Body and headings |
--text-secondary | rgba(255,255,255,.60) | Supporting copy |
--text-tertiary | rgba(255,255,255,.40) | Mono-caps labels, disabled |
--card-border | rgba(255,255,255,.08) | Subtle divider |
The contrast flip is the whole point of the token names. Secondary and tertiary text are DARKER hexes in light and LIGHTER alphas in dark; the token name does not change. Hard-code either one and the opposite theme loses legibility.
Dark mode is for impact — a hero, a final CTA band, a premium surface, or a reader who chose it. Light is the default for reading.
The three semantics, and the accent is not one of them
| Token | Light | Dark | Job |
|---|---|---|---|
--positive | #036b4e | #34D399 | It worked, it passed, it is covered |
--caution | #8a5a00 | #FEBC2E | Warning, stale, needs attention |
--destructive | #a4231c | #FF5F57 | Irreversible, refused, failed |
A brand accent means "this is the subject". A semantic means "this is a state".
Never use the accent to say something passed, and never use positive-green as
decoration — in iq they are near-neighbours and the difference is the meaning.
The shadcn bridge
styles/globals.css aliases the design-system names onto the shadcn names in
ONE place, so all 31 primitives inherit the identity:
--background ← --surface-page --primary ← --brand-accent
--card ← --surface-card --border ← --card-border
--foreground ← --text-primary --ring ← --brand-accent
--muted-foreground ← --text-secondary
--accent ← rgba(var(--brand-accent-rgb), 0.12)
Never give a shadcn name a raw hex. The flip lives in the semantic layer, and a hard-coded value breaks the other mode silently.
Row and surface ladder (tables, lists)
--row (card) → --row-alt (zebra) → --row-hover (an accent-tinted step) →
--row-selected. --row-header is charcoal with cream ink in light mode: the
table header is a dark band in both themes, which is what keeps a wide table
readable when the eye is scanning columns.
Texture and gradient
32px grid or 32px dots, never both, never stacked, under 8% contrast
(styles/ds/texture.css). On dark, one soft accent glow or the
#18181B → #0F0F11 vertical fade. Never a rainbow or multi-stop gradient, and
never a gradient over content — it sits under it.