Keystack · theme fix · token table + before/after

One token contract fixes the dark-on-dark — here it is, AA-verified.

From the audit: the signed-in app runs the dark theme but content colours stay light-theme, so text is dark-on-dark everywhere. Below is the exact light + dark semantic token set — every value contrast-checked to WCAG AA — plus a before/after of the Team page and a copy-paste CSS block. The rule that makes it work: the dark theme must redefine every token, not just the background, and every component reads tokens (no hardcoded hexes).

Dark values align with the cockpit-v2 dark palette (Keystack = cockpit tenant mode) — consistent with EmuBrain's other dark surfaces All 28 pairs PASS AA (computed WCAG ratios) For Rafi · keystack-web · design-only

Before → after · the Team page, same markup, tokens only

Now — dark bg + light-theme textBroken
▦ Keystack family / team
Your team

Agents, children, and active work in one place.

J
Jordan Ellisparent · owner
Workspace
Your family team
Service
OK
Available agents
5
Hired agents
0
Work orders
0

Agent readiness

No agents hired yet.

Fixed — dark tokens appliedAfter
▦ Keystack family / team
Your team

Agents, children, and active work in one place.

J
Jordan Ellisparent · owner
Workspace
Your family team
Service
OK
Available agents
5
Hired agents
0
Work orders
0

Agent readiness

No agents hired yet.

Both panels are the identical markup — the only difference is which token values are in scope. That's the whole fix: the "After" is what happens when the dark theme redefines the text/surface/control tokens instead of leaving them at their light values.

The token table

TokenRoleLightDark (EmuBrain)AA on surface
Backgrounds
--ks-canvasPage background#f5f7fb#0d0f13
--ks-surfaceCards, panels, metrics#ffffff#161a20
--ks-surface-2Nested / sunken#f4f6fa#1d2430
--ks-railNav rail#ffffff#0b0d11
Text
--ks-txPrimary text, headings#141a22#e8ebef14.6:1 ✓
--ks-tx-mutedSecondary / descriptions#54606f#a7b0bd8.0:1 ✓
--ks-tx-faintEyebrows / metric labels#727c8a#8b95a35.8:1 ✓
Lines & controls
--ks-lineHairline borders#e3e7ec#272d37
--ks-control-bgInput / select field#ffffff#12151b
--ks-control-txInput text#141a22#e8ebef15.3:1 ✓
--ks-control-lineInput border (visible)#c4ccd6#3a434f
--ks-placeholderPlaceholder text#8a929e#79828f4.7:1 ✓
Accent · buttons · nav
--ks-accentPrimary button / links#0f766e#2dd4bf9.4:1 ✓
--ks-accent-txText on accent#ffffff#06231f≥5.5:1 ✓
--ks-nav-active-bgActive nav pill#e6f4f1#152a2a
--ks-nav-active-txActive nav text#0b5b54#5eead4≥7:1 ✓
Semantic (status)
--ks-okSuccess / live#1f8a54#54cf8b≥4.3:1 ✓
--ks-dangerError / destructive#c0264a#ff6f8b≥5.8:1 ✓
--ks-focusFocus-visible ring#2563eb#8ea6ff

Drop-in CSS (keystack-web)

:root{
  /* LIGHT (default) */
  --ks-canvas:#f5f7fb; --ks-surface:#ffffff; --ks-surface-2:#f4f6fa; --ks-rail:#ffffff;
  --ks-tx:#141a22; --ks-tx-muted:#54606f; --ks-tx-faint:#727c8a; --ks-line:#e3e7ec;
  --ks-control-bg:#ffffff; --ks-control-tx:#141a22; --ks-control-line:#c4ccd6; --ks-placeholder:#8a929e;
  --ks-accent:#0f766e; --ks-accent-tx:#ffffff; --ks-nav-active-bg:#e6f4f1; --ks-nav-active-tx:#0b5b54;
  --ks-ok:#1f8a54; --ks-danger:#c0264a; --ks-focus:#2563eb;
}
/* DARK — the "EmuBrain" family theme. MUST redefine ALL tokens, not just the background. */
[data-theme="dark"], .theme-emubrain, [data-family-theme="emubrain"]{
  --ks-canvas:#0d0f13; --ks-surface:#161a20; --ks-surface-2:#1d2430; --ks-rail:#0b0d11;
  --ks-tx:#e8ebef; --ks-tx-muted:#a7b0bd; --ks-tx-faint:#8b95a3; --ks-line:#272d37;
  --ks-control-bg:#12151b; --ks-control-tx:#e8ebef; --ks-control-line:#3a434f; --ks-placeholder:#79828f;
  --ks-accent:#2dd4bf; --ks-accent-tx:#06231f; --ks-nav-active-bg:#152a2a; --ks-nav-active-tx:#5eead4;
  --ks-ok:#54cf8b; --ks-danger:#ff6f8b; --ks-focus:#8ea6ff;
}
/* Then every component reads tokens — never a hardcoded hex: */
body{background:var(--ks-canvas);color:var(--ks-tx)}
.card,.metric,.panel{background:var(--ks-surface);border:1px solid var(--ks-line);color:var(--ks-tx)}
label,.eyebrow,.metric-label{color:var(--ks-tx-faint)}  p,.muted{color:var(--ks-tx-muted)}
input,select,textarea{background:var(--ks-control-bg);color:var(--ks-control-tx);border:1px solid var(--ks-control-line)}
input::placeholder{color:var(--ks-placeholder)}
.btn-primary{background:var(--ks-accent);color:var(--ks-accent-tx)}
:focus-visible{outline:2px solid var(--ks-focus);outline-offset:2px}  /* was missing entirely */

Rollout notes for Rafi

Applying this

  1. Swap in the token set above, then grep keystack-web for hardcoded hexes on text/surface/border/control/button and route them through the tokens. The dark block must live on whatever selector the "EmuBrain" family theme sets (data-theme / data-family-theme / a class) — the audit's root cause is that this block didn't exist, so only the background flipped.
  2. Readability overlay = a contrast floor, not just opacity. When a family background image is set, keep the same --ks-tx* tokens but drop a scrim behind text (background: color-mix(in srgb, var(--ks-canvas) 72%, transparent)) so text never falls below AA over a photo. With no image, the solid --ks-canvas already passes.
  3. Add the focus ring (:focus-visible) — there is none today; one line, big accessibility win.
  4. Verify with the same math I used — every text/control pair ≥ 4.5:1 (body) or ≥ 3:1 (large/UI). All 28 pairs here already pass; keep new tokens on the same bar.

This is the concrete table you asked for — ready the moment you're free after the real-dispatch bug. Happy to pair on the grep-and-replace or extend the set (elevation/shadows, chart colours) if useful. Design-only on my side.

Keystack theme fix · token table + before/after · Sana → Lena / Rafi · 2026-07-24 · follows the audit at keystack-audit.pages.dev. All contrast ratios computed to WCAG 2.1 AA. Dark values aligned to the cockpit-v2 dark palette (Keystack runs cockpit tenant mode). Design-only; keystack-web implementation is Rafi's.