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 token | On | Ratio | Verdict |
|---|---|---|---|
--color-text-primary-normal | --color-background-paper | 12.27 | AAA |
--color-text-secondary-normal | --color-background-paper | 6.92 | AA, not AAA |
--color-button-primary-text #49454f | --color-button-primary-base #ffcd00 | 6.22 | AA |
--color-button-primary-focus #90CAF9 | --color-background-paper | 7.01 | AAA — good ring color |
--connection-strength-medium (gold) | --color-background-paper | 8.75 | AA |
--color-text-disabled #9e9e9e | --color-background-paper | 4.58 | AA, barely |
--color-text-disabled #9e9e9e | --color-background-elevated | 3.75 | fails AA text |
--utility-alert | --color-background-paper | 4.53 | AA, barely |
--connection-strength-strong (green) | --color-background-paper | 3.65 | fails AA text, passes non-text |
--connection-strength-offline (red) | --color-background-paper | 2.63 | fails everything |
light --color-text-primary-normal | light --color-background-default | 13.94 | AAA |
light --color-text-secondary-normal | light --color-background-paper | 5.27 | AA |
light --color-text-disabled | light --color-background-paper | 2.59 | fails 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:
| Token | Value | Where styles/theme.ts puts it |
|---|---|---|
--typography-font-size-xs | 12px | small print, DialogPopup |
--typography-font-size-sm | 14px | body1 — the MUI default body text |
--typography-font-size-md | 16px | h3 and body2 |
--typography-font-size-xxl | 24px | h2 |
--typography-font-size-xxxl | 32px | h1 |
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.