THEME_OVERHAUL_PLAN

Monaco Theme Overhaul — Holistic Dark + Light

Date: 2026-04-16 Status (2026-04-20): Landed on vs_code_integration in commit 0186fb4. Phases A–G all complete: gxwf-dark / gxwf-light JSONs, synthetic theme extension, boot-time + reactive theme selection, themes.test.ts (8 cases), monaco-css-scoping.spec.ts theme assertions (default dark/light, live toggle, token colors). make check + make test green. Phase F3 folded into docs/architecture/gxwf-ui.md (no separate gxwf-ui-monaco.md pitfalls doc). Branch: vs_code_integration of galaxy-tool-util Supersedes: the in-flight uncommitted approach in packages/gxwf-ui/src/editor/theme.ts (chrome customizations layered on vs-dark) and Phase 9I in VS_CODE_MONACO_FIRST_PLAN_V2.md. Driver: ship Monaco editor branding via the same channel VS Code itself uses — full color themes contributed via an extension manifest — not via decorative workbench.colorCustomizations. Adds true light-mode support that tracks the app’s dark-mode toggle.


Background — what changed our mind

Initial attempt (uncommitted) called monaco.editor.defineTheme(...) and threw at runtime: defineTheme is not a function. That API is bypassed by getThemeServiceOverride() from monaco-vscode-api — themes route through the workbench theme service. Pivoted to workbench.colorCustomizations + editor.tokenColorCustomizations, which works but is decorative: it patches a base theme rather than owning one.

Web research + source inspection of monaco-vscode-api 30.0.1 confirmed the supported integration surface:

  1. Theme registration — extension manifest contributes.themes → JSON theme files. Same shape @codingame/monaco-vscode-theme-defaults-default-extension (Light+/Dark+) and per-theme packages (Solarized Light, Tomorrow Night Blue, etc.) use.
  2. Theme selectionworkbench.colorTheme user-config setting (initial via initUserConfiguration, runtime via updateUserConfiguration).
  3. Decorative overridesworkbench.colorCustomizations + editor.tokenColorCustomizations (current uncommitted approach).

Holistic approach uses #1 + #2 as primary and discards #3 — full first-class themes, switched at runtime from the dark-mode toggle.

Authoritative source citations

Decisions baked into this plan (from prior round)


1. Architecture

File layout (new)

packages/gxwf-ui/src/editor/
  themes/
    gxwf-dark.json          # full VS Code color theme — type: "dark"
    gxwf-light.json         # full VS Code color theme — type: "light"
  themesExtension.ts        # registers synthetic "gxwf-themes" extension
  themeSync.ts              # wires dark-class → workbench.colorTheme
  services.ts               # initUserConfiguration sets initial colorTheme
  extensionSource.ts        # (unchanged)
  monacoEnvironment.ts      # (unchanged)

Removed

Wiring

App boot
  └─ App.vue script: read localStorage["gxwf-dark"], add/remove `dark` class on <html>
                     (unchanged — already does this)

services.ts initMonacoServices()
  ├─ buildUserConfigJson(cfg)
  │   ├─ "workbench.colorTheme": initialColorThemeFromDom()    ← reads <html class="dark">
  │   ├─ "galaxyWorkflows.validation.profile": ...
  │   └─ "galaxyWorkflows.toolShed.url": ...
  ├─ await initUserConfiguration(json)
  ├─ await initialize({ ...overrides })
  ├─ await loadGxwfThemesExtension()                            ← registers gxwf-dark + gxwf-light
  │   └─ result.whenReady() resolves only after JSONs loadable
  └─ installThemeSync()                                         ← MutationObserver, idempotent
        └─ on <html>.classList["dark"] flip:
             updateUserConfiguration({ "workbench.colorTheme": "gxwf-dark"|"gxwf-light" })

MonacoEditor.vue mount
  └─ no theme prop — workbench picks the theme from user config

Why register themes after initialize

initialize brings up the workbench theme service. Calling registerExtension before that service exists doesn’t crash (it queues), but reading whenReady() and getting deterministic readiness ordering is cleaner if we register after services init. Same ordering is already used for loadGalaxyWorkflowsExtension().

Why register themes before the first editor mount

