#228

Share web's theme tokens and families with the phone

Opencalledforthwants to merge intofrom

What this does

The phone's colours are now web's by token, not hand-copied hex values. Every theme family and scheme web has (six families × light and dark) paints the same on the phone, and a token change on web cannot drift from it.

The generator (packages/app-core)

scripts/theme-tokens.mjs reads styles/globals.css and styles/fluid.css, applies the cascade the way the browser does for each data-theme scheme on the root element (every :root / html rule plus the matching [data-theme=…] and :is(…) rules, by specificity then source order; Tailwind's @theme block counts as root; descendants, pseudo-elements, media queries and utilities are not part of the root cascade), substitutes var(), evaluates color-mix() (premultiplied alpha, as the spec says, so mixing with transparent keeps the hue), oklch(), rgb(… / a) and friends with culori, and writes src/lib/theme-tokens.generated.ts: for each scheme, every custom property as #rrggbb, rgba(r, g, b, a) or its text, plus the shadow recipes parsed into layers.

  • The file is checked in. tests/theme-tokens.test.ts (node environment) regenerates in memory and fails when the file is stale, so changing a token in globals.css without regenerating fails ci:app-core. pnpm --filter @openmanager/app-core theme-tokens regenerates; theme-tokens:check is the same check as a script.
  • The test also pins the semantics: every scheme from theme-families.ts and no other; no var() or colour function survives; each family's canvas and ink match its swatch; a family override beats the root default by specificity (neutral canvas #171717, not globals' #141414); light schemes carry opaque pastel washes while dark ones carry translucent ones.
  • src/lib/theme-tokens.ts wraps the data (themeTokens(scheme), themeShadows(scheme), colorChannels, withAlpha) and is exported as @openmanager/app-core/lib/theme-tokens; ./lib/theme-families is exported too, so the phone imports both without the full logic graph (Metro cannot yet resolve that graph's .js specifiers — CAL-148). The logic entry re-exports theme-tokens.
  • New devDependencies for the script only: postcss, culori. The generated file is prettier-ignored (it is generated).

The phone (apps/mobile)

  • src/theme/tokens.ts: phoneTheme(scheme) derives a PhoneTheme from the generated map — the roles the mockups use (canvas, ink, muted, faint, rule, hover, active, surface-2, bubble, action, float, scrim, tint, status colours and washes), glass as the floating surface at a scheme-specific opacity, shadows as boxShadow text and as layers — and themeVars turns it into NativeWind variables. legacyBasis keeps the first build's token shape so its screens still compile and paint.
  • tailwind.config.js: every colour points at a variable; new role names (bg-canvas, text-ink, bg-hover, bg-glass, text-status-done…) alongside the first build's names.
  • src/theme/appearance.ts + ThemeProvider.tsx: family + mode (light / dark / system) in AsyncStorage under web's key and JSON shape (openmanager-appearance), resolved against the OS scheme when the mode is system, the old mode-only key carried over once, the window background set to the canvas. useTheme() exposes appearance, resolved scheme and PhoneTheme; usePhoneTheme() for imperative styles.
  • Settings → Appearance gains the six family swatches; Settings → Theme → "Preview every scheme" opens src/app/theme-preview.tsx, which paints the current scheme and all twelve.
  • vitest (node) on the appearance and derivation logic: pnpm --filter @openmanager/mobile test, run in CI through a new root ci:mobile script (typecheck + tests) in the desktop validation job; the root test chain includes mobile too.
  • The first build's session status dot and user bubble now read the selected family (bubble on basis-chat-user-bg with no outline, as web paints it); destructive colours come from color-destructive / color-destructive-foreground rather than copied hex.
  • apps/mobile now depends on @openmanager/app-core (workspace). Only the two theme modules are imported, so nothing from app-core's UI reaches the bundle.

Not done: the UI font setting (web's Inter / Public Sans switch) — the phone ships Geist and JetBrains Mono as before; a font setting is cheap to add later but is not part of this change.

Reviews

gpt-6.1-sol (high) derived eight tokens by hand for neutral, carbon, claude-light and lovable and matched the generated file throughout, verified the specificity/order behaviour, premultiplied alpha, :is() and font selectors, descendant exclusion, boxShadow on RN 0.86 and NativeWind vars() with rgba, and found three P2s — live consumers (status dot, user bubble) and the destructive constants bypassing the generated roles — all fixed in the second commit. Greptile found the same three plus the migration catch, shadow-color as a shadow, and mobile tests outside CI; all fixed and answered inline.

Verification

CheckResult
theme-tokens.mjs --checkup to date
app-core tests/theme-tokens.test.ts7 pass
app-core typecheck / lint / full testsclean / 0 errors / 48 files, 516 tests pass
web, desktop typecheckclean
mobile typecheckclean
mobile tests/theme.test.ts11 pass (also via pnpm run ci:mobile)
npx expo export --platform androidbundles (11 MB hbc) with app-core's theme modules
npx expo export --platform web + headless Chrome/theme-preview renders all 12 schemes, 0 page errors; screenshots in ~/.t3/reviews/mobile-phase1/cal-306/shots/

The screenshots show, for example, Neutral dark painting #171717 / #ededed with the violet–blue–amber–pink–green–red status marks, and Claude light on #faf9f5 with the clay tint, exactly as web does.

🤖 Generated with Claude Code

Activity

23
cloudflare-workers-and-pages[bot]9d ago

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

StatusDeployment URLCommitUpdated (UTC)See this deployment's details
  • Build: Failed ❌

View logs ↗
56608d92026-10-21T07:57:01.558ZView logs ↗
  • Build: Failed ❌

View logs ↗
61a81ff2026-10-21T07:38:29.986ZView logs ↗
greptile-apps[bot]9d ago


[Medium impact] The PR appears safe to merge; all six previous findings are addressed and no new actionable issues were found.

Summary

Shares web's theme colors and shadows with mobile through generated data for all twelve schemes.

  • Web’s CSS now produces a shared map of colors and shadows for every theme.
  • The phone’s saved appearance now picks a web theme family and color mode.
  • Mobile settings now show family swatches and a preview of every scheme.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  CSS["globals.css + fluid.css"] --> Generator["theme-tokens.mjs"]
  Generator --> Data["theme-tokens.generated.ts"]
  Data --> Phone["phoneTheme"]
  Saved["Saved family and mode"] --> Provider["ThemeProvider"]
  OS["OS light or dark mode"] --> Provider
  Provider --> Phone
  Phone --> Vars["themeVars"]
  Vars --> Screens["Mobile screens"]
  Phone --> Hooks["useTokens / usePhoneTheme"]
  Hooks --> Screens

Reviews (2) · Last reviewed commit: "Paint the live consumers from the genera..." · Reviewed by Greptile

greptile-apps[bot]reviewed9d ago
greptile-apps[bot]reviewed9d ago
greptile-apps[bot]reviewed9d ago
greptile-apps[bot]reviewed9d ago
greptile-apps[bot]reviewed9d ago
greptile-apps[bot]reviewed9d ago
greptile-apps[bot]reviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago
calledforthreviewed9d ago