Strata tokens

Source of truth: packages/ui/src/css/theme.css (raw values, :root + .dark) and packages/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 same theme.css. To tweak a dark-mode color, edit the .dark block — 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:

PlaneDarkL*What sits on it
--secondaryrgb(20 20 22)6.4the deepest backdrop
--backgroundrgb(25 25 27)8.8the page
--cardrgb(33 33 35)12.8panels on the page
--popoverrgb(42 42 44)17.1dialogs, 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.
  • --ink is 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. --card is calibrated against --background; on a --secondary fill it vanishes in light mode (the two values are near-identical). Any container that establishes a secondary plane declares data-surface="secondary" (rule lives in css/tailwind.css): it gets the --secondary fill 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 --brand below; 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-purple rgb(198 172 226) — also aliased as --brand (text-brand/bg-brand), the primary accent. A subtree may override --brand locally (section accent bands) and every --brand-backed utility and color-mix() follows.
  • --brand-blue / --brand-green / --brand-peach — the carousel accents.
  • --brand-mint rgb(145 237 188) — the Dashboard accent (added 2026-07-20, replacing the goal-era gold, which is dead).
  • --brand-green-success rgb(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..5 is 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..6 is 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-5 rose #c66aaa and --chart-series-6 sand #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 peachsand 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:

UtilitySize
text-xxs10px
text-xs11px
text-sm12px
text-base14px (body default)
text-lg16px
text-xl20px

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.css before writing any var(--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 inline block under a distinct name.
  • Never invent arbitrary CSS values (heights, offsets, spacing) — ask for the correct value if a design doesn’t specify one.

Raw markdown for agents: /docs/tokens.md