monaco.editor.create will pick the active theme from user config. If our themes haven’t been contributed yet, the workbench resolves the configured workbench.colorTheme to a missing theme and falls back to a built-in default. To avoid a “flash of vs-dark” between editor mount and theme resolution, we await both loadGxwfThemesExtension() and loadGalaxyWorkflowsExtension() inside MonacoEditor.vue’s onMounted before calling monaco.editor.create. (Same pattern as today’s extension load.)


2. Theme JSON format

VS Code color themes are JSON with this shape:

{
  "$schema": "vscode://schemas/color-theme",
  "name": "Galaxy Workflows Dark",
  "type": "dark",                          // or "light"
  "colors": {                              // ~150 named keys defined here
    "editor.background": "#2c3143",
    "editor.foreground": "#e6e6e7",
    "editorCursor.foreground": "#d0bd2a",
    // ... etc.
  },
  "tokenColors": [                          // TextMate scope rules
    {
      "name": "YAML key",
      "scope": ["entity.name.tag.yaml"],
      "settings": { "foreground": "#d0bd2a", "fontStyle": "bold" }
    },
    // ...
  ],
  "semanticTokenColors": { },               // optional; future
  "semanticHighlighting": true              // optional; future
}

Key surface — chrome (colors)

Minimum set we should explicitly define (others fall back to VS Code defaults for the type). Grouped by area:

Editor surface

Cursor / line numbers

Brackets / matches

Diagnostics (LSP wire-up)

Widgets — hover, suggest, completion

Scrollbar / minimap

Focus / global chrome

Key surface — TextMate (tokenColors)

Workflow files are YAML and JSON. Minimum scope coverage:

YAML scopes (full list from extensions/yaml/syntaxes/yaml.tmLanguage.json shipped with VS Code)

JSON scopes

General fallbacks (inherited by both)

This gives us a complete scheme without leaning on any base theme leaking through.


3. Concrete palette mappings

Both themes share the same brand identity: Hokey Pokey gold for accents, Ebony Clay navy for dark surfaces, Chicago grey for light surfaces. The dark-only / light-only choices come from packages/gxwf-ui/src/styles/galaxy.css and packages/gxwf-ui/src/theme.ts (PrimeVue preset).

Source palette tokens (from galaxy.css)

TokenHex
--gx-gold#d0bd2a
--gx-gold-300#e1d36b
--gx-gold-600#a19321
--gx-gold-700#736817
--gx-navy-dark#1a1f2e
--gx-navy#2c3143
--gx-navy-800#3c435c
--gx-navy-700#4c5574
--gx-grey-50#f5f5f6
--gx-grey-100#e6e6e7
--gx-grey-200#d0d0d1
--gx-grey-300#afafb1
--gx-grey-400#878789
--gx-grey-500#6c6c6e
--gx-grey-600#58585a
--gx-grey-700#4f4e50
--gx-blue-700#387dba
--gx-blue-800#2e689a

gxwf-dark.json — chrome key mappings

VS Code keyValueSource token
editor.background#2c3143--gx-navy
editor.foreground#e6e6e7--gx-grey-100
editor.lineHighlightBackground#3c435c--gx-navy-800
editor.selectionBackground#d0bd2a44--gx-gold @ ~27% alpha
editor.inactiveSelectionBackground#d0bd2a22--gx-gold @ ~13% alpha
editorCursor.foreground#d0bd2a--gx-gold
editorLineNumber.foreground#6c6c6e--gx-grey-500
editorLineNumber.activeForeground#d0bd2a--gx-gold
editorIndentGuide.background1#4c5574--gx-navy-700
editorIndentGuide.activeBackground1#d0bd2a--gx-gold
editorWhitespace.foreground#4c5574--gx-navy-700
editorBracketMatch.background#d0bd2a33--gx-gold @ alpha
editorBracketMatch.border#d0bd2a--gx-gold
editorWidget.background#1a1f2e--gx-navy-dark
editorWidget.border#4c5574--gx-navy-700
editorHoverWidget.background#1a1f2e--gx-navy-dark
editorHoverWidget.border#d0bd2a--gx-gold
editorSuggestWidget.background#1a1f2e--gx-navy-dark
editorSuggestWidget.foreground#e6e6e7--gx-grey-100
editorSuggestWidget.selectedBackground#4c5574--gx-navy-700
editorSuggestWidget.highlightForeground#d0bd2a--gx-gold
editorError.foreground#cd3131(red, VS Code convention)
editorWarning.foreground#d0bd2a--gx-gold (gold doubles as warning)
editorInfo.foreground#387dba--gx-blue-700
focusBorder#d0bd2a--gx-gold
scrollbarSlider.background#3c435c99--gx-navy-800 @ alpha
scrollbarSlider.hoverBackground#4c5574cc--gx-navy-700 @ alpha
scrollbarSlider.activeBackground#d0bd2a--gx-gold
foreground#e6e6e7--gx-grey-100
errorForeground#cd3131red

