diff --git a/src/config/index.ts b/src/config/index.ts index cf6cdeb59..1fe735d7b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -688,6 +688,8 @@ export interface UnconfiguredConfig { command: "tui" | "exec"; /** Exec-only chosen primary. Omitted on the unconfigured path too. */ director?: DirectorId; + /** Validated appearance preference needed before first-run surfaces mount. */ + theme?: Settings["theme"]; // Path where the onboarding flow should write the new settings. globalSettingsPath: string; /** Original CLI path, present only when --config selected the write target. */ @@ -1119,6 +1121,9 @@ export async function loadConfig( auto, command, ...(director !== undefined ? { director } : {}), + ...(settingsForResolution?.theme !== undefined + ? { theme: settingsForResolution.theme } + : {}), globalSettingsPath: effectiveSettingsPath, ...(configPath !== undefined ? { cliConfigPath: configPath } : {}), programmaticSettingsPath: options.globalSettingsPath !== undefined, diff --git a/src/config/settings.test.ts b/src/config/settings.test.ts index 44394c705..f5157617e 100644 --- a/src/config/settings.test.ts +++ b/src/config/settings.test.ts @@ -369,6 +369,7 @@ test("loadSettings cannot silently drop a known optional key", async () => { favoriteModels: [{ provider: "p", model: "m" }], dangerouslySkipPermissions: true, anthropicCachePrompt: true, + theme: "dark" as const, }; await writeFile(globalPath, JSON.stringify(fixture)); const loaded = await loadSettings(globalPath); diff --git a/src/config/settings.ts b/src/config/settings.ts index f80ae1a59..bf742c575 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -186,6 +186,9 @@ export interface Settings { // results on the outgoing prompt only. Default off: sessions do not depend // on that shrink. anthropicCachePrompt?: boolean; + // Terminal palette selection (CL-8993). "auto" (default when unset) follows + // the terminal/OS detection chain; "light"/"dark" pin the palette. + theme?: "auto" | "light" | "dark"; } function modelRefKey(ref: ModelRef): string { @@ -599,6 +602,7 @@ const SettingsSchema = type({ "showPromptCost?": "boolean", "dangerouslySkipPermissions?": "boolean", "anthropicCachePrompt?": "boolean", + "theme?": "'auto' | 'light' | 'dark'", }); // Per-entry MCP shape without the name key. The "exactly one transport" rule is @@ -803,6 +807,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "favoriteModels", "dangerouslySkipPermissions", "anthropicCachePrompt", + "theme", ] as const satisfies readonly (keyof OptionalSettingsFields)[]; /** Optional local settings keys the load path is required to consider. */ @@ -974,6 +979,10 @@ function normalizeParsedSettings(path: string, parsed: unknown): Settings { s.anthropicCachePrompt !== undefined ? Boolean(s.anthropicCachePrompt) : undefined, + theme: + s.theme === "light" || s.theme === "dark" || s.theme === "auto" + ? s.theme + : undefined, }; return { providers: s.providers as Settings["providers"], diff --git a/src/index.ts b/src/index.ts index 142a75b00..d48fe2aa5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { import { runExec } from "./exec/runner.js"; import { runOnboarding } from "./tui/onboarding.js"; import { runTUI } from "./tui/runner/index.js"; +import { applyStartupTheme } from "./tui/theme-startup.js"; export interface Runners { runTUI: (config: import("./config/index.js").Config) => Promise; @@ -91,6 +92,12 @@ export async function mainWithRunners( } let exitCode: number; + // Welcome, setup, and the product host read `UI` at construction time. + if (config.command === "tui") { + applyStartupTheme( + config.configured ? config.settings?.theme : config.theme, + ); + } if (!config.configured) { if (config.command === "exec") { // Exec needs a provider; onboarding is TUI-only. Fail closed with a diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index ddff77598..c21f57916 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -19,6 +19,7 @@ import { type SettingsSnapshot, } from "./command-surfaces"; import type { KeyEvent } from "@opentui/core"; +import type { ThemeSetting } from "./theme-detect.js"; import { focusOwner } from "./focus/index.js"; import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js"; @@ -43,6 +44,7 @@ function baseSnapshot(): SettingsSnapshot { waitForApproval: true, telemetryEnabled: false, showPromptCost: false, + theme: "auto", }; } @@ -133,6 +135,7 @@ function settingsDeps(overrides?: Partial): { waitForApproval: boolean[]; telemetry: boolean[]; showPromptCost: boolean[]; + theme: ThemeSetting[]; }; } { let state: SettingsSnapshot = { ...baseSnapshot(), ...overrides }; @@ -140,6 +143,7 @@ function settingsDeps(overrides?: Partial): { waitForApproval: [] as boolean[], telemetry: [] as boolean[], showPromptCost: [] as boolean[], + theme: [] as ThemeSetting[], }; const deps: CommandSurfaceDeps = { notify: () => undefined, @@ -157,6 +161,10 @@ function settingsDeps(overrides?: Partial): { calls.showPromptCost.push(value); state = { ...state, showPromptCost: value }; }, + setTheme: (value) => { + calls.theme.push(value); + state = { ...state, theme: value }; + }, }, }; return { deps, snapshot: () => state, calls }; @@ -217,6 +225,42 @@ describe("settings surface", () => { }); }); + test("left/right cycles the theme pin through auto, dark, light", async () => { + await withShell(async (shell) => { + const { deps, calls, snapshot } = settingsDeps(); + openCommandSurface(shell, "settings", deps); + await flushSurface(); + + // approval wait, telemetry, show cost, theme + moveOverlaySelection(shell, 3); + cycleOverlaySelection(shell, 1); + await flushSurface(); + expect(calls.theme).toEqual(["dark"]); + expect(snapshot().theme).toBe("dark"); + expect(shell.overlayItems.some((l) => l.includes("‹ dark ›"))).toBe(true); + + cycleOverlaySelection(shell, 1); + await flushSurface(); + expect(calls.theme).toEqual(["dark", "light"]); + expect(snapshot().theme).toBe("light"); + expect(shell.overlayItems.some((l) => l.includes("‹ light ›"))).toBe( + true, + ); + + // Wraps around: past light comes auto again. + cycleOverlaySelection(shell, 1); + await flushSurface(); + expect(calls.theme).toEqual(["dark", "light", "auto"]); + expect(snapshot().theme).toBe("auto"); + + // And stepping back from auto wraps the other way, to light. + cycleOverlaySelection(shell, -1); + await flushSurface(); + expect(calls.theme).toEqual(["dark", "light", "auto", "light"]); + expect(snapshot().theme).toBe("light"); + }); + }); + test("arrow navigation still moves the cursor in a non-cycling overlay", async () => { await withShell(async (shell) => { const deps: CommandSurfaceDeps = { diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 5833eb1cc..3248ea88a 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -39,6 +39,7 @@ import { setOwnedOverlayItems, } from "./shell/overlay-host.js"; import { openHelpOverlay, openSettingsOverlay } from "./shell/palette.js"; +import type { ThemeSetting } from "./theme-detect.js"; /** A remembered approval, flattened for display and revocation by id. */ export interface GrantEntry { @@ -104,6 +105,7 @@ export interface SettingsSnapshot { readonly waitForApproval: boolean; readonly telemetryEnabled: boolean; readonly showPromptCost: boolean; + readonly theme: ThemeSetting; } export interface PermissionsSurfaceDeps { @@ -216,6 +218,8 @@ export interface SettingsSurfaceDeps { readonly setWaitForApproval: (value: boolean) => void; readonly setTelemetryEnabled: (value: boolean) => void; readonly setShowPromptCost: (value: boolean) => void; + /** Pins the terminal palette (auto follows terminal/OS detection). */ + readonly setTheme: (value: ThemeSetting) => void; /** Live counts for the hooks row summary. Omitted while hooks discovery is unbuilt. */ readonly hooksSummary?: () => HooksSurfaceSummary; /** Opens the hooks surface. Omitted while it is unbuilt (row still shows, Enter no-ops). */ @@ -423,6 +427,19 @@ const ON_OFF_OPTIONS: readonly CycleOption<"on" | "off">[] = [ { id: "on", label: "on" }, { id: "off", label: "off" }, ]; +const THEME_OPTIONS: readonly CycleOption[] = [ + { id: "auto", label: "auto" }, + { id: "dark", label: "dark" }, + { id: "light", label: "light" }, +]; + +/** Step the theme pin forward or back through auto → dark → light. */ +function stepTheme(current: ThemeSetting, direction: -1 | 1): ThemeSetting { + const index = THEME_OPTIONS.findIndex((o) => o.id === current); + const next = + (index + direction + THEME_OPTIONS.length) % THEME_OPTIONS.length; + return THEME_OPTIONS[next]?.id ?? "auto"; +} const SETTINGS_NAME_WIDTH = 16; /** One inline-cycled settings row: label, live value, description, and its cycle step. */ @@ -485,6 +502,18 @@ function settingsCycleRows( }, cycle: () => settings.setShowPromptCost(!snapshot.showPromptCost), }, + { + id: "theme", + value: `${"theme".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(THEME_OPTIONS, snapshot.theme)}`, + chosenLabel: activeOptionLabel(THEME_OPTIONS, snapshot.theme), + describe: { + what: "which terminal palette the shell paints with.", + impact: + "applies immediately — auto follows the terminal and OS appearance; dark or light pins the palette instead.", + }, + cycle: (direction) => + settings.setTheme(stepTheme(snapshot.theme, direction)), + }, ]; } diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 059f644d5..d99f298ca 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -310,10 +310,12 @@ describe("mountRunnerHost command surfaces", () => { waitForApproval: true, telemetryEnabled: false, showPromptCost: false, + theme: "auto", }), setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, + setTheme: () => undefined, }, }, }, diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index b19fe287a..4b49ea18a 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -36,11 +36,13 @@ import { wireMcp } from "./mcp.js"; import { wirePostStartup } from "./wiring.js"; import { createRunnerState, liveAgent } from "./state.js"; import { applyCredentialRecoverySelection } from "./credential-recovery.js"; +import { applyStartupTheme } from "../theme-startup.js"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; export async function runTUI(initialConfig: Config): Promise { const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + applyStartupTheme(initialConfig.settings?.theme); const start = await prepareTUISession(initialConfig, liveTelemetry); if (start === null) return 0; const state = createRunnerState(start); diff --git a/src/tui/runner/settings-theme.test.ts b/src/tui/runner/settings-theme.test.ts new file mode 100644 index 000000000..0e53080e3 --- /dev/null +++ b/src/tui/runner/settings-theme.test.ts @@ -0,0 +1,139 @@ +/** + * Settings-pin live-apply (CL-8993): cycling the theme pin repaints the shell + * at once — it never records-and-waits-for-relaunch. Pins resolve exactly as + * startup does; explicit pins are deterministic regardless of terminal state, + * so these tests never touch `process.env` or spawn an OS probe. + * + * Every assertion below reads painted content or the live style registry — + * never a repaint counter. The pin cycle runs with no cost movement at all, + * so a meter-gated repaint (or a stale cached SyntaxStyle) leaves the old + * palette on screen and fails. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { RGBA, type CapturedSpan } from "@opentui/core"; + +import { corbitsDark, corbitsLight, setTheme, UI } from "../theme.js"; +import { transcriptSyntaxStyle } from "../stream.js"; +import { withTestRenderer, type Harness } from "../harness.js"; +import { appendStreamRow } from "../shell/chrome.js"; +import { createAppShell } from "../shell/index.js"; +import { applyThemePinLive, repaintShellForTheme } from "./settings.js"; + +afterEach(() => { + setTheme("corbits-dark"); +}); + +/** Live heading colour in the transcript registry. */ +function registryHeadingFg(): RGBA | undefined { + return transcriptSyntaxStyle().getAllStyles().get("markup.heading.1")?.fg; +} + +function painted(spans: CapturedSpan[], text: string, fg: RGBA): boolean { + return spans.some((s) => s.text.includes(text) && s.fg.equals(fg)); +} + +async function settleSpans( + h: Harness, + ready: (spans: CapturedSpan[]) => boolean, +): Promise { + let spans = h.captureSpans().lines.flatMap((line) => line.spans); + for (let i = 0; i < 400 && !ready(spans); i++) { + await h.renderOnce(); + await new Promise((resolve) => setTimeout(resolve, 25)); + spans = h.captureSpans().lines.flatMap((line) => line.spans); + } + return spans; +} + +describe("applyThemePinLive", () => { + test("a light pin swaps the binding, rebuilds the registry, and repaints", async () => { + await withTestRenderer( + async (h) => { + setTheme("corbits-dark"); + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 40 }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { role: "assistant", text: "# pin cycle" }); + const darkHeading = RGBA.fromHex(corbitsDark.heading); + const lightHeading = RGBA.fromHex(corbitsLight.heading); + const darkGround = UI.ground; + const before = await settleSpans(h, (spans) => + painted(spans, "pin cycle", darkHeading), + ); + expect(painted(before, "pin cycle", darkHeading)).toBe(true); + expect(registryHeadingFg()?.equals(darkHeading)).toBe(true); + + // No cost movement anywhere in this cycle: the meter never moves, + // so only an unconditional repaint repaints anything. + const applied = applyThemePinLive("light", () => + repaintShellForTheme(shell), + ); + expect(applied).toBe("corbits-light"); + expect(UI.name).toBe("corbits-light"); + expect(UI.ground).not.toBe(darkGround); + expect(registryHeadingFg()?.equals(lightHeading)).toBe(true); + + const after = await settleSpans(h, (spans) => + painted(spans, "pin cycle", lightHeading), + ); + expect(painted(after, "pin cycle", lightHeading)).toBe(true); + expect(painted(after, "pin cycle", darkHeading)).toBe(false); + } finally { + shell.dispose(); + setTheme("corbits-dark"); + } + }, + { width: 80, height: 40 }, + ); + }, 30000); + + test("pinning back to dark restores dark content", async () => { + await withTestRenderer( + async (h) => { + setTheme("corbits-light"); + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 40 }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { role: "assistant", text: "# pin cycle" }); + const darkHeading = RGBA.fromHex(corbitsDark.heading); + const lightHeading = RGBA.fromHex(corbitsLight.heading); + const before = await settleSpans(h, (spans) => + painted(spans, "pin cycle", lightHeading), + ); + expect(painted(before, "pin cycle", lightHeading)).toBe(true); + expect(registryHeadingFg()?.equals(lightHeading)).toBe(true); + + const applied = applyThemePinLive("dark", () => + repaintShellForTheme(shell), + ); + expect(applied).toBe("corbits-dark"); + expect(UI.name).toBe("corbits-dark"); + expect(registryHeadingFg()?.equals(darkHeading)).toBe(true); + + const after = await settleSpans(h, (spans) => + painted(spans, "pin cycle", darkHeading), + ); + expect(painted(after, "pin cycle", darkHeading)).toBe(true); + expect(painted(after, "pin cycle", lightHeading)).toBe(false); + } finally { + shell.dispose(); + setTheme("corbits-dark"); + } + }, + { width: 80, height: 40 }, + ); + }, 30000); + + test("auto re-resolves through the startup path and rebuilds the registry", () => { + setTheme("corbits-dark"); + const applied = applyThemePinLive("auto", () => undefined); + expect(["corbits-dark", "corbits-light"]).toContain(applied); + expect(UI.name).toBe(applied); + expect(registryHeadingFg()?.equals(RGBA.fromHex(UI.heading))).toBe(true); + }); +}); diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index f814a8ab4..5c3243163 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -43,7 +43,16 @@ import { import { isOpenCodeGoProvider } from "../../../packages/opencode-go/src/index.js"; import { isZenProvider } from "../../../packages/zen/src/index.js"; import { applyLiveModelSwitch } from "../../session/live-model-switch.js"; -import { applyFocus } from "../shell/chrome.js"; +import type { ThemeSetting } from "../theme-detect.js"; +import { applyStartupTheme } from "../theme-startup.js"; +import type { ThemeName } from "../theme.js"; +import { + applyFocus, + paintChrome, + paintPromptBorder, + repaintTranscriptWindow, +} from "../shell/chrome.js"; +import type { AppShell } from "../shell/internals.js"; import { setShellInputSuspended } from "../shell/prompt.js"; import { warningsForPluginEntry } from "../../plugins/diagnostics.js"; import { isPluginEnabledForSurface } from "../plugin-surface.js"; @@ -633,6 +642,37 @@ function createHooksSurface( }; } +/** + * Live-apply a theme pin: resolve it exactly as startup does (explicit pins + * win, `auto` re-detects) and swap the shared `UI` binding, then repaint. + * The swap is synchronous — an instant rebinding, never an animated + * transition. Returns the resolved palette name so callers can report the + * outcome the pin settled on. Exported for tests; the settings surface is + * the only caller. + */ +export function applyThemePinLive( + value: ThemeSetting, + repaint: () => void, +): ThemeName { + const applied = applyStartupTheme(value); + repaint(); + return applied; +} + +/** + * Unconditional post-pin repaint for the settings surface. The cost/context + * meter path (`refreshCostContext` → `setPromptCostContext`) skips painting + * when the meter did not move — the common pin-cycle case — so cycling pins + * with no cost movement repainted nothing behind the overlay. Force the + * chrome, repaint the border, and rebuild the transcript rows so markdown + * bodies pick up the fresh SyntaxStyle registry. Synchronous, like the swap. + */ +export function repaintShellForTheme(shell: AppShell): void { + paintChrome(shell, { force: true }); + paintPromptBorder(shell); + repaintTranscriptWindow(shell); +} + function createSettingsSurface( state: RunnerState, services: RunnerServices, @@ -647,6 +687,7 @@ function createSettingsSurface( waitForApproval: resolveWaitForApproval(services.liveToolWatchdog), telemetryEnabled: state.liveTelemetryIntent, showPromptCost: state.liveShowPromptCost, + theme: state.liveTheme, }), setWaitForApproval: (value: boolean) => { services.liveToolWatchdog.waitForApproval = value; @@ -675,6 +716,19 @@ function createSettingsSurface( showPromptCost: value, })); }, + setTheme: (value: ThemeSetting) => { + state.liveTheme = value; + // The pin used to record-and-persist only, leaving the old palette on + // screen until relaunch. Resolve it the same way startup does and paint + // now: the swap is an instant rebinding of the shared UI object, never + // an animated transition. The repaint is unconditional — the meter path + // would skip it whenever the cost context did not move. + applyThemePinLive(value, () => repaintShellForTheme(hostOf(state).shell)); + void persistGlobalSettings("theme", (base) => ({ + ...base, + theme: value, + })); + }, hooksSummary: () => { const statuses = services.hookManager.getStatuses(); return { diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index fceb9e526..806a9d77b 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -26,6 +26,7 @@ import { resolveLocalSettingsPath, } from "../../config/settings.js"; import { resolveLiveSessionSources } from "../../session/assemble-runtime.js"; +import { resolveThemeSetting, type ThemeSetting } from "../theme-detect.js"; import type { prepareTUISession } from "../session-start.js"; import type { PersistMCPServerListResult } from "../../mcp/add-server.js"; import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; @@ -249,6 +250,10 @@ export interface RunnerState { // On during the first-run hold. liveTelemetryIntent: boolean; liveShowPromptCost: boolean; + // The theme pin the settings surface cycles (auto/dark/light). Cycling it + // live-applies the winning palette the same way startup does; this only + // records the pin so the row reads the choice back. + liveTheme: ThemeSetting; // The permissions surface addresses grants by their position in the last // listing, so revoke resolves against the same snapshot the operator saw. listedGrants: readonly ScopedApproval[]; @@ -405,6 +410,7 @@ export function createRunnerState(start: TUIStart): RunnerState { liveHookConfig: { ...(config.settings?.hooks ?? {}) }, liveTelemetryIntent: false, liveShowPromptCost: config.settings?.showPromptCost ?? false, + liveTheme: resolveThemeSetting(config.settings?.theme), listedGrants: [], host: undefined, stampProvider: { fn: undefined }, diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts index 7016d50fc..f7f80d9bb 100644 --- a/src/tui/shell/prompt.ts +++ b/src/tui/shell/prompt.ts @@ -35,7 +35,7 @@ import { enqueueSteer, interrupt, } from "../delivery-queue.js"; -import { UI } from "../theme.js"; +import { UI, onThemeChange } from "../theme.js"; import { type AppShell, @@ -184,6 +184,16 @@ let cachedPromptSyntaxStyle: SyntaxStyle | null = null; let cachedPromptRecognizedStyleId: number | null = null; +let promptStyleGeneration = 0; + +const promptHighlightGenerations = new WeakMap(); + +onThemeChange(() => { + cachedPromptSyntaxStyle = null; + cachedPromptRecognizedStyleId = null; + promptStyleGeneration += 1; +}); + /** * The style registry backing the prompt's highlights, plus the one style id * this feature uses. Lazy for the same reason as `transcriptSyntaxStyle`: @@ -214,8 +224,13 @@ export function syncPromptHighlights(shell: AppShell): void { const source = shellRecognitionSource.get(shell); if (source === undefined) return; const value = shell.prompt.value; - if (promptHighlightedValue.get(shell) === value) return; + if ( + promptHighlightedValue.get(shell) === value && + promptHighlightGenerations.get(shell) === promptStyleGeneration + ) + return; promptHighlightedValue.set(shell, value); + promptHighlightGenerations.set(shell, promptStyleGeneration); const styleId = promptRecognizedStyleId(); shell.prompt.syntaxStyle = cachedPromptSyntaxStyle; diff --git a/src/tui/slash-popup-gate.test.ts b/src/tui/slash-popup-gate.test.ts index 521fcdbff..20bc659c8 100644 --- a/src/tui/slash-popup-gate.test.ts +++ b/src/tui/slash-popup-gate.test.ts @@ -165,10 +165,12 @@ function settingsOnCommand( waitForApproval: true, telemetryEnabled: false, showPromptCost: false, + theme: "auto", }), setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, + setTheme: () => undefined, }, permissions: { list: () => list, @@ -720,10 +722,12 @@ describe("overlay host occupancy and opt-in deferral", () => { waitForApproval: true, telemetryEnabled: false, showPromptCost: false, + theme: "auto", }), setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, + setTheme: () => undefined, }, permissions: { list: () => hanging.list, @@ -878,10 +882,12 @@ describe("overlay host occupancy and opt-in deferral", () => { waitForApproval: true, telemetryEnabled: false, showPromptCost: false, + theme: "auto", }), setWaitForApproval: () => undefined, setTelemetryEnabled: () => undefined, setShowPromptCost: () => undefined, + setTheme: () => undefined, }, permissions: { list: () => { diff --git a/src/tui/stream.ts b/src/tui/stream.ts index eac09023f..152962fb7 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -13,7 +13,7 @@ import { thinkingSettledLine, type Thought, } from "./thinking.js"; -import { UI } from "./theme.js"; +import { UI, onThemeChange } from "./theme.js"; import { pastTenseToolLabel } from "./tool-formatter.js"; /** @@ -953,47 +953,57 @@ export function streamRowGutter( * markdown rows read as the same product skin as plain rows. * Native scope names: `markup.*` for markdown, the rest for fenced-code * syntax highlighting. + * + * A function of the live `UI` binding, never a module snapshot: the values + * are read when a fresh registry is built, so a theme pin rebuilds them. */ -const MARKDOWN_STYLES = { - default: { fg: UI.text }, - conceal: { fg: UI.textFaint, dim: true }, - // Tree-sitter markdown tags headings by level, and SyntaxStyle matches whole - // scope names, so the unnumbered scope alone would never be hit. - "markup.heading": { fg: UI.heading, bold: true }, - "markup.heading.1": { fg: UI.heading, bold: true }, - "markup.heading.2": { fg: UI.heading, bold: true }, - "markup.heading.3": { fg: UI.heading, bold: true }, - "markup.heading.4": { fg: UI.heading, bold: true }, - "markup.heading.5": { fg: UI.heading, bold: true }, - "markup.heading.6": { fg: UI.heading, bold: true }, - "markup.strong": { fg: UI.text, bold: true }, - "markup.italic": { fg: UI.text, italic: true }, - "markup.strikethrough": { fg: UI.textFaint }, - "markup.raw": { fg: UI.inFlight }, - "markup.list": { fg: UI.inFlightBright }, - "markup.quote": { fg: UI.textDim, italic: true }, - "markup.link": { fg: UI.inFlightBright }, - "markup.link.label": { fg: UI.inFlightBright }, - "markup.link.url": { fg: UI.inFlightBright }, - keyword: { fg: UI.inFlightBright }, - string: { fg: UI.done }, - number: { fg: UI.done }, - comment: { fg: UI.textFaint, italic: true }, - function: { fg: UI.inFlight }, - type: { fg: UI.inFlightBright }, - variable: { fg: UI.text }, - punctuation: { fg: UI.textDim }, -} as const; +function markdownStyles() { + return { + default: { fg: UI.text }, + conceal: { fg: UI.textFaint, dim: true }, + // Tree-sitter markdown tags headings by level, and SyntaxStyle matches whole + // scope names, so the unnumbered scope alone would never be hit. + "markup.heading": { fg: UI.heading, bold: true }, + "markup.heading.1": { fg: UI.heading, bold: true }, + "markup.heading.2": { fg: UI.heading, bold: true }, + "markup.heading.3": { fg: UI.heading, bold: true }, + "markup.heading.4": { fg: UI.heading, bold: true }, + "markup.heading.5": { fg: UI.heading, bold: true }, + "markup.heading.6": { fg: UI.heading, bold: true }, + "markup.strong": { fg: UI.text, bold: true }, + "markup.italic": { fg: UI.text, italic: true }, + "markup.strikethrough": { fg: UI.textFaint }, + "markup.raw": { fg: UI.inFlight }, + "markup.list": { fg: UI.inFlightBright }, + "markup.quote": { fg: UI.textDim, italic: true }, + "markup.link": { fg: UI.inFlightBright }, + "markup.link.label": { fg: UI.inFlightBright }, + "markup.link.url": { fg: UI.inFlightBright }, + keyword: { fg: UI.inFlightBright }, + string: { fg: UI.done }, + number: { fg: UI.done }, + comment: { fg: UI.textFaint, italic: true }, + function: { fg: UI.inFlight }, + type: { fg: UI.inFlightBright }, + variable: { fg: UI.text }, + punctuation: { fg: UI.textDim }, + }; +} let cachedSyntaxStyle: SyntaxStyle | null = null; /** * Shared transcript SyntaxStyle. Lazy because construction reaches into the - * native render lib, which is unavailable until a renderer exists. + * native render lib, which is unavailable until a renderer exists. Dropped + * by `setTheme`, so the next read rebuilds from the new palette. */ export function transcriptSyntaxStyle(): SyntaxStyle { if (cachedSyntaxStyle === null) { - cachedSyntaxStyle = SyntaxStyle.fromStyles({ ...MARKDOWN_STYLES }); + cachedSyntaxStyle = SyntaxStyle.fromStyles({ ...markdownStyles() }); } return cachedSyntaxStyle; } + +onThemeChange(() => { + cachedSyntaxStyle = null; +}); diff --git a/src/tui/theme-contrast.test.ts b/src/tui/theme-contrast.test.ts new file mode 100644 index 000000000..cd6860932 --- /dev/null +++ b/src/tui/theme-contrast.test.ts @@ -0,0 +1,147 @@ +/** + * Light-palette legibility (CL-8993): every role in `corbitsLight` must hold + * >=4.5:1 against the cream ground, and the warm roles must keep the + * lightness/hue structure the palette header promises. Everything here + * asserts relationships between computed values, never pinned hex, so the + * palette can be re-tuned without rewriting the contract. + */ + +import { describe, expect, test } from "bun:test"; + +import { corbitsDark, corbitsLight, type Theme } from "./theme.js"; + +type Channel = readonly [number, number, number]; + +function channels(hex: string): Channel { + const clean = hex.replace("#", ""); + const at = (i: number): number => parseInt(clean.slice(i, i + 2), 16) / 255; + return [at(0), at(2), at(4)]; +} + +function linearChannel(c: number): number { + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +function luminance(hex: string): number { + const [r, g, b] = channels(hex); + return ( + 0.2126 * linearChannel(r) + + 0.7152 * linearChannel(g) + + 0.0722 * linearChannel(b) + ); +} + +function contrast(foreground: string, background: string): number { + const hi = Math.max(luminance(foreground), luminance(background)); + const lo = Math.min(luminance(foreground), luminance(background)); + return (hi + 0.05) / (lo + 0.05); +} + +function saturation(hex: string): number { + const [r, g, b] = channels(hex); + const mx = Math.max(r, g, b); + const mn = Math.min(r, g, b); + if (mx === mn) return 0; + const light = (mx + mn) / 2; + return light > 0.5 ? (mx - mn) / (2 - mx - mn) : (mx - mn) / (mx + mn); +} + +function hue(hex: string): number { + const [r, g, b] = channels(hex); + const mx = Math.max(r, g, b); + const mn = Math.min(r, g, b); + if (mx === mn) return 0; + const d = mx - mn; + const h = + mx === r + ? ((g - b) / d + (g < b ? 6 : 0)) * 60 + : mx === g + ? ((b - r) / d + 2) * 60 + : ((r - g) / d + 4) * 60; + return h; +} + +/** Every painted role: all Theme keys except the ground it sits on and its name. */ +function paintedRoles(theme: Theme): readonly [string, string][] { + return (Object.entries(theme) as [string, string][]).filter( + ([key, value]) => + key !== "ground" && key !== "name" && value.startsWith("#"), + ); +} + +const onCream = (role: string): number => + contrast(corbitsLight[role as keyof Theme] as string, corbitsLight.ground); + +describe("corbitsLight legibility on cream", () => { + test("every painted role holds >=4.5:1 against the ground", () => { + for (const [role, value] of paintedRoles(corbitsLight)) { + expect( + contrast(value, corbitsLight.ground), + `${role} contrast`, + ).toBeGreaterThanOrEqual(4.5); + } + }); + + test("body text keeps dark-theme parity within a narrow band", () => { + const lightText = contrast(corbitsLight.text, corbitsLight.ground); + const darkText = contrast(corbitsDark.text, corbitsDark.ground); + expect(Math.abs(lightText - darkText)).toBeLessThan(2); + }); + + test("emphasis orderings run text > dim > faint within each ramp", () => { + expect(onCream("text")).toBeGreaterThan(onCream("textDim")); + expect(onCream("textDim")).toBeGreaterThan(onCream("textFaint")); + expect(onCream("action")).toBeGreaterThan(onCream("actionDim")); + expect(onCream("inFlight")).toBeGreaterThan(onCream("inFlightBright")); + }); + + test("each intra-ramp step spans >=1.5:1 so ramps read as ramps", () => { + expect(onCream("action") / onCream("actionDim")).toBeGreaterThanOrEqual( + 1.5, + ); + expect( + onCream("inFlight") / onCream("inFlightBright"), + ).toBeGreaterThanOrEqual(1.5); + expect(onCream("text") / onCream("textDim")).toBeGreaterThanOrEqual(1.5); + }); + + test("the decision marker is the darkest warm on screen", () => { + for (const role of ["actionDim", "inFlight", "inFlightBright", "heading"]) { + expect(onCream("action"), `action vs ${role}`).toBeGreaterThan( + onCream(role), + ); + } + }); + + test("the heading sits in the lightest tier, apart from the dim tier", () => { + expect(onCream("heading")).toBeLessThan(onCream("inFlight")); + expect(onCream("heading")).toBeLessThan(onCream("actionDim")); + }); + + test("same-tier collisions are carried by hue and saturation, not lightness", () => { + expect(saturation(corbitsLight.action)).toBeGreaterThan( + saturation(corbitsLight.heading), + ); + expect(saturation(corbitsLight.heading)).toBeGreaterThan( + saturation(corbitsLight.inFlightBright), + ); + expect(saturation(corbitsLight.actionDim)).toBeGreaterThan( + saturation(corbitsLight.inFlight), + ); + expect( + Math.abs(hue(corbitsLight.actionDim) - hue(corbitsLight.inFlight)), + ).toBeGreaterThanOrEqual(15); + }); +}); + +describe("dark/light role parity", () => { + test("both themes expose the same role keys", () => { + expect(Object.keys(corbitsLight).sort()).toEqual( + Object.keys(corbitsDark).sort(), + ); + }); + + test("both themes paint on their own declared ground", () => { + expect(corbitsLight.ground).not.toBe(corbitsDark.ground); + }); +}); diff --git a/src/tui/theme-detect.test.ts b/src/tui/theme-detect.test.ts new file mode 100644 index 000000000..5296a90a8 --- /dev/null +++ b/src/tui/theme-detect.test.ts @@ -0,0 +1,134 @@ +/** + * Theme detection contract (CL-8993): pin beats terminal sniff beats OS + * appearance beats the dark default; the COLORFGBG table and the OS probe + * map their edges the documented way. Everything here feeds injected inputs + * — no `process.env`, no `process.platform`, no spawned commands — so the + * matrix stays deterministic. Values asserted are resolved palette names and + * precedence outcomes, never internals. + */ + +import { describe, expect, test } from "bun:test"; + +import { + detectOsAppearance, + resolveDetectedTheme, + sniffSyncTheme, +} from "./theme-detect.js"; + +describe("detection precedence pin > COLORFGBG > OS > dark", () => { + test("an explicit pin wins over every signal below it", () => { + expect( + resolveDetectedTheme({ + setting: "dark", + syncEnv: { COLORFGBG: "15;15" }, + os: "corbits-light", + }), + ).toBe("corbits-dark"); + expect( + resolveDetectedTheme({ + setting: "light", + syncEnv: { COLORFGBG: "15;0" }, + os: "corbits-dark", + }), + ).toBe("corbits-light"); + }); + + test("the terminal sniff wins over OS appearance", () => { + expect( + resolveDetectedTheme({ + setting: "auto", + syncEnv: { COLORFGBG: "15;15" }, + os: "corbits-dark", + }), + ).toBe("corbits-light"); + expect( + resolveDetectedTheme({ + setting: "auto", + syncEnv: { COLORFGBG: "15;0" }, + os: "corbits-light", + }), + ).toBe("corbits-dark"); + }); + + test("OS appearance decides when the pin is auto and the sniff abstains", () => { + expect( + resolveDetectedTheme({ + setting: "auto", + syncEnv: {}, + os: "corbits-light", + }), + ).toBe("corbits-light"); + }); + + test("silence everywhere falls back to dark", () => { + expect( + resolveDetectedTheme({ setting: "auto", syncEnv: {}, os: null }), + ).toBe("corbits-dark"); + }); +}); + +describe("COLORFGBG edge table", () => { + test.each([ + ["dark ground 0", "15;0", "corbits-dark"], + ["light ground 7", "15;7", "corbits-light"], + ["dark ground 8", "15;8", "corbits-dark"], + ["bright ground 9", "15;9", "corbits-light"], + ["bright ground 15", "0;15", "corbits-light"], + ["xterm cursor field still reads the middle bg", "0;15;0", "corbits-light"], + ["whitespace around the bg still parses", "15; 7 ", "corbits-light"], + ["default bg abstains, never guesses", "15;default", null], + ["out-of-table bg abstains", "15;16", null], + ["non-numeric bg abstains", "15;red", null], + ["missing fg;bg separator abstains", "15", null], + ["empty value abstains", "", null], + ] as const)("%s", (_label, colorfgbg, expected) => { + expect(sniffSyncTheme({ COLORFGBG: colorfgbg })).toBe(expected); + }); + + test("a missing COLORFGBG abstains", () => { + expect(sniffSyncTheme({})).toBe(null); + }); + + test("TERM_PROGRAM alone never decides", () => { + expect(sniffSyncTheme({ TERM_PROGRAM: "iTerm.app" })).toBe(null); + expect( + sniffSyncTheme({ TERM_PROGRAM: "iTerm.app", COLORFGBG: "15;0" }), + ).toBe("corbits-dark"); + }); +}); + +describe("OS appearance probe", () => { + const runner = (out: string | null | undefined) => () => out; + + test("a missing macOS key means the Light default", () => { + expect(detectOsAppearance("darwin", runner(null))).toBe("corbits-light"); + }); + + test("Dark means dark, anything else means light", () => { + expect(detectOsAppearance("darwin", runner("Dark\n"))).toBe("corbits-dark"); + expect(detectOsAppearance("darwin", runner("dark"))).toBe("corbits-dark"); + expect(detectOsAppearance("darwin", runner(""))).toBe("corbits-light"); + }); + + test("a failed lookup abstains instead of guessing", () => { + expect(detectOsAppearance("darwin", runner(undefined))).toBe(null); + expect( + detectOsAppearance("darwin", () => { + throw new Error("defaults unavailable"); + }), + ).toBe(null); + }); + + test("non-Darwin platforms abstain without probing", () => { + for (const platform of ["linux", "win32", "freebsd"]) { + let probed = false; + expect( + detectOsAppearance(platform, () => { + probed = true; + return "Dark"; + }), + ).toBe(null); + expect(probed).toBe(false); + } + }); +}); diff --git a/src/tui/theme-detect.ts b/src/tui/theme-detect.ts new file mode 100644 index 000000000..3d0bc8f5a --- /dev/null +++ b/src/tui/theme-detect.ts @@ -0,0 +1,119 @@ +/** + * Terminal/OS theme detection (CL-8993). + * + * Precedence, highest first: + * + * 1. Explicit `theme` setting (`light` | `dark`; `auto` defers). + * 2. Sync sniff of COLORFGBG (and TERM_PROGRAM, which currently carries no + * theme signal on its own — consulted so the step owns both vars). + * 3. OS appearance (best-effort per platform; unknown platforms abstain). + * 4. Default dark. + * + * Everything here is pure over injected inputs: no direct `process.env`, + * `process.platform`, or stdin access. Callers read the environment once and + * pass it in, which keeps the precedence matrix unit-testable and leaves OS + * appearance lookup at the startup wiring edge. Nothing is cached across + * restarts — every launch re-detects. + */ + +import type { ThemeName } from "./theme.js"; + +export type ThemeSetting = "auto" | "light" | "dark"; + +const SETTING_THEMES: Record, ThemeName> = { + light: "corbits-light", + dark: "corbits-dark", +}; + +/** Validate a raw settings value. Unknown values fall back to `auto`. */ +export function resolveThemeSetting(raw: unknown): ThemeSetting { + return raw === "light" || raw === "dark" || raw === "auto" ? raw : "auto"; +} + +/** Step 1: an explicit setting short-circuits everything below it. */ +export function settingTheme(setting: ThemeSetting): ThemeName | null { + return setting === "auto" ? null : SETTING_THEMES[setting]; +} + +export interface SyncThemeEnv { + readonly COLORFGBG?: string; + readonly TERM_PROGRAM?: string; +} + +/** + * Step 2: synchronous terminal sniff. + * + * COLORFGBG is `fg;bg` (xterm appends a third cursor field whose middle `bg` + * still applies). A `default` background means "ask the terminal" — unknown, + * never a guess. Numeric backgrounds follow the ANSI table: 0-6 and 8 are + * dark grounds, 7 and 9-15 are light ones. TERM_PROGRAM names the terminal + * but no current terminal encodes its light/dark state there, so it never + * decides alone. + */ +export function sniffSyncTheme(env: SyncThemeEnv): ThemeName | null { + const parts = (env.COLORFGBG ?? "").split(";"); + const bg = parts.length >= 2 ? parts[1]?.trim() : undefined; + if (bg === undefined || bg === "" || bg.toLowerCase() === "default") { + return null; + } + if (!/^\d+$/.test(bg)) return null; + const n = Number(bg); + if (n === 7 || (n >= 9 && n <= 15)) return "corbits-light"; + if ((n >= 0 && n <= 6) || n === 8) return "corbits-dark"; + return null; +} + +export type OsAppearanceRunner = ( + command: string, + args: readonly string[], +) => string | null | undefined; + +/** + * Step 3: OS appearance, best-effort per platform over an injected runner. + * macOS reads the global AppleInterfaceStyle default (`Dark` = dark; a + * missing key means the Light default). Other platforms abstain — null is a + * normal answer, not an error. + */ +export function detectOsAppearance( + platform: string, + run: OsAppearanceRunner, +): ThemeName | null { + if (platform !== "darwin") return null; + let out: string | null | undefined; + try { + out = run("defaults", ["read", "-g", "AppleInterfaceStyle"]); + } catch { + return null; + } + if (out === undefined) return null; + if (out === null) return "corbits-light"; + return out.trim().toLowerCase() === "dark" ? "corbits-dark" : "corbits-light"; +} + +export interface ThemeResolution { + readonly setting: ThemeSetting; + readonly syncEnv: SyncThemeEnv; + readonly os: ThemeName | null; +} + +/** Select the final theme from signals gathered before the TUI is constructed. */ +export function resolveDetectedTheme(resolution: ThemeResolution): ThemeName { + return ( + settingTheme(resolution.setting) ?? + sniffSyncTheme(resolution.syncEnv) ?? + resolution.os ?? + "corbits-dark" + ); +} + +/** Read the sync env slice out of a `process.env`-shaped record. */ +export function syncEnvFromRecord( + env: Record, +): SyncThemeEnv { + return { + ...(env.COLORFGBG !== undefined ? { COLORFGBG: env.COLORFGBG } : {}), + ...(env.TERM_PROGRAM !== undefined + ? { TERM_PROGRAM: env.TERM_PROGRAM } + : {}), + }; +} diff --git a/src/tui/theme-startup.ts b/src/tui/theme-startup.ts new file mode 100644 index 000000000..800544f3c --- /dev/null +++ b/src/tui/theme-startup.ts @@ -0,0 +1,65 @@ +/** + * Startup theme wiring (CL-8993): gather the detection signals at the process + * edge and publish the winning palette onto the live `UI` binding before any + * renderable is constructed, so the first frame cannot mix palettes. + */ + +import { spawnSync } from "node:child_process"; + +import { + detectOsAppearance, + resolveDetectedTheme, + resolveThemeSetting, + sniffSyncTheme, + syncEnvFromRecord, + type OsAppearanceRunner, +} from "./theme-detect.js"; +import { setTheme, type ThemeName } from "./theme.js"; + +export interface StartupThemeOptions { + readonly env?: Record; + readonly platform?: string; + readonly runOsCommand?: OsAppearanceRunner; +} + +function runOsAppearanceCommand( + command: string, + args: readonly string[], +): string | null | undefined { + try { + const out = spawnSync(command, [...args], { + encoding: "utf8", + timeout: 500, + }); + if (out.error !== undefined) return undefined; + if (out.status !== 0) return null; + return typeof out.stdout === "string" ? out.stdout : undefined; + } catch { + return undefined; + } +} + +export function resolveStartupTheme( + rawSetting: unknown, + options: StartupThemeOptions = {}, +): ThemeName { + const setting = resolveThemeSetting(rawSetting); + const syncEnv = syncEnvFromRecord(options.env ?? process.env); + const os = + setting === "auto" && sniffSyncTheme(syncEnv) === null + ? detectOsAppearance( + options.platform ?? process.platform, + options.runOsCommand ?? runOsAppearanceCommand, + ) + : null; + return resolveDetectedTheme({ setting, syncEnv, os }); +} + +export function applyStartupTheme( + rawSetting: unknown, + options: StartupThemeOptions = {}, +): ThemeName { + const theme = resolveStartupTheme(rawSetting, options); + setTheme(theme); + return theme; +} diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 222b0f398..6347a796c 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -100,5 +100,96 @@ export const corbitsDark: Theme = { error: ERROR_RED, }; -/** Semantic roles. Everything outside this file paints through these. */ -export const UI: Theme = corbitsDark; +/** + * Light companion to `corbitsDark`: the same roles on a warm light ground. + * + * Data-only — no interface change, no new roles, no per-theme branches at + * call sites. Every value was picked by relative luminance against the cream + * ground, not by eye: body text holds ~14:1 (near the dark theme's ~15:1), + * and every essential text role holds >=4.5:1. Orange still appears once per + * screen (action/actionDim); it is darkened here because Breakthrough Orange + * itself is ~2.3:1 on cream and unreadable as text. The bronze ramp is + * darkened for the same reason SAND is ~1.8:1 on cream. Dimmed text is a + * dimmed ink, never a neutral gray, so the warm bias survives at every + * emphasis level. Standing caution moves to a muted plum: a bronze warning + * collapses into the machine ramp on cream, while red or orange would spend + * the failure/action hues. + * + * The warm roles separate by lightness first, hue second. Action is the + * darkest warm (~10.5:1) and the most saturated, so the decision marker never + * shares a step with chrome. The dim tier (actionDim, inFlight) sits near + * ~6.4-7:1 and the bright tier (inFlightBright, heading) at ~4.5:1; each + * intra-ramp step spans >=1.5:1 so each ramp still reads as a ramp. + * Same-tier collisions across ramps are carried by hue and saturation: + * actionDim stays orange against the olive machine, and the gold heading + * out-saturates the tan machine-bright. + */ +export const corbitsLight: Theme = { + name: "corbits-light", + ground: BRAND.canvasCream, + text: "#221d18", + textDim: "#57493d", + textFaint: "#746658", + action: "#55270c", + actionDim: "#7a4824", + inFlight: "#564e38", + inFlightBright: "#7d6836", + heading: "#856619", + done: "#3f6b3a", + warning: "#655275", + error: "#b03a30", +}; + +const THEMES = { + "corbits-dark": corbitsDark, + "corbits-light": corbitsLight, +} as const; + +export type ThemeName = keyof typeof THEMES; + +/** Resolve a theme name to its palette. Unknown names fall back to dark. */ +export function resolveThemeName(name: string): Theme { + return (THEMES as Record)[name] ?? corbitsDark; +} + +let activeTheme: Theme = corbitsDark; + +/** + * Reset hooks for paint state derived from the palette. Modules that snapshot + * palette values into renderer-owned registries (a SyntaxStyle) register a + * reset here; `setTheme` runs every hook right after swapping `UI`, so no + * stale palette survives a pin change. The single choke point stays here — + * call sites never reset caches themselves. + */ +type ThemeCacheReset = () => void; + +const themeCacheResets = new Set(); + +export function onThemeChange(reset: ThemeCacheReset): () => void { + themeCacheResets.add(reset); + return () => { + themeCacheResets.delete(reset); + }; +} + +/** + * Switch the live `UI` binding to the named theme, keeping the reference. + * Everything outside this file paints through `UI`, so existing readers pick + * the change up without re-importing. Never reassign or destructure this + * binding — `const { text } = UI` snapshots the old palette forever. + */ +export function setTheme(name: ThemeName | string): Theme { + activeTheme = resolveThemeName(name); + Object.assign(UI, activeTheme); + for (const reset of themeCacheResets) reset(); + return activeTheme; +} + +/** + * Semantic roles. Everything outside this file paints through these. + * + * A settable live binding, not a frozen value: `setTheme` copies the next + * palette onto this same object. The default is dark; startup detection + * selects the final theme before renderables are constructed. + */ +export const UI: Theme = { ...corbitsDark };