feat(tui): add V2 theme system (#36950)

This commit is contained in:
James Long 2026-07-14 20:28:45 -04:00 committed by GitHub
commit 9a25673c37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1838 additions and 0 deletions

View file

@ -0,0 +1,35 @@
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { createComponentTheme } from "../../../src/theme/v2/component"
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
import { resolveTheme } from "../../../src/theme/v2/resolve"
import { selectTheme } from "../../../src/theme/v2/select"
import type { ContextKey } from "../../../src/theme/v2"
test("provides reactive property, variant, state, and context accessors", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
const [context, setContext] = createSignal<ContextKey>()
const theme = createComponentTheme(() => {
const key = context()
return key ? resolved().contexts[key] ?? resolved() : resolved()
})
expect(theme.color.text()).toBe(resolved().color.text.default)
expect(theme.color.text.subdued()).toBe(resolved().color.text.subdued)
expect(theme.color.text.action()).toBe(resolved().color.text.action.primary.default)
expect(theme.color.text.action.primary("pressed")).toBe(resolved().color.text.action.primary.pressed)
expect(theme.color.background.action.secondary("disabled")).toBe(
resolved().color.background.action.secondary.disabled,
)
expect(theme.color.scrollbar()).toBe(resolved().color.scrollbar.default)
expect(theme.color.diff.text.added()).toBe(resolved().color.diff.text.added)
setContext("@context:elevated")
expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
expect(theme.color.background.action.primary("selected")).toBe(
resolved().contexts["@context:elevated"]!.color.background.action.primary.selected,
)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
})

View file