gxwf-light.json — chrome key mappings

Light surface, gold accents, navy text. Selection alpha needs to be slightly stronger (gold on cream is lower-contrast than gold on navy).

VS Code keyValueSource token
editor.background#f5f5f6--gx-grey-50
editor.foreground#2c3143--gx-navy
editor.lineHighlightBackground#e6e6e7--gx-grey-100
editor.selectionBackground#d0bd2a55--gx-gold @ ~33% alpha
editor.inactiveSelectionBackground#d0bd2a33--gx-gold @ ~20% alpha
editorCursor.foreground#736817--gx-gold-700 (deeper for light bg)
editorLineNumber.foreground#878789--gx-grey-400
editorLineNumber.activeForeground#736817--gx-gold-700
editorIndentGuide.background1#d0d0d1--gx-grey-200
editorIndentGuide.activeBackground1#a19321--gx-gold-600
editorWhitespace.foreground#d0d0d1--gx-grey-200
editorBracketMatch.background#d0bd2a44--gx-gold @ alpha
editorBracketMatch.border#a19321--gx-gold-600
editorWidget.background#ffffffwhite
editorWidget.border#d0d0d1--gx-grey-200
editorHoverWidget.background#ffffffwhite
editorHoverWidget.border#a19321--gx-gold-600
editorSuggestWidget.background#ffffffwhite
editorSuggestWidget.foreground#2c3143--gx-navy
editorSuggestWidget.selectedBackground#e6e6e7--gx-grey-100
editorSuggestWidget.highlightForeground#736817--gx-gold-700
editorError.foreground#cd3131red
editorWarning.foreground#a19321--gx-gold-600
editorInfo.foreground#2e689a--gx-blue-800
focusBorder#a19321--gx-gold-600
scrollbarSlider.background#d0d0d199--gx-grey-200 @ alpha
scrollbarSlider.hoverBackground#afafb1cc--gx-grey-300 @ alpha
scrollbarSlider.activeBackground#a19321--gx-gold-600
foreground#2c3143--gx-navy
errorForeground#cd3131red

tokenColors for both themes

Same scope rules; different absolute hex values.

Scope groupDark colorLight colorStyle
entity.name.tag.yaml, support.type.property-name.json (keys)#d0bd2a#736817bold
string.*#e1d36b#a19321normal
constant.numeric.*#387dba#2e689anormal
constant.language.* (true/false/null)#387dba#2e689anormal
comment#6c6c6e#878789italic
entity.other.attribute-name.alias.yaml (anchors)#e1d36b#a19321normal
keyword.control.flow.alias.yaml (refs)#e1d36b#a19321normal
entity.name.directive.yaml#878789#878789normal
invalid#cd3131#cd3131normal

4. Implementation phases

Each phase has its own changeset entry candidate, and each test step is red-first — write the test, run it, watch it fail in the expected way, then implement until it passes.

Phase A — Author theme JSONs (no behavior change yet)

A1. Create packages/gxwf-ui/src/editor/themes/gxwf-dark.json per §3. A2. Create packages/gxwf-ui/src/editor/themes/gxwf-light.json per §3. A3. Add a vitest snapshot test (or simple structural assertion) at packages/gxwf-ui/test/themes.test.ts that:

Phase B — Synthetic theme extension (still no UI change)

B1. Create packages/gxwf-ui/src/editor/themesExtension.ts:

import {
  registerExtension,
  ExtensionHostKind,
  type IExtensionManifest,
  type RegisterLocalExtensionResult,
} from "@codingame/monaco-vscode-api/extensions";
import gxwfDarkUrl from "./themes/gxwf-dark.json?url";
import gxwfLightUrl from "./themes/gxwf-light.json?url";

