feat(tui): support single-mode themes (#37930)

This commit is contained in:
James Long
2026-07-20 09:37:48 -04:00
committed by GitHub
parent 3f4fb3f9db
commit 7be95bcd6a
13 changed files with 283 additions and 77 deletions
@@ -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<typeof useTheme> | undefined
function Probe() {
const value = useTheme()
theme = value
return <text>{value.mode()}</text>
}
function current() {
if (!theme) throw new Error("Theme provider is not mounted")
return theme
}
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "light-only", mode: "dark" } })}>
<ThemeProvider
mode="dark"
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }}
>
<Probe />
</ThemeProvider>
</ConfigProvider>
),
{ 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()
}
})
+5 -5
View File
@@ -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])
})
+29 -35
View File
@@ -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({
+22 -1
View File
@@ -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,
+7
View File
@@ -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)
})
+41 -5
View File
@@ -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")