GALAXY_EMBED_THEME_PLAN

Galaxy Embed Theme Plan: Dark-mode the embedded Page for Orbit

Status: Phase A implemented, unit-tested, and live-verified end-to-end 2026-06-22 — embed renders fully dark (bg #2c3143, light text, no white bands, no cell errors). Not yet committed. Galaxy: built-in orbit theme + --content-* plumbing + App.vue ?theme= honoring + PageView dark prose CSS (themes 5/5, resolveTheme 8/8). Orbit: getEmbedUrl appends &theme=orbit (1003/1003 green). Live-verify caught + fixed two real bugs (see §4 Phase A notes): the scoped-CSS :global() mangling and a Bootstrap .bg-white/.stripe white-band. Committed 2026-06-22 — Galaxy c101fb3b31, Orbit 82ff4f2 (both on notebook_iframe, not pushed). Pending: Phase B (widgets); the separate loom-session push fix is still uncommitted in the Loom worktree. Date: 2026-06-22 Goal: When Orbit shows a bound Galaxy Page in its <webview> (the notebook embed), the page should read as part of Orbit’s dark shell instead of a bright white rectangle — done the right way, by extending Galaxy’s existing theme system into page content, not by injecting CSS into a cross-origin webview. Related: LOOM_PLAN.md (the embed itself), LOOM_1B_EMBED_AUTH_DESIGN.md (embed-token auth).


0. Problem

Orbit is dark-only (single fixed :root palette in app/src/renderer/styles.css — “Galaxy brand dark theme”, #2c3143 bg / gold accent; no light mode, no prefers-color-scheme).

The notebook embed is not Orbit-rendered. artifact-panel.ts:showGalaxyWebview() mounts an Electron <webview> pointing cross-origin at {galaxy_server_url}/published/page?id={pageId}&embed=true&rev=…. Inside it, Galaxy renders the page (PageView.vue, embed mode). embed=true strips chrome only — it does not theme. Galaxy renders light (Bootstrap 4.6). Net effect: a white document sitting in a dark shell.

1. Research findings (2026-06-22)

Galaxy does have a real, runtime theme system — and it’s the right hook, but it does not reach page content today.

Two limits make it unusable as-is for the embed:

  1. Masthead-only reach. Every sample theme defines masthead: and nothing else, and page/markdown content CSS consumes zero theme vars (grep 'var(--…' in components/Page + components/Markdown → empty). Content color is hardcoded light Bootstrap 4. And in embed=true the masthead isn’t even rendered (isChromeFree), so today’s themes have literally no visual effect on the embed.
  2. Selection is a persisted user preference (App.vue reads userStore.currentTheme; set via /api/users), not a URL param. An embed has no theme picker and the embed-token user may have no pref set.

Galaxy stack constraint: Bootstrap 4.6 + bootstrap-vue 2.23 — predates Bootstrap 5.3 data-bs-theme / native dark mode. There is no prefers-color-scheme anywhere in the client. So a content dark mode must be built on CSS custom properties; there is nothing native to switch on.

1a. Decisions (locked 2026-06-22)

2. Decision

Extend Galaxy’s theme system into page content and select it for the embed via a query param. This is the durable choice:

Defaults preserve light. Every var-ified rule defaults to its current light value (var(--content-bg, #fff)), so normal Galaxy is byte-unchanged until a dark theme is selected.

3. Architecture

Four pieces, smallest-blast-radius first:

  1. Built-in orbit theme + content surfaces. Define orbit as a built-in nested theme in lib/galaxy/util/themes.py, seeded into config.themes before _load_theme so it’s always available (admin-overridable). Its content: block flattens to --content-bg, --content-text, --content-surface, --content-border, --content-link, etc. flatten_theme already supports nesting — no parser change. Also document a content: example in themes_conf.yml.sample.
  2. Content CSS consumes the vars. Replace hardcoded light colors in PageView.vue and the Markdown render components with var(--content-*, <light-default>). This is the cost driver — it scales with widget variety (see §4 phasing). Defaults keep normal Galaxy byte-unchanged.
  3. Honor ?theme=<id> for any embedded page. Extend App.vue’s theme() computed: when this.embedded, read $route.query.theme; if it’s a known key in config.themes, apply that theme dict on #app (its --content-* vars cascade to PageView). Returns null otherwise (today’s behavior). Generic — any theme id works. Gated on embedded, so it never overrides a logged-in user’s pref in the normal app.
  4. Orbit appends &theme=orbit. In the embed-URL builder (extensions/loom/galaxy-embed.ts getEmbedUrl), append &theme=<id> (default orbit, configurable). The orbit built-in mirrors Orbit’s palette (app/src/renderer/styles.css :root#2c3143 surface, #f0f2f8 text, gold).

4. Phasing (prose-first)

Each phase is independently shippable and red-to-green testable.

5. Testing strategy (red-to-green)

Galaxy-side (client):

Orbit-side:

Per house rule: red-to-green, never weaken assertions, run newly-added tests before claiming done.

6. Risks / watch-list

7. Unresolved questions

  1. Content scope to ship first: prose/markdown only (Phase A — current default), or must rich widgets (invocation tables, history cards, charts) be dark at v1 too? (Cost driver. Proceeding prose-first unless told otherwise.)

Resolved (2026-06-22)