From 49f21383f676f18e768c2bada2178560b6cd7697 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Jul 2026 21:31:02 +0000 Subject: [PATCH] feat(tui): support single-mode themes --- packages/tui/src/app.tsx | 3 +- .../tui/src/component/devtools-sidebar.tsx | 12 ++-- packages/tui/src/context/theme.tsx | 35 ++++++--- packages/tui/src/theme/v2/index.ts | 1 + packages/tui/src/theme/v2/schema.ts | 31 ++++++-- packages/tui/src/theme/v2/select.ts | 30 +++++--- packages/tui/src/theme/v2/v1-migrate.ts | 26 +++++-- packages/tui/test/cli/tui/theme-mode.test.tsx | 72 +++++++++++++++++++ packages/tui/test/theme/v2/component.test.ts | 10 +-- packages/tui/test/theme/v2/resolve.test.ts | 64 ++++++++--------- packages/tui/test/theme/v2/select.test.ts | 23 +++++- packages/tui/test/theme/v2/types.test.ts | 7 ++ packages/tui/test/theme/v2/v1-migrate.test.ts | 46 ++++++++++-- 13 files changed, 283 insertions(+), 77 deletions(-) create mode 100644 packages/tui/test/cli/tui/theme-mode.test.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0690d3fba75d..0ebebad798d1 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -420,7 +420,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { const client = useClient() const toast = useToast() const themeState = useTheme() - const { themeV2, mode, setMode, locked, lock, unlock } = themeState + const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState const data = useData() const location = useLocation() const exit = useExit() @@ -818,6 +818,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "theme.switch_mode", title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", palette: undefined, + enabled: () => supports(mode() === "dark" ? "light" : "dark"), run: () => { setMode(mode() === "dark" ? "light" : "dark") dialog.clear() diff --git a/packages/tui/src/component/devtools-sidebar.tsx b/packages/tui/src/component/devtools-sidebar.tsx index 6fa3fdd645cb..f3ef08cc73e8 100644 --- a/packages/tui/src/component/devtools-sidebar.tsx +++ b/packages/tui/src/component/devtools-sidebar.tsx @@ -4,8 +4,10 @@ import { useTheme } from "../context/theme" import { DevTools } from "../devtools" export function DevToolsSidebar() { - const { themeV2, mode, setMode } = useTheme().contextual("elevated") + const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated") const [modeHovered, setModeHovered] = createSignal(false) + const nextMode = () => (mode() === "dark" ? "light" : "dark") + const canSwitchMode = () => supports(nextMode()) return ( setModeHovered(true)} + backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action("hovered") : undefined} + onMouseOver={() => setModeHovered(canSwitchMode())} onMouseOut={() => setModeHovered(false)} - onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")} + onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined} > - {mode()} + {mode()} diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index c9f5f01a6e67..0a70f763d504 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -21,6 +21,7 @@ import { discoverThemes, themeDirectories } from "../theme/discovery" import { createComponentTheme, type ComponentTheme } from "../theme/v2/component" import { resolveThemeFile } from "../theme/v2/resolve" import { migrateV1 } from "../theme/v2/v1-migrate" +import { themeModes } from "../theme/v2/select" import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -78,10 +79,12 @@ type ThemeService = { has: typeof hasTheme syntax: Accessor mode: Accessor<"dark" | "light"> + modes: Accessor + supports(mode: "dark" | "light"): boolean locked: Accessor lock(): void unlock(): void - setMode(mode?: "dark" | "light", persist?: boolean): void + setMode(mode?: "dark" | "light", persist?: boolean): boolean set(theme: string): boolean readonly ready: boolean } @@ -271,17 +274,25 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode) const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode")) - const values = createMemo(() => resolveTheme(source(), store.mode)) - const valuesV2 = createMemo(() => { + const file = createMemo(() => { const started = performance.now() - const file = migrateV1(source()) + const result = migrateV1(source()) themePerformance.set("Convert V1 to V2", duration(performance.now() - started)) + return result + }) + const modes = createMemo(() => themeModes(file())) + const mode = () => { + const supported = modes() + if (supported.includes(store.mode)) return store.mode + return supported[0] ?? store.mode + } + const values = createMemo(() => resolveTheme(source(), mode())) + const valuesV2 = createMemo(() => { const resolveStarted = performance.now() - const result = resolveThemeFile(file, store.mode, sourceName()) + const result = resolveThemeFile(file(), mode(), sourceName()) themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted)) return result }) - const mode = () => store.mode const themeV2 = createComponentTheme(valuesV2, mode) const contextsV2 = { elevated: createComponentTheme(() => { @@ -319,11 +330,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ all: allThemes, has: hasTheme, syntax, - mode: () => store.mode, + mode, + modes, + supports: (requested) => modes().includes(requested), locked: () => store.lock !== undefined, - lock: () => pin(store.mode), + lock: () => pin(mode()), unlock: free, - setMode: pin, + setMode(requested = mode(), persist = true) { + if (!modes().includes(requested)) return false + pin(requested, persist) + return true + }, set(theme: string) { if (!hasTheme(theme)) return false setStore("active", theme) diff --git a/packages/tui/src/theme/v2/index.ts b/packages/tui/src/theme/v2/index.ts index 4254789cb36b..68b482246945 100644 --- a/packages/tui/src/theme/v2/index.ts +++ b/packages/tui/src/theme/v2/index.ts @@ -41,3 +41,4 @@ export type { StatefulColor, } from "./types" export { migrateV1 } from "./v1-migrate" +export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select" diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts index fcc512e99e74..b56235667b50 100644 --- a/packages/tui/src/theme/v2/schema.ts +++ b/packages/tui/src/theme/v2/schema.ts @@ -137,15 +137,35 @@ const BackgroundDefinition = Schema.Struct({ export type BackgroundDefinition = Schema.Schema.Type export const SyntaxToken = Schema.Literals([ - "comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation", + "comment", + "keyword", + "function", + "variable", + "string", + "number", + "type", + "operator", + "punctuation", ]) export type SyntaxToken = Schema.Schema.Type export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue)) export type SyntaxDefinition = Schema.Schema.Type export const MarkdownToken = Schema.Literals([ - "text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem", - "listEnumeration", "image", "imageText", "codeBlock", + "text", + "heading", + "link", + "linkText", + "code", + "blockQuote", + "emphasis", + "strong", + "horizontalRule", + "listItem", + "listEnumeration", + "image", + "imageText", + "codeBlock", ]) export type MarkdownToken = Schema.Schema.Type export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue)) @@ -225,5 +245,8 @@ const FileMetadata = { version: Schema.Literal(2), standalone: Schema.optional(Schema.Boolean), } -export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition }) +export const ThemeFile = Schema.Union([ + Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }), + Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }), +]) export type ThemeFile = Schema.Schema.Type diff --git a/packages/tui/src/theme/v2/select.ts b/packages/tui/src/theme/v2/select.ts index 15017a747356..c763179f53af 100644 --- a/packages/tui/src/theme/v2/select.ts +++ b/packages/tui/src/theme/v2/select.ts @@ -9,7 +9,7 @@ import type { } from "./index" export function selectTheme( - file: Omit & { light: ThemeDefinition; dark: ThemeDefinition }, + file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition }, mode?: Mode, ): ThemeDefinition export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition @@ -21,17 +21,31 @@ export function selectThemeMode( file: ThemeFile, mode: Mode = "light", ): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } { + const modes = themeModes(file) + const selectedMode = modes.includes(mode) ? mode : modes[0] + const selected = file[selectedMode] + if (!selected) throw new Error("Theme must provide at least one mode") if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes") - const selected = file[mode] - if (!merges(selected)) return { theme: selected, mode, expanded: false } + if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false } - const otherMode = mode === "light" ? "dark" : "light" + const otherMode = selectedMode === "light" ? "dark" : "light" const other = file[otherMode] + if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`) const merged = mergeTheme(expandTheme(other), expandTheme(selected)) - if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`) - return { theme: merged as FileThemeDefinition, mode, expanded: true } + if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`) + return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true } } -function merges(definition: ModeDefinition): definition is MergeModeDefinition { - return "mergeMode" in definition && definition.mergeMode === true +export function themeModes(file: ThemeFile): readonly Mode[] { + if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme") + if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme") + return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined) +} + +export function supportsThemeMode(file: ThemeFile, mode: Mode) { + return themeModes(file).includes(mode) +} + +function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition { + return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true } diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts index 83be1abe92d3..3b0054777202 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -2,7 +2,7 @@ import { RGBA } from "@opentui/core" import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color" import type { Theme, ThemeJson } from "../index" import { DEFAULT_THEME } from "./defaults" -import type { ThemeFile } from "./index" +import type { FileThemeDefinition, Mode, ThemeFile } from "./index" import { HueStep } from "./schema" type ThemeColor = Exclude @@ -13,15 +13,33 @@ const minimumChroma = 0.03 const lightThreshold = 0.6 export function migrateV1(theme: ThemeJson): ThemeFile { + const light = resolveV1(theme, "light") + const dark = resolveV1(theme, "dark") + if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) { + const lightMode = detectMode(light) + const darkMode = detectMode(dark) + if (lightMode === darkMode) { + if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") } + return { version: 2, standalone: true, dark: migrateMode(dark, "dark") } + } + } return { version: 2, standalone: true, - light: migrateMode(resolveV1(theme, "light"), "light"), - dark: migrateMode(resolveV1(theme, "dark"), "dark"), + light: migrateMode(light, "light"), + dark: migrateMode(dark, "dark"), } } -function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] { +function detectMode(theme: Theme): Mode { + return luminance(theme.text) > luminance(theme.background) ? "dark" : "light" +} + +function luminance(color: RGBA) { + return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b +} + +function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition { const color = (key: ThemeColor) => hex(theme[key]) const selected = hex(selectedForeground(theme, theme.primary)) const destructive = hex(selectedForeground(theme, theme.error)) diff --git a/packages/tui/test/cli/tui/theme-mode.test.tsx b/packages/tui/test/cli/tui/theme-mode.test.tsx new file mode 100644 index 000000000000..cb195e384bb1 --- /dev/null +++ b/packages/tui/test/cli/tui/theme-mode.test.tsx @@ -0,0 +1,72 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { DEFAULT_THEMES } from "../../../src/theme" +import { ConfigProvider } from "../../../src/config" +import { ThemeProvider, useTheme } from "../../../src/context/theme" + +async function wait(fn: () => boolean) { + const started = Date.now() + while (!fn()) { + if (Date.now() - started > 2000) throw new Error("timed out waiting for theme mode") + await Bun.sleep(10) + } +} + +test("uses an available mode while retaining the pinned preference", async () => { + const lightOnly = structuredClone(DEFAULT_THEMES.opencode) + lightOnly.theme.background = "#eeeeee" + lightOnly.theme.text = "#111111" + const dual = structuredClone(DEFAULT_THEMES.opencode) + dual.theme.background = { light: "#eeeeee", dark: "#111111" } + dual.theme.text = { light: "#111111", dark: "#eeeeee" } + const darkOnly = structuredClone(DEFAULT_THEMES.opencode) + darkOnly.theme.background = "#111111" + darkOnly.theme.text = "#eeeeee" + let theme: ReturnType | undefined + + function Probe() { + const value = useTheme() + theme = value + return {value.mode()} + } + + function current() { + if (!theme) throw new Error("Theme provider is not mounted") + return theme + } + + const app = await testRender( + () => ( + + Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }} + > + + + + ), + { width: 20, height: 2 }, + ) + app.renderer.start() + + try { + await wait(() => theme?.ready === true) + expect(current().mode()).toBe("light") + expect(current().modes()).toEqual(["light"]) + expect(current().supports("dark")).toBeFalse() + expect(current().setMode("dark")).toBeFalse() + expect(current().set("dark-only")).toBeTrue() + await wait(() => current().mode() === "dark") + expect(current().modes()).toEqual(["dark"]) + expect(current().set("light-only")).toBeTrue() + await wait(() => current().mode() === "light") + expect(current().set("dual")).toBeTrue() + await wait(() => current().mode() === "dark") + expect(current().modes()).toEqual(["light", "dark"]) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 6d926ed71f3e..00da1c045c1a 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -20,12 +20,12 @@ test("provides reactive property, variant, state, and context accessors", () => expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500]) expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500]) expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200]) - expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[300]) - expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[300]) + expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[400]) + expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[400]) expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100]) expect(theme.increase(theme.hue.red(900), 3)).toBe(resolved().hue.red[900]) expect(theme.decrease(theme.hue.red(100), 3)).toBe(resolved().hue.red[100]) - expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 200 }) + expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 300 }) const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts()) expect(theme.source(equivalent)).toBeUndefined() expect(theme.increase(equivalent, 1)).toBe(equivalent) @@ -87,6 +87,6 @@ test("provides reactive property, variant, state, and context accessors", () => setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) setMode("dark") expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default) - expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[700]) - expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[700]) + expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[600]) + expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[600]) }) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index 98181dbccb97..67aafc208f7c 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -8,6 +8,19 @@ import { selectTheme } from "../../../src/theme/v2/select" const light = selectTheme(DEFAULT_THEME, "light") const dark = selectTheme(DEFAULT_THEME, "dark") +test("resolves one-mode files with defaults for the available mode", () => { + const resolvedLight = resolveThemeFile({ version: 2, light: {} }, "dark") + const resolvedDark = resolveThemeFile({ version: 2, dark: {} }, "light") + + expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue() + expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue() +}) + +test("rejects theme files without a mode", () => { + // @ts-expect-error Runtime decoding also enforces the at-least-one-mode invariant. + expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme") +}) + test("resolves independent definitions and hue aliases", () => { const lightTheme = resolveTheme(light) const darkTheme = resolveTheme(dark) @@ -20,44 +33,32 @@ test("resolves independent definitions and hue aliases", () => { expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue() expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 }) expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 }) - expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 200 }) + expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 }) expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100]) - expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe( - lightTheme.hue.red[200], - ) + expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) expect(lightTheme.text.default).toBeInstanceOf(RGBA) expect(darkTheme.background.default).toBeInstanceOf(RGBA) - expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[200]) - expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[300]) + expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300]) + expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400]) expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA) - expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) + expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200]) expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( lightTheme.hue.interactive[500], ) expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset) - expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe( - lightTheme.hue.neutral[100], - ) + expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe( lightTheme.hue.interactive[500], ) expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay) - expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe( - lightTheme.hue.neutral[100], - ) + expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( darkTheme.hue.interactive[400], ) - expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe( - darkTheme.hue.neutral[100], - ) - expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe( - darkTheme.hue.interactive[400], - ) - expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe( - darkTheme.hue.neutral[900], - ) + expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) + expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(darkTheme.hue.interactive[400]) + expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) }) test("resolves base hue aliases and rejects circular hue aliases", () => { @@ -65,10 +66,7 @@ test("resolves base hue aliases and rejects circular hue aliases", () => { ...light, hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" }, }) - const overridden = resolveThemeFile( - { version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, - "light", - ) + const overridden = resolveThemeFile({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light") expect(aliased.hue.blue).not.toBe(aliased.hue.red) expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue() @@ -195,9 +193,7 @@ test("resolves elevated hover surfaces from direct colors", () => { ) expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255]) - expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([ - 35, 69, 103, 255, - ]) + expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255]) }) test("resolves transparent colors", () => { @@ -268,12 +264,10 @@ test("rejects missing, base, and contextual reference cycles", () => { test("validates complete hues, resolved groups, and hue-only syntax", () => { expect(() => - resolveTheme( - { - ...light, - hue: { ...light.hue, accent: "$hue.missing" }, - } as unknown as ThemeDefinition, - ), + resolveTheme({ + ...light, + hue: { ...light.hue, accent: "$hue.missing" }, + } as unknown as ThemeDefinition), ).toThrow("$hue.missing") expect(() => resolveTheme({ diff --git a/packages/tui/test/theme/v2/select.test.ts b/packages/tui/test/theme/v2/select.test.ts index 80d965022468..ca0f9c84c61a 100644 --- a/packages/tui/test/theme/v2/select.test.ts +++ b/packages/tui/test/theme/v2/select.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" -import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select" +import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select" const hue = {} as HueDefinition const light = { hue, text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition @@ -27,6 +27,27 @@ test("merges an expanded mode override over the other mode", () => { expect(selected.text?.subdued).toBe("$text.default") }) +test("selects the available mode when the requested mode is missing", () => { + const lightOnly = { version: 2, light } satisfies ThemeFile + const darkOnly = { version: 2, dark } satisfies ThemeFile + + expect(themeModes(lightOnly)).toEqual(["light"]) + expect(themeModes(darkOnly)).toEqual(["dark"]) + expect(supportsThemeMode(lightOnly, "light")).toBeTrue() + expect(supportsThemeMode(lightOnly, "dark")).toBeFalse() + expect(selectThemeMode(lightOnly, "dark")).toEqual({ theme: light, mode: "light", expanded: false }) + expect(selectThemeMode(darkOnly, "light")).toEqual({ theme: dark, mode: "dark", expanded: false }) +}) + +test("rejects a merge mode without its base mode", () => { + expect(() => selectThemeMode({ version: 2, light: { mergeMode: true } })).toThrow( + "light theme cannot merge without a dark theme", + ) + expect(() => selectThemeMode({ version: 2, dark: { mergeMode: true } })).toThrow( + "dark theme cannot merge without a light theme", + ) +}) + test("rejects mutual mode merging", () => { const file = { version: 2, diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts index acbc008a6d36..4f6efbb54354 100644 --- a/packages/tui/test/theme/v2/types.test.ts +++ b/packages/tui/test/theme/v2/types.test.ts @@ -51,6 +51,10 @@ const definition = { } satisfies ThemeDefinition const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile +const lightOnly = { version: 2, light: definition } satisfies ThemeFile +const darkOnly = { version: 2, dark: definition } satisfies ThemeFile +// @ts-expect-error A theme file must provide at least one mode. +const empty = { version: 2 } satisfies ThemeFile test("supports property-first definitions, variants, states, and contexts", () => { expect(text.action.primary.$hovered).toBe("$hue.neutral.200") @@ -63,4 +67,7 @@ test("supports property-first definitions, variants, states, and contexts", () = expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800") expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300") expect(file.light).toBe(definition) + expect(lightOnly.light).toBe(definition) + expect(darkOnly.dark).toBe(definition) + expect(empty.version).toBe(2) }) diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 00a04c5a76ee..27ccb9db25e6 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -1,10 +1,12 @@ import { expect, test } from "bun:test" import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme" import { resolveThemeFile } from "../../../src/theme/v2/resolve" +import { selectThemeMode, themeModes } from "../../../src/theme/v2/select" import { migrateV1 } from "../../../src/theme/v2/v1-migrate" test("migrates resolved V1 modes into literal V2 tokens", () => { const migrated = migrateV1(DEFAULT_THEMES.opencode) + if (!migrated.light || !migrated.dark) throw new Error("Expected both modes") const legacy = resolveV1(DEFAULT_THEMES.opencode, "light") const resolved = resolveThemeFile(migrated, "light") @@ -61,6 +63,7 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou source.theme.success = { light: "#ff6666", dark: "#450000" } const migrated = migrateV1(source) + if (!migrated.light || !migrated.dark) throw new Error("Expected both modes") const lightRed = migrated.light.hue?.red const darkRed = migrated.dark.hue?.red if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales") @@ -92,6 +95,7 @@ test("builds and extrapolates gray from V1 surfaces and text without using menus const light = resolveV1(source, "light") const dark = resolveV1(source, "dark") const migrated = migrateV1(source) + if (!migrated.light || !migrated.dark) throw new Error("Expected both modes") const lightGray = migrated.light.hue?.gray const darkGray = migrated.dark.hue?.gray if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales") @@ -114,8 +118,9 @@ test("builds and extrapolates gray from V1 surfaces and text without using menus source.theme.borderSubtle = "#ff00ff" source.theme.border = "#00ff00" source.theme.borderActive = "#00ffff" - expect(migrateV1(source).light.hue?.gray).toEqual(lightGray) - expect(migrateV1(source).dark.hue?.gray).toEqual(darkGray) + const withBorders = migrateV1(source) + expect(withBorders.light?.hue?.gray).toEqual(lightGray) + expect(withBorders.dark?.hue?.gray).toEqual(darkGray) }) test("uses the default text reference for primary actions on transparent backgrounds", () => { @@ -124,6 +129,7 @@ test("uses the default text reference for primary actions on transparent backgro source.theme.primary = { light: "#ffffff", dark: "#000000" } delete source.theme.selectedListItemText const migrated = migrateV1(source) + if (!migrated.light || !migrated.dark) throw new Error("Expected both modes") expect(migrated.light.text?.action?.primary?.default).toBe("$text.default") expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default") @@ -137,14 +143,44 @@ test("retains V1 circular reference errors", () => { expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one") }) -test("migrates every built-in V1 theme in both modes", () => { +test("migrates every built-in V1 theme in its supported modes", () => { for (const source of Object.values(DEFAULT_THEMES)) { const migrated = migrateV1(source) - expect(resolveThemeFile(migrated, "light").text.default).toBeDefined() - expect(resolveThemeFile(migrated, "dark").text.default).toBeDefined() + for (const mode of themeModes(migrated)) { + expect(resolveThemeFile(migrated, mode).text.default).toBeDefined() + } } }) +test("collapses identical V1 backgrounds when both variants infer one mode", () => { + const dark = structuredClone(DEFAULT_THEMES.opencode) + dark.theme.background = "#111111" + dark.theme.text = "#eeeeee" + const migratedDark = migrateV1(dark) + expect(migratedDark.light).toBeUndefined() + expect(migratedDark.dark).toBeDefined() + expect(themeModes(migratedDark)).toEqual(["dark"]) + expect(selectThemeMode(migratedDark, "light").mode).toBe("dark") + + const light = structuredClone(DEFAULT_THEMES.opencode) + light.theme.background = "#eeeeee" + light.theme.text = "#111111" + const migratedLight = migrateV1(light) + expect(migratedLight.light).toBeDefined() + expect(migratedLight.dark).toBeUndefined() + expect(themeModes(migratedLight)).toEqual(["light"]) + expect(selectThemeMode(migratedLight, "dark").mode).toBe("light") +}) + +test("keeps both modes when a shared background has different contrast", () => { + const source = structuredClone(DEFAULT_THEMES.opencode) + source.theme.background = "#808080" + source.theme.text = { light: "#111111", dark: "#eeeeee" } + const migrated = migrateV1(source) + + expect(themeModes(migrated)).toEqual(["light", "dark"]) +}) + function hex(color: { toInts(): [number, number, number, number] }) { const [r, g, b, a] = color.toInts() const byte = (value: number) => value.toString(16).padStart(2, "0")