const EXTENSION_PATH = "/gxwf-themes";
let loaded: Promise<RegisterLocalExtensionResult> | null = null;

const manifest: IExtensionManifest = {
  name: "gxwf-themes",
  publisher: "galaxyproject",
  version: "0.0.0",
  engines: { vscode: "*" },
  contributes: {
    themes: [
      { id: "gxwf-dark",  label: "Galaxy Workflows Dark",  uiTheme: "vs-dark", path: "./themes/gxwf-dark.json" },
      { id: "gxwf-light", label: "Galaxy Workflows Light", uiTheme: "vs",      path: "./themes/gxwf-light.json" },
    ],
  },
};

export function loadGxwfThemesExtension(): Promise<RegisterLocalExtensionResult> {
  if (loaded) return loaded;
  loaded = (async () => {
    const result = registerExtension(manifest, ExtensionHostKind.LocalWebWorker, {
      path: EXTENSION_PATH,
    });
    result.registerFileUrl("themes/gxwf-dark.json", gxwfDarkUrl);
    result.registerFileUrl("themes/gxwf-light.json", gxwfLightUrl);
    await result.whenReady();
    return result;
  })();
  return loaded;
}

B2. Wire into services.ts — call await loadGxwfThemesExtension() after await initialize(...) resolves. (Decision: keep the call inside initMonacoServices, not inside MonacoEditor.vue, to mirror how initialize itself is process-singleton.) B3. Vite asset handling — ?url imports from JSON files should “just work” with Vite’s asset graph; the JSON is fingerprinted, copied to the build output, and the ?url returns the runtime URL. Verify by running pnpm --filter gxwf-ui build and inspecting dist/ for the hashed JSON. B4. Fallback if ?url doesn’t resolve: copy themes into packages/gxwf-ui/public/gxwf-themes/ at build time (vite-plugin-static-copy) and use absolute URLs /gxwf-themes/gxwf-dark.json. Decide based on B3 outcome.

Phase C — Initial theme selection from boot state

C1. Add helper in services.ts:

function initialColorThemeId(): string {
  // Boot before App.vue script runs is possible (services init can race); fall
  // back to dark if class isn't present yet but localStorage indicates dark.
  if (typeof document !== "undefined" && document.documentElement.classList.contains("dark")) {
    return "gxwf-dark";
  }
  try {
    if (typeof localStorage !== "undefined" && localStorage.getItem("gxwf-dark") === "1") {
      return "gxwf-dark";
    }
  } catch {
    // localStorage unavailable in some test envs; ignore.
  }
  return "gxwf-light";
}

Note: the localStorage fallback is the only place we touch the storage key; runtime switching uses the dark class as truth (Q4). C2. Add to buildUserConfigJson:

"workbench.colorTheme": initialColorThemeId(),

C3. Delete the spread of gxwfThemeCustomizations() and the import { gxwfThemeCustomizations } from "./theme" line. C4. Delete packages/gxwf-ui/src/editor/theme.ts. C5. MonacoEditor.vue: remove the theme prop and its watcher; remove the comment block introduced for the prop. C6. make check — confirm typecheck/lint clean.

Phase D — Reactive runtime switching

D1. Create packages/gxwf-ui/src/editor/themeSync.ts:

import { updateUserConfiguration } from "@codingame/monaco-vscode-configuration-service-override";

let installed = false;

function currentThemeId(): "gxwf-dark" | "gxwf-light" {
  return document.documentElement.classList.contains("dark") ? "gxwf-dark" : "gxwf-light";
}

export function installThemeSync(): void {
  if (installed) return;
  installed = true;
  // Push initial value (in case the dark class flipped between
  // initUserConfiguration and now — App.vue script and services init can race).
  void updateUserConfiguration(JSON.stringify({ "workbench.colorTheme": currentThemeId() }));
  const observer = new MutationObserver(() => {
    void updateUserConfiguration(JSON.stringify({ "workbench.colorTheme": currentThemeId() }));
  });
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
  // Note: no dispose. This is process-global by design — same lifecycle as
  // services init. Multiple MonacoEditor mount/unmount cycles share one
  // observer so we don't leak handlers per editor instance.
}

