Skip to main content

Color and typography

WCAG AA wants 4.5:1 for normal text, 3:1 for large text (18pt, or 14pt bold) and for any non-text thing that carries meaning — an icon, a status dot, a focus ring. The WebAIM contrast checker will compute a pair for you.

The rule you follow in this repo is simpler: take the color from styles/tokens/. But the tokens are not all safe, and this page tells you which ones. Every number below was computed from the generated styles/_variables.css and styles/_variables.light.css.

What passes, what doesn't

Dark is the default theme (:root); light is [data-theme='light'], set by app/layout.tsx.

Foreground tokenOnRatioVerdict
--color-text-primary-normal--color-background-paper12.27AAA
--color-text-secondary-normal--color-background-paper6.92AA, not AAA
--color-button-primary-text #49454f--color-button-primary-base #ffcd006.22AA
--color-button-primary-focus #90CAF9--color-background-paper7.01AAA — good ring color
--connection-strength-medium (gold)--color-background-paper8.75AA
--color-text-disabled #9e9e9e--color-background-paper4.58AA, barely
--color-text-disabled #9e9e9e--color-background-elevated3.75fails AA text
--utility-alert--color-background-paper4.53AA, barely
--connection-strength-strong (green)--color-background-paper3.65fails AA text, passes non-text
--connection-strength-offline (red)--color-background-paper2.63fails everything
light --color-text-primary-normallight --color-background-default13.94AAA
light --color-text-secondary-normallight --color-background-paper5.27AA
light --color-text-disabledlight --color-background-paper2.59fails AA text

The offline red is used as text

--connection-strength-offline (rgba(222, 45, 38, 1)) reads 2.63:1 on the standard surface — below the 4.5:1 text bar and below the 3:1 non-text bar. It would be fine as a fill with a label next to it. It is not being used that way. Five places set it as a color:

components/atoms/AIIndicatorBtn/AIIndicatorBtn.module.scss:28
components/molecules/CameraDetails/CamDetailsButtons/CamDetailsButtons.module.scss:81
components/molecules/Notifications/AlertMessage/AlertMessageButton/…:78
components/molecules/Notifications/AlertMessage/AlertSidePanel/AlertSidePanel.module.scss:31
components/organisms/LocationView/CameraTitleWrapper/CameraTitleWrapper.module.scss:46

This is the offline/alert state in a wildfire console — the one piece of status a dispatcher most needs to read at a glance. Lightening the token (or adding a lighter --connection-strength-offline-text variant for text usage) is a token-level fix; don't work around it locally with a one-off hex.

The same shape of problem, less severely, applies to --connection-strength-strong: at 3.65:1 it is a legal dot or icon but an illegal text color, and components/molecules/CameraDetailsTileView/CameraDetailsTileView.tsx:306 uses it as color.

Disabled-looking is not the same as disabled

WCAG exempts genuinely disabled controls from contrast requirements. components/organisms/Navbar/Navbar.tsx uses MUI's text.disabled for something else:

color: visibleLocationViewPanels.image ? 'text.primary' : 'text.disabled';

That is the unselected state of a view toggle — still clickable, still meaningful. It is not disabled, so the exemption does not apply and it needs to clear 4.5:1. Reach for --color-text-secondary-normal (6.92:1) for de-emphasised-but-active, and keep text.disabled for controls that genuinely do nothing.

Never encode meaning in color alone

WCAG 1.4.1, and the one that matters most in a status-dense UI. Camera state, alert severity, and connection strength all have a color here — each also needs a label, an icon shape, or text. ImageFreshnessBadge gets this right: the colored dot is aria-hidden="true" and the adjacent text carries the meaning, so the dot is pure reinforcement.

The type scale, and why it is in px

styles/tokens/ generates five sizes and no more:

TokenValueWhere styles/theme.ts puts it
--typography-font-size-xs12pxsmall print, DialogPopup
--typography-font-size-sm14pxbody1 — the MUI default body text
--typography-font-size-md16pxh3 and body2
--typography-font-size-xxl24pxh2
--typography-font-size-xxxl32pxh1

Two things to know before you pick one. There is no lg token — the jump from 16px to 24px has nothing in it, which is why you will find hardcoded intermediate sizes if you grep. And h3 (16px) is larger than default body text (14px) by only one step, so heading hierarchy in this app is carried mostly by weight and position, not size.

Default body text is 14px at --typography-font-weight-light (200). That weight is really loaded — app/layout.tsx requests Roboto at ['100'…'900'] via next/font/google — so it renders as a true light face rather than a synthesised one. It is legible, but it is thin white-on-black at a small size, and it is the floor. Never set body copy below sm, and never below xs anywhere.

Sizes are px, not rem. Browser page zoom still scales everything (WCAG 1.4.4 is satisfied), and the viewport meta in app/layout.tsx sets no maximum-scale or user-scalable=no, so pinch-zoom works on mobile. What px costs you is the browser's default font size setting — a user who sets their browser to 20px base sees no change here. Converting the scale to rem is a token-pipeline change, not something to do one component at a time.

For where tokens come from and how the build generates them, see Design tokens.

Next: Testing and linting.