Skip to content

Theming across surfaces

A Celestial workspace has one active brand theme that drives the look of every customer-facing surface in that workspace — dashboard, marketing-site, Lens preview, AuthKit login page. This page documents the data flow, the contract each layer holds, and the one notable gap (WorkOS) that requires a manual step.

The arc that built this shipped as backlog items A–I (tasks #117–#125).


One source of truth: the active Lens workspace.

# celestial.lens.yaml — the workspace primitive (lives at repo root)
workspace: celestial-intelligence # MUST match an ss workspace name
env: prod
themes:
- id: gold
label: "Aetheric Technomancy"
css: packages/theme/src/index.css
logo: packages/marketing-site/public/wordmark-gold.svg
- id: admiral
label: "Admiral Operator"
css: packages/theme/src/themes/admiral.css
logo: packages/marketing-site/public/wordmark-cyan.svg
active: gold # ← `lens set-active <id>` rewrites this

When a designer runs lens set-active admiral, two things happen:

  1. The active: field in celestial.lens.yaml is rewritten (YAML formatting + comments preserved).
  2. A row is upserted into Supabase table celestial_service_state:
    (workspace_id, env, service, kind) = value
    (celestial-intelligence, prod, lens, active-theme) = { themeId: "admiral", ts: "..." }

That single row is what every other system reads.


┌───────────────────────────────┐
│ celestial.lens.yaml │
│ + celestial_service_state │
│ (lens, active-theme) │
└────────────┬──────────────────┘
┌──────────────────────┼─────────────────────┬──────────────────────┐
▼ ▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ ss compose │ │ Lens preview │ │ dashboard chrome │ │ auth-branding │
│ ┌─────────────┐ │ │ (auto: workspace│ │ (admiral cyan/ │ │ job │
│ │buildTopology│ │ │ YAML is the │ │ void from │ │ ┌──────────────┐ │
│ │ → .brand │ │ │ Lens config │ │ themes/admiral.css)│ │ │computes │ │
│ └──────┬──────┘ │ │ source) │ │ per data-theme= │ │ │WorkOsBranding│ │
└────────┼────────┘ └─────────────────┘ │ attribute on <html>│ │ │Fields, writes│ │
▼ └────────────────────┘ │ │service-state │ │
┌──────────────────┐ │ │(workos- │ │
│ topology API │ │ │ authkit, │ │
│ /api/state/ │ │ │ desired- │ │
│ topology │ │ │ branding) │ │
│ → brand field │ │ └──────┬───────┘ │
└────────┬─────────┘ └────────┼─────────┘
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ topology-view │ │ dashboard banner │
│ "Theme: gold │ │ "Apply these │
│ (Lens · …)" │ │ values in │
│ badge per node │ │ WorkOS UI" │
└──────────────────┘ └──────────────────┘

composeWithBrand() (in packages/starsystem-cli/src/compose/brand.ts) fetches the active-theme row at compose time and attaches a brand field to the composed workspace:

brand: { activeTheme: "gold" | null, source: "lens" | "default", updatedAt: ISO | null }

The field rides through buildTopology() into the topology projection, which is what the dashboard’s topology view consumes. A Theme: gold (Lens · celestial-intelligence) chip renders per node so the user can answer “where does this styling come from?” at a glance.

Soft-fail: if Supabase is unreachable (offline dev, missing env vars), compose emits a debug log and falls back to source: 'default'. The network read never blocks the CLI.

The dashboard’s HTML root sets <html data-theme="admiral">. That selector engages the cyan/void palette in packages/theme/src/themes/admiral.css. The dashboard ships admiral.css directly (vs reading the active-theme row at runtime) because it IS the admin operator console — the cyan aesthetic is the intended ADMIRAL identity, not a customizable surface.

Customer-facing surfaces (marketing-site sections served per-customer, the eventual customer-tenant dashboards) read the active-theme row at build time and ship the matching CSS in their bundle.

The design-gallery’s lens.config.ts reads celestial.lens.yaml via Vite’s ?raw import, so the theme switcher in the Lens UI mirrors the workspace state automatically. Editing celestial.lens.yaml is the canonical way to change what Lens shows.

WorkOS AuthKit branding (logo, colors, fonts on the hosted login page) is not configurable via the Management API as of May 2026. Phase A of the theming arc probed every plausible endpoint — /branding, /user_management/branding, /applications/<id>/branding, etc. — all 404. The WorkOS changelog mentions branding features (AI Branding Assistant, Custom Fonts) but zero programmatic APIs.

So the auth-branding job is a drift notifier rather than a push-config:

  1. Job reads the active theme via celestial_service_state.
  2. Loads the theme’s CSS file → calls extractThemeTokens() → maps to WorkOsBrandingFields = { primaryColor, backgroundColor, accentColor, logoUrl, fontFamily }.
  3. Writes (workspace, env, 'workos-authkit', 'desired-branding') with the computed fields.
  4. Idempotent: if the existing row’s fields already match, no write.

The dashboard subscribes to that row and shows a banner above the viewport when desired-branding.computedAt > applied-branding.dismissedAt:

Your WorkOS AuthKit branding is out of sync with active Lens theme ‘admiral’. Open WorkOS dashboard → AuthKit → Branding and apply: primaryColor=#00E5FF, backgroundColor=#030712, fontFamily=Jost. [Open WorkOS dashboard] [I’ve applied this]

Pressing “I’ve applied this” POSTs to /api/state/branding/dismiss which writes an applied-branding row with dismissedAt = now(). Subsequent drift (a new Lens active-theme) re-triggers the banner.

Re-evaluate quarterly: when WorkOS exposes a real Branding API, Phase H upgrades from drift-notifier to push-config — same job, same data flow, just an outbound API call replacing the banner.


Cross-service alignment rule (load-bearing)

Section titled “Cross-service alignment rule (load-bearing)”

A Lens workspace name MUST match an ss workspace name. This is a hard architectural rule. Both celestial.lens.yaml and celestial.prod.ssws.yaml use the same workspace: celestial-intelligence identifier, which is why ss and Lens can cross-query celestial_service_state without an intermediate mapping table.

The Lens composer emits a stderr warning (v1; future: hard-fail) if no sibling *.ssws.yaml declares the same workspace name. Don’t under-name; don’t rename one half without the other.


  1. Drop a CSS file at packages/theme/src/themes/<id>.css with a [data-theme="<id>"] selector and the standard tokens (start by copying themes/admiral.css).
  2. Register it in celestial.lens.yaml under themes::
    - id: <id>
    label: "Display Name"
    css: packages/theme/src/themes/<id>.css
    logo: packages/marketing-site/public/wordmark-<id>.svg
  3. Run pnpm --filter @celestial/theme test to confirm extractThemeTokens() resolves all 8 BrandTokens fields (any missing token returns "" — the test will flag it).
  4. Eyeball in Lens (lens dev) — switch to the new theme via the gallery’s switcher.
  5. When ready, lens set-active <id> to make it the workspace default.

Adding a new surface that consumes the theme

Section titled “Adding a new surface that consumes the theme”

Use the established pattern (same as ss compose, topology-view, auth-branding already do):

import { createServiceStateClient } from "@celestial/shared";
const client = createServiceStateClient({ url, serviceKey });
const row = await client.get({
workspaceId: "celestial-intelligence",
env: "prod",
service: "lens",
kind: "active-theme",
});
const themeId = (row?.value as { themeId?: string } | undefined)?.themeId ?? "gold";

Then load the matching CSS or call extractThemeTokens() for structured fields. Soft-fail if Supabase is unreachable — your surface should render with a sensible default.


WorkOS AuthKit’s Custom Fonts feature supports Google Fonts only. Our shipping themes use:

ThemeDisplayBodyMonoAll on Google Fonts?
goldJostMontserratJetBrains Mono
admiralInterInterJetBrains Mono
cyan (legacy)InterInterJetBrains Mono

If a future theme picks a font outside Google Fonts, the Lens composer will not detect that today — file as a future-runner check. The runbook on dashboard security headers covers the dashboard-side font loading.


  • packages/lens/AGENTS.md — Lens workspace primitive + set-active CLI
  • Dashboard security headers — the related polish item from the same arc
  • ADR-021 — saas-dashboard-workos (the auth substrate this theming sits on top of)
  • ADR-033 — platform-services-as-remote-mcp (Track A session model)