All documents

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-brandiq | rei | roof. Picks the accent.
  • data-themelight | 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

TokeniqreiroofJob
--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

TokenValueJob
--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.

TokenValueJob
--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

TokenLightDarkJob
--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.