D2. Call installThemeSync() from services.ts initMonacoServices() after loadGxwfThemesExtension() resolves. D3. Confirm updateUserConfiguration is exported from the configuration-service-override package — already imported by services.ts consumers, but worth a grep.

Phase E — Tests (Playwright + structural)

E1. Update packages/gxwf-e2e/tests/monaco-css-scoping.spec.ts:

E2. Run E2E with MONACO_ENABLED=1:

cd packages/gxwf-e2e && MONACO_ENABLED=1 pnpm playwright test monaco-css-scoping.spec.ts

Each new test should be authored to fail first (e.g. write the test before the corresponding wiring step in C/D), then pass after the wiring lands. Order:

E3. Re-run the inventory script (packages/gxwf-e2e/scripts/inventory-monaco-css.mjs or however it’s named) and update packages/gxwf-e2e/.inventory/REPORT.md. Confirm Dashboard probes still report “no changes” — themes are scoped to the editor surface and shouldn’t leak.

E4. packages/gxwf-ui/test/themes.test.ts — structural test from A3, run with pnpm --filter gxwf-ui test.

Phase F — Plan + docs + changeset

F1. VS_CODE_MONACO_FIRST_PLAN_V2.md:

---
"@galaxy-tool-util/gxwf-ui": minor
---

Brand the embedded Monaco editor with first-class `gxwf-dark` and `gxwf-light`
color themes, contributed via a synthetic theme extension. The active theme
tracks the app's dark-mode toggle in real time via the workbench
configuration service. Replaces the prior decorative `workbench.colorCustomizations`
approach with full VS Code theme JSON files (chrome + TextMate token rules).

Bump level: this is a noticeable user-facing behavior change (light mode added, default theme changed) — leaning minor rather than patch. Final call before committing.

Phase G — Verification before merge

G1. make check clean. G2. make test clean (new vitest themes.test.ts passes). G3. MONACO_ENABLED=1 pnpm --filter @galaxy-tool-util/gxwf-e2e playwright test monaco-css-scoping.spec.ts clean. G4. Spot-check by hand:


5. Risk register

RiskLikelihoodImpactMitigation
?url import for JSON files doesn’t resolve in Vite asset graphMedLowPhase B3 verifies; B4 documents the public/ fallback.
result.whenReady() resolves before themes are actually queryableLowMedVerified by inspection of colorThemeData.js — themes register synchronously into the workbench theme registry during extension activation. If we hit this, push initial workbench.colorTheme set into a follow-up updateUserConfiguration after whenReady().
MutationObserver fires before themes are loaded (race between App.vue toggling dark class on mount and our async theme registration)LowLowthemeSync.installThemeSync() is called only after loadGxwfThemesExtension() awaits whenReady(). If a toggle fires earlier, the initial workbench.colorTheme we set in user config catches it.
data-vscode-theme-kind attribute in E1 test isn’t actually how the workbench signals theme changeMedLowVerify in source before writing the test; fall back to polling computed background.
Brand drift between galaxy.css --gx-* tokens and the JSON themesMed over timeMedTokens are pinned at theme-author time; on future palette refresh, regenerate from the same source-of-truth list. Could automate with a small build script in a follow-up if drift becomes a real issue.
Selection alpha values look wrong on real text under different fonts/zoomLowLowTune by hand during G4. The current values (44/55 hex alpha for dark/light) are starting points based on contrast intuition.
Light-mode contrast for token colors (gold on cream) fails accessibilityMedMedThe gold on cream is WCAG borderline for body text but acceptable for short YAML keys; deepened the gold to #736817 (gold-700) for light-mode keys to compensate. Verify by hand; adjust if too washed out.
Existing scoping test breaks because theme contributions add stylesheetsLowLowInventory regen (E3) is the check. Themes contribute via TextMate token CSS classes (.mtk*), which already appear in pre-overhaul inventory; new theme JSON shouldn’t change the set of stylesheets, only their content.

6. Open questions to answer mid-implementation

(Answered or marked “decide in the moment”.)


7. Estimated effort

Total: ~3.5–4 hours of focused work, in a single sitting.


8. Out of scope (future work)