Strata tokens
Source of truth:
packages/ui/src/css/theme.css(raw values,:root+.dark) andpackages/ui/src/css/tailwind.css(token → Tailwind utility wiring, radius ramp, type scale, base document styles). Edit those files; every consumer picks the change up automatically. The workbench (apps/strata) renders the live palette in both themes.
Palette model
- Semantic tokens only. Components consume
--background,--foreground,--card,--muted,--accent,--border,--ring,--sidebar-*,--chart-1..5, etc. via the Tailwind utilities (bg-background,text-muted-foreground,border-border). Never raw palette utilities. - Dark mode is class-based
.dark, overrides live in the sametheme.css. To tweak a dark-mode color, edit the.darkblock — one edit propagates to web-v2, marketing, and the docs sites.
The dark elevation ladder (ruling 2026-08-01)
Dark mode stacks by lightness: every nested plane is lighter than the one it sits on. Four planes, evenly stepped in L*, and the order is load-bearing:
| Plane | Dark | L* | What sits on it |
|---|---|---|---|
--secondary | rgb(20 20 22) | 6.4 | the deepest backdrop |
--background | rgb(25 25 27) | 8.8 | the page |
--card | rgb(33 33 35) | 12.8 | panels on the page |
--popover | rgb(42 42 44) | 17.1 | dialogs, menus, selects, tooltips |
--popover must stay above --card. A Card inside a Dialog is ordinary
markup, so the two can never hold the same value — they did until 2026-08-01,
and every card inside every dialog was invisible in dark mode. Light mode
encodes the same ordering (popover 255 over card 246); it is only more
forgiving because its --card is a recess rather than a lift.
Tints are alpha in dark, not opaque greys. --muted, --accent,
--border and --input are drawn ON a plane rather than being one, and an
opaque grey can only be calibrated against a single surface — which is how a
Skeleton (bg-muted) came to be invisible on a card, and how a dialog’s header
rule came to be invisible on a popover. They are white at 5%, 10%, 8% and 8%
respectively, so they lift with whatever is underneath. Each resolves to
roughly its old opaque value on --background. Light mode keeps opaque values
except --border, which was already alpha.
Adding a dark surface? Give it a step on this ladder and check it against every plane it can land on, not just the page.
Ink is calibrated against the darkest surface it lands on (ruling 2026-08-01)
The same one-surface mistake shows up in text, and it is easy to miss because
the token passes where you happen to be looking. --muted-foreground was tuned
against --background: 4.74:1 on white, then 4.39 on --card, 4.35 on
--secondary, 4.17 on --muted. --warning was worse — 3.19:1 on white and
2.95:1 on --card, under even the 3:1 large-text floor, on the surface
text-warning actually sits on.
Pick an ink’s value from the worst surface it can land on, not the page.
The fix is never to lighten the surfaces to meet the ink: --muted,
--secondary and --card are a calibrated ladder in light mode, and raising
them to rescue a text token collapses it — the same failure as --popover and
--card colliding in dark.
Current floors, worst case across every surface: --muted-foreground 4.54:1
(light) / 4.57:1 (dark), --warning 4.65:1, --destructive 4.52:1. The two
deliberate exceptions are --brand and --brand-green-success at ~2:1, which
are identity colors carried by legends and 2px strokes rather than read as
copy (see Chart colors below).
- Neutral grays carry a +2 blue channel (cool/slate tinge) in both themes. Keep that when adding grays.
--inkis the fixed brand near-black (rgb(0 0 2), same in both themes) for chrome like the nav island and footer.
Surfaces
- The secondary plane remaps
--card.--cardis calibrated against--background; on a--secondaryfill it vanishes in light mode (the two values are near-identical). Any container that establishes a secondary plane declaresdata-surface="secondary"(rule lives incss/tailwind.css): it gets the--secondaryfill and redefines--card: var(--background)for its subtree, so cards float on it in both themes. Consumers:WindowSheetSection variant="secondary", the settings dialog bodies, the comments conversation pane. - Never fix a card-on-secondary collision with a descendant CSS override or
a call-site background class — mark the container
data-surface="secondary"instead. Call sites must stay ignorant of the background they sit on. - Portaled content (dialogs, popovers) opened from a secondary surface
escapes the DOM subtree and keeps the stock
--card— correct, it renders on the page backdrop, not the secondary plane. - Same subtree-override idiom as
--brandbelow; cards stay borderless (founder ruling 2026-07-08: no ring) — separation comes from the surface stack, not outlines.
Brand palette
Five fixed pastels, identical in light and dark, exposed as
text-/bg-brand-{purple,blue,green,peach,mint}:
--brand-purplergb(198 172 226)— also aliased as--brand(text-brand/bg-brand), the primary accent. A subtree may override--brandlocally (section accent bands) and every--brand-backed utility andcolor-mix()follows.--brand-blue/--brand-green/--brand-peach— the carousel accents.--brand-mintrgb(145 237 188)— the Dashboard accent (added 2026-07-20, replacing the goal-era gold, which is dead).--brand-green-successrgb(73 200 95)— saturated functional green for money/positive signals only.
Area accents come from this palette (founder ruling 2026-07-20): the nav’s
agent areas map social → purple, ads → green, ugc → peach, measurement → blue
(area-color.ts); utility destinations (Library) carry no accent and render
the generic active chip like Chats. The old off-brand ads sage #d1ddd3 is
dead.
JS mirror: components that need literal values (inline styles, SVG
attributes) read PALETTE from @layers/ui/lib/palette
(packages/ui/src/lib/palette.ts; marketing re-exports it). If you change a
--brand-* value in theme.css, update the mirror in the same commit.
Chart colors (ruling 2026-07-10)
Two token families with two different jobs — never swap them:
--chart-1..5is the VALUE ramp — a monochrome light→dark grayscale for magnitude (a single-series bar chart, a heat ramp). It is deliberate and never repurposed for identity.--chart-series-1..6is IDENTITY — categorical colors that say which series is which, in a fixed order: purple → green → blue → peach → rose → sand. Assign them to entities, never to ranks — a filter that changes the series count must not repaint the survivors.- 1–4 alias the brand pastels, CVD-validated 2026-07-10: this order’s worst adjacent pair is ΔE 28.9 (blue~peach, protanopia; CIE76); the naive carousel order fails outright (green beside peach reads identical under deuteranopia, ΔE 3.8).
- 5 and 6 are chart-only colours (founder ruling 2026-09-25):
--chart-series-5rose#c66aaaand--chart-series-6sand#b89f8c. They have no--brand-*alias and hold one value in both themes. Every brand pastel sits in one lightness band, and no two further hues inside that band stay apart under all three colour-vision deficiencies, so both sit deeper than the pastels; lightness carries them where hue collapses. Mint was tested and rejected: green~mint is ΔE 3.8 under protanopia. - Validation 2026-09-25 (CIEDE2000, Machado 2009 simulation at full
severity). Every pair involving series 5 or 6 is ΔE ≥ 10 under every
vision type. Worst such pair per vision: normal peach
sand 14.9, deuteranopia greensand 12.7, protanopia peachsand 11.4, tritanopia purplesand 10.7. The six-series order’s worst adjacent pair is rosesand at ΔE 15.2 under tritanopia (the original four’s worst is greenblue at 15.8 there); under the 2026-07-10 CIE76 method it is still blue~peach at 28.9 under protanopia. Both new colours stay at least ΔE 10.2 from every status colour and ΔE 10.9 from the “Other” ink under every vision type. Contrast on the light card (--surface-raised): rose 3.2:1, sand 2.3:1, above the weakest pastel (green, 1.39:1). On the dark card: rose 4.6:1, sand 6.4:1.
Rules that fall out:
- Cap: 6 identity series. A 7th folds into “Other” rendered with
--muted-foreground, never a generated hue. - A legend is mandatory on every ≥2-series chart. The pastels sit at 1.5–2:1 contrast on light surfaces (an accepted identity trade; rose and sand sit higher at 3.2:1 and 2.3:1, and all six clear 3:1 on dark) — the legend plus 2px strokes carry identity. A single series needs no legend; the title names it.
- One y-axis, always. Two measures of different scale get two charts.
- Status colors (
--warning,--destructive,--brand-green-success) are never used as series colors. - Chart configs point at these tokens (
var(--chart-series-1)), never raw hex — the--chart-series-*indirection is the escape hatch if a future ruling darkens the light-mode steps.
JS mirror: CHART_SERIES in packages/ui/src/lib/palette.ts (same
fixed order) for SVG fills and SSR; keep it in lockstep with theme.css.
Radius
Everything derives from the single --radius token (0.625rem) via the ramp
--radius-sm … --radius-4xl in tailwind.css. Never write a bare
var(--radius-*) that isn’t in the ramp, and never invent an arbitrary
radius value. Squircle corners (corner-shape: squircle) are authored ~2x
the visual target and MUST ship a half-value fallback behind an @supports
gate — never inline-style a squircle radius.
Type scale
The Layers scale is denser than Tailwind’s defaults — shared components are authored against it and render ~2px oversized without it:
| Utility | Size |
|---|---|
text-xxs | 10px |
text-xs | 11px |
text-sm | 12px |
text-base | 14px (body default) |
text-lg | 16px |
text-xl | 20px |
16px is a floor for focused text controls on touch surfaces, and it is not
part of this ramp. Mobile Safari auto-zooms any focused input under 16px, so
apps/web pins input/textarea/select/contenteditable to a literal 16px
under (pointer: coarse), (max-width: 799.98px) — one unlayered rule in
client/css/mobile-shell.css. Above the breakpoint on a mouse the scale above
still applies, so text-base (14px) remains the input size on desktop.
Note what this ramp did to the stock shadcn guard: text-base md:text-sm is
16px → 14px on Tailwind’s default scale and IS the zoom mitigation, but 14px →
12px on ours, so it never worked here. See rules.md.
Fonts: --font-sans = Inter Variable (via @fontsource-variable/inter);
--font-mono = Berkeley Mono (self-hosted per app); --font-heading = the
sans. Base document styles (14px body, antialiasing, the theme-aware thin
scrollbars) come from tailwind.css’s @layer base — don’t re-declare them
per app.
Misc tokens
--noise-url (hosted noise SVG), --logo-color (the logo lime,
rgb(181 255 107)), --success-background, --dot-grid (the dot-grid
backdrop fill; deliberately fainter in dark mode).
--media-backdrop (bg-media-backdrop) is the letterbox behind a
video/photo canvas — constant black in both themes, never a themed surface.
Reach for it instead of writing black by hand; before 2026-08-01 six sites had
each written their own (#000, #000000, #1a1a1a, bg-black) and disagreed
about the value. Chrome drawn ON TOP of media (caption scrims, platform
mockups, carousel dots) is the separate case where literal white/black is
correct — a semantic token would invert and go illegible over the image. Those
files carry a standing entry in the strata-gate no-raw-palette-utility ALLOW
list with that reason.
Status indicators follow the binding status-color contract:
green is ok, amber is warning, and red is error-only. Status UI uses the
--status-* tokens and declares its semantic with data-status.
Rules
- Grep
packages/ui/src/css/theme.cssbefore writing anyvar(--x)— if the token doesn’t exist there (or in the consumer’s own app layer), don’t use it. - Never redefine a Strata token in an app stylesheet. App-specific tokens
(e.g. marketing’s
--container-marketing) live in the app’s own@theme inlineblock under a distinct name. - Never invent arbitrary CSS values (heights, offsets, spacing) — ask for the correct value if a design doesn’t specify one.