@ -0,0 +1,200 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
import type { ThemeDefinition } from "../../../src/theme/v2"
import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve"
import { selectTheme } from "../../../src/theme/v2/select"
const light = selectTheme(DEFAULT_THEME, "light")
const dark = selectTheme(DEFAULT_THEME, "dark")
test("resolves independent definitions and hue aliases", () => {
const lightTheme = resolveTheme(light)
const darkTheme = resolveTheme(dark)
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
expect(lightTheme.color.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.color.background.default).toBeInstanceOf(RGBA)
expect(lightTheme.color.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.color.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
)
expect(lightTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(lightTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
)
expect(lightTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
)
expect(darkTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
darkTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
)
expect(darkTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
darkTheme.hue.neutral[900],
)
})
test("merges partial files with the selected OpenCode defaults", () => {
const theme = resolveThemeFile(
{
version: 2,
light: {
hue: light.hue,
color: { text: { default: "#123456" } },
},
dark: { hue: dark.hue },
},
"light",
)
expect(theme.color.text.default.toInts()).toEqual([18, 52, 86, 255])
expect(theme.color.text.subdued.toInts()).toEqual([18, 52, 86, 255])
expect(theme.color.background.action.destructive.pressed).toBeInstanceOf(RGBA)
})
test("expands user structural fallbacks before merging defaults", () => {
const expanded = resolveThemeFile(
{
version: 2,
light: {
hue: light.hue,
color: { background: { action: { primary: { default: "#123456" } } } },
},
dark: { hue: dark.hue },
},
"light",
)
const isolatedState = resolveThemeFile(
{
version: 2,
light: {
hue: light.hue,
color: { background: { action: { primary: { $pressed: "#654321" } } } },
},
dark: { hue: dark.hue },
},
"light",
)
expect(expanded.color.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
expect(isolatedState.color.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
expect(isolatedState.color.background.action.primary.hovered.toInts()).toEqual(
resolveTheme(light).color.background.action.primary.hovered.toInts(),
)
})
test("standalone themes skip OpenCode defaults and use the red core fallback", () => {
const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const
const lightTheme = resolveThemeFile(file, "light")
const darkTheme = resolveThemeFile(file, "dark")
expect(lightTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
expect(lightTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
expect(darkTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
expect(darkTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
})
test("uses defaults for the selected mode when it merges the other mode", () => {
const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark")
expect(theme.color.background.default.toInts()).toEqual(resolveTheme(dark).color.background.default.toInts())
})
test("resolves matched action variants and states", () => {
const theme = resolveTheme(light)
expect(theme.color.text.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.color.background.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.color.text.action.secondary.default).toBeInstanceOf(RGBA)
expect(theme.color.background.action.destructive.disabled).toBeInstanceOf(RGBA)
})
test("context overrides rewire semantic references and apply state precedence", () => {
const definition = override(light, {
color: {
text: {
default: "#111111",
action: {
primary: { default: "$color.text.default", $pressed: "#222222" },
secondary: { default: "$color.text.default" },
},
},
},
"@context:elevated": {
color: {
text: {
default: "#333333",
action: { primary: { default: "#444444", $selected: "#555555" } },
},
},
},
})
const theme = resolveTheme(definition)
const overlay = theme.contexts["@context:elevated"]!
expect(overlay.color.text.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.color.text.action.secondary.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.color.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
expect(overlay.color.text.action.primary.selected.toInts()).toEqual([85, 85, 85, 255])
})
test("rejects missing, base, and contextual reference cycles", () => {
expect(() => resolveTheme(override(light, { color: { text: { default: "$missing.color" } } }))).toThrow(
'Theme reference "$missing.color" was not found',
)
expect(() =>
resolveTheme(
override(light, {
color: { text: { default: "$color.text.subdued", subdued: "$color.text.default" } },
}),
),
).toThrow("Circular theme reference")
expect(() =>
resolveTheme(
override(light, {
"@context:elevated": { color: { text: { default: "$color.text.default" } } },
}),
),
).toThrow("Circular theme reference")
})
test("validates complete hues, resolved groups, and hue-only syntax", () => {
expect(() =>
resolveTheme(
{
...light,
hue: { ...light.hue, accent: "$hue.missing" },
} as unknown as ThemeDefinition,
),
).toThrow("$hue.missing")
expect(() =>
resolveTheme({
...light,
color: { ...light.color, syntax: { ...light.color?.syntax, keyword: "$color.text.default" } },
} as unknown as ThemeDefinition),
).toThrow("$color.text.default")
})
function override(base: ThemeDefinition, value: Partial<ThemeDefinition>) {
return merge(base, value) as ThemeDefinition
}
function merge(...values: unknown[]): Record<string, unknown> {
return values.reduce<Record<string, unknown>>((result, value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return result
for (const [key, item] of Object.entries(value)) {
if (item === undefined) continue
result[key] = item && typeof item === "object" && !Array.isArray(item) ? merge(result[key], item) : item
}
return result
}, {})
}

View file

@ -0,0 +1,37 @@
import { expect, test } from "bun:test"
import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select"
const hue = {} as HueDefinition
const light = { hue, color: { text: { default: "#111111", subdued: "#222222" } } } satisfies ThemeDefinition
const dark = { hue, color: { text: { default: "#eeeeee", subdued: "#dddddd" } } } satisfies ThemeDefinition
test("requires and selects independent light and dark themes", () => {
const file = { version: 2, light, dark } satisfies ThemeFile
expect(selectTheme(file)).toBe(light)
expect(selectTheme(file, "light")).toBe(light)
expect(selectTheme(file, "dark")).toBe(dark)
expect(selectThemeMode(file, "dark").mode).toBe("dark")
})
test("merges an expanded mode override over the other mode", () => {
const file = {
version: 2,
light,
dark: { mergeMode: true, color: { text: { default: "#ffffff" } } },
} satisfies ThemeFile
const selected = selectTheme(file, "dark")
expect(selected.hue).toBeDefined()
expect(selected.color?.text?.default).toBe("#ffffff")
expect(selected.color?.text?.subdued).toBe("$color.text.default")
})
test("rejects mutual mode merging", () => {
const file = {
version: 2,
light: { mergeMode: true },
dark: { mergeMode: true },
} satisfies ThemeFile
expect(() => selectTheme(file)).toThrow("cannot both merge")
})

View file

@ -0,0 +1,47 @@
import { expect, test } from "bun:test"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
const text = {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $pressed: "$hue.neutral.200" },
secondary: { default: "$hue.neutral.900" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
},
} satisfies TextDefinition
const background = {
default: "$hue.neutral.100",
action: {
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
secondary: { default: "$hue.neutral.200" },
destructive: { default: "$hue.red.600" },
},
feedback: { error: { default: "$hue.red.100" } },
} satisfies BackgroundDefinition
const definition = {
hue: {} as ThemeDefinition["hue"],
color: { text, background, border: { default: "$hue.neutral.300" } },
"@context:elevated": {
color: {
text: { default: "$hue.neutral.800" },
background: { default: "$hue.neutral.200" },
},
},
"@context:overlay": { color: { background: { default: "$hue.neutral.300" } } },
} satisfies ThemeDefinition
const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
test("supports property-first definitions, variants, states, and contexts", () => {
expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
expect(background.action.destructive.default).toBe("$hue.red.600")
expect(definition["@context:elevated"].color?.text?.default).toBe("$hue.neutral.800")
expect(definition["@context:overlay"].color?.background?.default).toBe("$hue.neutral.300")
expect(file.light).toBe(definition)
})

View file

@ -0,0 +1,62 @@
import { expect, test } from "bun:test"
import { DEFAULT_THEMES, resolveTheme as resolveV1, selectedForeground } from "../../../src/theme"
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
test("migrates resolved V1 modes into literal V2 tokens", () => {
const migrated = migrateV1(DEFAULT_THEMES.opencode)
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
const resolved = resolveThemeFile(migrated, "light")
expect(migrated.standalone).toBeUndefined()
expect(migrated.light.hue?.accent).toBeObject()
if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale")
expect(migrated.light.hue.accent[300]).toBe(hex(legacy.accent))
expect(migrated.light.color?.background?.default).toBe(hex(legacy.background))
expect(migrated.light.color?.background?.action?.primary?.default).toBe(hex(legacy.primary))
expect(migrated.light.color?.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
expect(migrated.light.color?.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.color?.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.color?.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
expect(resolved.color.background.action.secondary.hovered.toInts()).toEqual(legacy.backgroundElement.toInts())
expect(resolved.color.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.color.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.color.background.default.toInts()).toEqual(
legacy.backgroundMenu.toInts(),
)
})
test("preserves V1 selected foreground behavior on transparent backgrounds", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.background = "transparent"
source.theme.primary = { light: "#ffffff", dark: "#000000" }
delete source.theme.selectedListItemText
const migrated = migrateV1(source)
expect(migrated.light.color?.text?.action?.primary?.default).toBe("#000000")
expect(migrated.dark.color?.text?.action?.primary?.default).toBe("#ffffff")
})
test("retains V1 circular reference errors", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.defs = { ...source.defs, one: "two", two: "one" }
source.theme.primary = "one"
expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
})
test("migrates every built-in V1 theme in both modes", () => {
for (const source of Object.values(DEFAULT_THEMES)) {
const migrated = migrateV1(source)
expect(resolveThemeFile(migrated, "light").color.text.default).toBeDefined()
expect(resolveThemeFile(migrated, "dark").color.text.default).toBeDefined()
}
})
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")
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
}