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).
The contract
Section titled “The contract”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 nameenv: prodthemes: - 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.svgactive: gold # ← `lens set-active <id>` rewrites thisWhen a designer runs lens set-active admiral, two things happen:
- The
active:field incelestial.lens.yamlis rewritten (YAML formatting + comments preserved). - 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.
Who reads what
Section titled “Who reads what” ┌───────────────────────────────┐ │ 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" │└──────────────────┘ └──────────────────┘ss compose and topology projection
Section titled “ss compose and topology projection”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.
Dashboard chrome
Section titled “Dashboard chrome”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.
Lens preview
Section titled “Lens preview”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.
Auth-branding (the WorkOS gap)
Section titled “Auth-branding (the WorkOS gap)”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:
- Job reads the active theme via
celestial_service_state. - Loads the theme’s CSS file → calls
extractThemeTokens()→ maps toWorkOsBrandingFields = { primaryColor, backgroundColor, accentColor, logoUrl, fontFamily }. - Writes
(workspace, env, 'workos-authkit', 'desired-branding')with the computed fields. - 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.
Adding a new theme
Section titled “Adding a new theme”- Drop a CSS file at
packages/theme/src/themes/<id>.csswith a[data-theme="<id>"]selector and the standard tokens (start by copyingthemes/admiral.css). - Register it in
celestial.lens.yamlunderthemes::- id: <id>label: "Display Name"css: packages/theme/src/themes/<id>.csslogo: packages/marketing-site/public/wordmark-<id>.svg - Run
pnpm --filter @celestial/theme testto confirmextractThemeTokens()resolves all 8 BrandTokens fields (any missing token returns""— the test will flag it). - Eyeball in Lens (
lens dev) — switch to the new theme via the gallery’s switcher. - 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 font limitations (documented gap)
Section titled “WorkOS font limitations (documented gap)”WorkOS AuthKit’s Custom Fonts feature supports Google Fonts only. Our shipping themes use:
| Theme | Display | Body | Mono | All on Google Fonts? |
|---|---|---|---|---|
| gold | Jost | Montserrat | JetBrains Mono | ✓ |
| admiral | Inter | Inter | JetBrains Mono | ✓ |
| cyan (legacy) | Inter | Inter | JetBrains 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.
See also
Section titled “See also”packages/lens/AGENTS.md— Lens workspace primitive +set-activeCLI- 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)