feat(tui): refine V2 theme colors (#37346)

This commit is contained in:
James Long 2026-07-16 14:01:25 -04:00 committed by GitHub
commit 7a7075d86f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 314 additions and 120 deletions

View file

@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js"
import { useEvent } from "./event"
@ -54,7 +55,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData()
const client = useClient()
const toast = useToast()
const { theme, themeV2 } = useTheme()
const { theme, themeV2, mode } = useTheme()
const route = useRoute()
const paths = useTuiPaths()
const args = useArgs()
@ -83,15 +84,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
const colors = createMemo(() => [
themeV2.hue.accent(500),
theme.accent,
theme.success,
theme.warning,
theme.primary,
theme.error,
theme.info,
])
const colors = createMemo(() => {
const step = mode() === "light" ? 800 : 200
return dedupeWith(
[
themeV2.hue.blue(step),
themeV2.hue.purple(step),
themeV2.hue.green(step),
themeV2.hue.orange(step),
themeV2.hue.red(step),
themeV2.hue.cyan(step),
],
(first, second) => first.equals(second),
)
})
return {
list() {
return agents()
@ -441,7 +447,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const slots = createMemo(() => {
const existing = new Set(data.session.list().filter((x) => x.parentID === undefined).map((x) => x.id))
const existing = new Set(
data.session
.list()
.filter((x) => x.parentID === undefined)
.map((x) => x.id),
)
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
})

View file

@ -50,8 +50,9 @@ function Mcp(props: { context: Plugin.Context }) {
}
function View(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme()
const { themeV2, mode, setMode } = useTheme()
const dimensions = useTerminalDimensions()
const modeLabel = createMemo(() => (mode() === "dark" ? "Light mode" : "Dark mode"))
const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
if (list.length === 0) return 0
@ -72,10 +73,16 @@ function View(props: { context: Plugin.Context }) {
>
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
maxWidth={Math.max(
2,
dimensions().width - 10 - Bun.stringWidth(InstallationVersion) - Bun.stringWidth(modeLabel()) - mcpWidth(),
)}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0} onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}>
<text fg={themeV2.text.action.secondary()}>{modeLabel()}</text>
</box>
<box flexShrink={0}>
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
</box>

View file

@ -656,7 +656,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box
backgroundColor={themeV2.background()}
border={["left"]}
borderColor={themeV2.text.feedback.warning()}
borderColor={themeV2.background.action.primary("focused")}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }

View file

@ -25,6 +25,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
blue: (step: HueStep) => current().hue.blue[step],
purple: (step: HueStep) => current().hue.purple[step],
accent: (step: HueStep) => current().hue.accent[step],
interactive: (step: HueStep) => current().hue.interactive[step],
neutral: (step: HueStep) => current().hue.neutral[step],
}
const text = Object.assign(() => current().text.default, {

View file

@ -93,6 +93,7 @@ export const DEFAULT_THEME = {
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
text: {
@ -108,7 +109,7 @@ export const DEFAULT_THEME = {
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.600",
$selected: "$hue.interactive.600",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
@ -125,7 +126,7 @@ export const DEFAULT_THEME = {
},
action: {
primary: {
default: "$hue.accent.600", $focused: "$hue.accent.700", $pressed: "$hue.accent.800",
default: "$hue.interactive.600", $focused: "$hue.interactive.700", $pressed: "$hue.interactive.800",
$disabled: "$hue.neutral.300",
},
secondary: {
@ -140,7 +141,7 @@ export const DEFAULT_THEME = {
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
@ -196,14 +197,14 @@ export const DEFAULT_THEME = {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.accent.500" } },
action: { primary: { default: "$hue.interactive.500" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.500" } },
action: { primary: { default: "$hue.interactive.500" } },
},
},
},
@ -298,6 +299,7 @@ export const DEFAULT_THEME = {
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
text: {
@ -313,7 +315,7 @@ export const DEFAULT_THEME = {
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.500",
$selected: "$hue.interactive.500",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
@ -330,7 +332,7 @@ export const DEFAULT_THEME = {
},
action: {
primary: {
default: "$hue.accent.500", $focused: "$hue.accent.600", $pressed: "$hue.accent.800",
default: "$hue.interactive.500", $focused: "$hue.interactive.600", $pressed: "$hue.interactive.800",
$disabled: "$hue.neutral.800",
},
secondary: {
@ -345,7 +347,7 @@ export const DEFAULT_THEME = {
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
@ -401,14 +403,14 @@ export const DEFAULT_THEME = {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.accent.400" } },
action: { primary: { default: "$hue.interactive.400" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.900" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.400" } },
action: { primary: { default: "$hue.interactive.400" } },
},
},
},

View file

@ -157,7 +157,6 @@ function resolveHue(definition: HueDefinition) {
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
const value = source[name]
if (typeof value === "string") {
if ((BaseHue.literals as readonly string[]).includes(name)) throw new Error(`Base hue "${name}" must be a scale`)
const match = /^\$hue\.([^.]+)$/.exec(value)
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
const result = resolve(match[1], [...stack, name])

View file

@ -6,7 +6,7 @@ export type HueStep = Schema.Schema.Type<typeof HueStep>
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
export const HueAlias = Schema.Literals(["accent", "neutral"])
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
@ -41,33 +41,35 @@ const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueAliasDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueDefinition = Schema.Struct({
gray: HueScaleDefinition,
red: HueScaleDefinition,
orange: HueScaleDefinition,
yellow: HueScaleDefinition,
green: HueScaleDefinition,
cyan: HueScaleDefinition,
blue: HueScaleDefinition,
purple: HueScaleDefinition,
accent: HueAliasDefinition,
neutral: HueAliasDefinition,
gray: HueValueDefinition,
red: HueValueDefinition,
orange: HueValueDefinition,
yellow: HueValueDefinition,
green: HueValueDefinition,
cyan: HueValueDefinition,
blue: HueValueDefinition,
purple: HueValueDefinition,
accent: HueValueDefinition,
interactive: HueValueDefinition,
neutral: HueValueDefinition,
})
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueOverrideDefinition = Schema.Struct({
gray: Schema.optional(HueScaleDefinition),
red: Schema.optional(HueScaleDefinition),
orange: Schema.optional(HueScaleDefinition),
yellow: Schema.optional(HueScaleDefinition),
green: Schema.optional(HueScaleDefinition),
cyan: Schema.optional(HueScaleDefinition),
blue: Schema.optional(HueScaleDefinition),
purple: Schema.optional(HueScaleDefinition),
accent: Schema.optional(HueAliasDefinition),
neutral: Schema.optional(HueAliasDefinition),
gray: Schema.optional(HueValueDefinition),
red: Schema.optional(HueValueDefinition),
orange: Schema.optional(HueValueDefinition),
yellow: Schema.optional(HueValueDefinition),
green: Schema.optional(HueValueDefinition),
cyan: Schema.optional(HueValueDefinition),
blue: Schema.optional(HueValueDefinition),
purple: Schema.optional(HueValueDefinition),
accent: Schema.optional(HueValueDefinition),
interactive: Schema.optional(HueValueDefinition),
neutral: Schema.optional(HueValueDefinition),
})
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>

View file

@ -1,9 +1,16 @@
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 { HueStep } from "./schema"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
const minimumChroma = 0.03
const lightThreshold = 0.6
export function migrateV1(theme: ThemeJson): ThemeFile {
return {
@ -18,33 +25,48 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
const hues = inferHues(theme, mode)
const text = mode === "light" ? "$hue.neutral.900" : "$hue.neutral.100"
const textMuted = mode === "light" ? "$hue.neutral.700" : "$hue.neutral.300"
const primary = mode === "light" ? "$hue.interactive.900" : "$hue.interactive.100"
const background = mode === "light" ? "$hue.neutral.100" : "$hue.neutral.900"
const backgroundPanel = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
const backgroundMenu = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
return {
hue: {
...DEFAULT_THEME[mode].hue,
accent: hueScale(theme.secondary),
gray: neutralScale(theme, mode),
...Object.fromEntries(
chromaticHues.map((name) => {
const match = hues[name]
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
}),
),
accent: ambiguous(theme.accent) ? "$hue.gray" : hueScale(theme.accent, mode),
interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
neutral: "$hue.gray",
},
text: {
default: color("text"),
subdued: color("textMuted"),
default: text,
subdued: textMuted,
action: {
primary: {
default: selected,
$disabled: color("textMuted"),
default: "$text.default",
$disabled: textMuted,
$focused: selected,
},
secondary: {
default: "$text.default",
$disabled: color("textMuted"),
$disabled: textMuted,
},
destructive: { default: destructive, $disabled: color("textMuted") },
destructive: { default: destructive, $disabled: textMuted },
},
formfield: {
default: color("text"),
$focused: color("primary"),
$pressed: color("primary"),
$disabled: color("textMuted"),
$selected: color("primary"),
default: text,
$focused: primary,
$pressed: primary,
$disabled: textMuted,
$selected: primary,
},
feedback: {
error: { default: color("error") },
@ -54,13 +76,13 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
},
},
background: {
default: color("background"),
default: background,
surface: {
offset: color("backgroundPanel"),
overlay: color("backgroundMenu"),
offset: backgroundPanel,
overlay: backgroundMenu,
},
action: {
primary: { default: color("primary"), $focused: color("primary") },
primary: { default: "transparent", $focused: primary },
secondary: {
default: "$background.default",
$focused: color("backgroundElement"),
@ -131,21 +153,44 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
"@context:elevated": {
background: {
default: "$background.surface.offset",
action: {
primary: {
default: color("primary"),
$focused: color("primary"),
},
secondary: {
default: "$background.surface.offset",
},
},
},
},
"@context:overlay": { background: { default: "$background.surface.overlay" } },
}
}
function inferHues(theme: Theme, mode: "light" | "dark") {
return [theme.accent, theme.success, theme.warning, theme.primary, theme.error, theme.info, theme.secondary].reduce<
Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
>((result, color) => {
const value = toOklch(color)
if (ambiguous(color, value.c)) return result
const anchor = inferenceAnchor(value.l)
const nearest = chromaticHues
.map((name) => ({
name,
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
}))
.sort((first, second) => first.distance - second.distance)[0]
const current = result[nearest.name]
if (current && current.distance <= nearest.distance) return result
return { ...result, [nearest.name]: { color, distance: nearest.distance } }
}, {})
}
function inferenceAnchor(lightness: number): HueStep {
return lightness >= lightThreshold ? 300 : 700
}
function hueDistance(first: number, second: number) {
const difference = Math.abs(first - second)
return Math.min(difference, 360 - difference)
}
function ambiguous(color: RGBA, chroma = toOklch(color).c) {
return color.toInts()[3] === 0 || chroma < minimumChroma
}
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
const defs = theme.defs ?? {}
@ -192,28 +237,66 @@ function selectedForeground(theme: Theme, background: RGBA) {
: RGBA.fromInts(255, 255, 255)
}
function hueScale(color: RGBA) {
return {
100: mix(color, 255, 0.8),
200: mix(color, 255, 0.6),
300: mix(color, 255, 0.4),
400: mix(color, 255, 0.2),
500: hex(color),
600: mix(color, 0, 0.15),
700: mix(color, 0, 0.3),
800: mix(color, 0, 0.45),
900: mix(color, 0, 0.6),
}
function hueScale(color: RGBA, mode: "light" | "dark") {
const value = toOklch(color)
const anchor = mode === "light" ? 900 : 100
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
const alpha = color.toInts()[3]
return Object.fromEntries(
HueStep.literals.map((step) => {
if (step === anchor) return [step, hex(color)]
const progress = mode === "light" ? (900 - step) / 800 : (step - 100) / 800
const generated = oklchToHex({
l: value.l + (endpoint - value.l) * progress,
c: value.c * (1 - progress * 0.5),
h: value.h,
})
return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
}),
) as Record<HueStep, string>
}
function mix(color: RGBA, target: number, amount: number) {
const [r, g, b, a] = color.toInts()
return hexInts(
Math.round(r + (target - r) * amount),
Math.round(g + (target - g) * amount),
Math.round(b + (target - b) * amount),
a,
)
function neutralScale(theme: Theme, mode: "light" | "dark") {
const anchors = neutralAnchors(theme, mode)
return Object.fromEntries(
HueStep.literals.map((step) => {
const exact = anchors.find((anchor) => anchor.step === step)
if (exact) return [step, hex(exact.color)]
const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
const upper = anchors.find((anchor) => anchor.step > step)!
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
}),
) as Record<HueStep, string>
}
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 100, color: theme.background },
{ step: 200, color: theme.backgroundPanel },
{ step: 300, color: theme.backgroundMenu },
{ step: 700, color: theme.textMuted },
{ step: 900, color: theme.text },
]
if (mode === "light") return light
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
}
function interpolate(first: RGBA, second: RGBA, amount: number) {
const start = toOklch(first)
const end = toOklch(second)
const hue = ((((end.h - start.h) % 360) + 540) % 360) - 180
const generated = oklchToHex({
l: start.l + (end.l - start.l) * amount,
c: start.c + (end.c - start.c) * amount,
h: start.h + hue * amount,
})
const alpha = Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
}
function toOklch(color: RGBA) {
const [red, green, blue] = color.toInts()
return rgbToOklch(red / 255, green / 255, blue / 255)
}
function hex(color: RGBA) {
@ -221,10 +304,13 @@ function hex(color: RGBA) {
}
function hexInts(r: number, g: number, b: number, a: number) {
const byte = (value: number) => value.toString(16).padStart(2, "0")
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
}
function byte(value: number) {
return value.toString(16).padStart(2, "0")
}
function ansi(code: number) {
if (code < 16) {
const colors = [

View file

@ -16,6 +16,7 @@ test("provides reactive property, variant, state, and context accessors", () =>
expect(theme.text()).toBe(resolved().text.default)
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.text.subdued()).toBe(resolved().text.subdued)
expect(theme.text.action()).toBe(resolved().text.action.primary.default)

View file

@ -13,6 +13,7 @@ test("resolves independent definitions and hue aliases", () => {
const darkTheme = resolveTheme(dark)
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.interactive).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
@ -21,33 +22,54 @@ test("resolves independent definitions and hue aliases", () => {
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
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:overlay"]?.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
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(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
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.accent[400],
darkTheme.hue.interactive[400],
)
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[900],
)
})
test("resolves base hue aliases and rejects circular hue aliases", () => {
const aliased = resolveTheme({
...light,
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
})
const overridden = resolveThemeFile(
{ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} },
"light",
)
expect(aliased.hue.blue).toBe(aliased.hue.red)
expect(aliased.hue.purple).toBe(aliased.hue.red)
expect(overridden.hue.blue).toBe(overridden.hue.red)
expect(() =>
resolveTheme({
...light,
hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" },
}),
).toThrow("Circular hue reference: red -> blue -> red")
})
test("merges partial files with the selected OpenCode defaults", () => {
const theme = resolveThemeFile(
{

View file

@ -19,11 +19,11 @@ const background = {
default: "$hue.neutral.100",
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
action: {
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
primary: { default: "$hue.interactive.600", $pressed: "$hue.interactive.800" },
secondary: { default: "$hue.neutral.200" },
destructive: { default: "$hue.red.600" },
},
formfield: { default: "$hue.neutral.100", $selected: "$hue.accent.600" },
formfield: { default: "$hue.neutral.100", $selected: "$hue.interactive.600" },
feedback: { error: { default: "$hue.red.100" } },
} satisfies BackgroundDefinition

View file

@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { DEFAULT_THEMES, resolveTheme as resolveV1, selectedForeground } from "../../../src/theme"
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
@ -10,11 +10,22 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(migrated.standalone).toBeTrue()
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[500]).toBe(hex(legacy.secondary))
expect(migrated.light.background?.default).toBe(hex(legacy.background))
expect(migrated.light.background?.action?.primary?.default).toBe(hex(legacy.primary))
expect(migrated.light.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
expect(migrated.light.hue?.interactive).toBeObject()
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
throw new Error("Expected concrete accent and interactive scales")
}
expect(migrated.light.hue.accent[900]).toBe(hex(legacy.accent))
expect(migrated.light.hue.interactive[900]).toBe(hex(legacy.primary))
expect(migrated.light.text?.default).toBe("$hue.neutral.900")
expect(migrated.light.text?.subdued).toBe("$hue.neutral.700")
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
expect(migrated.light.background?.default).toBe("$hue.neutral.100")
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.200")
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.300")
expect(migrated.dark.background?.default).toBe("$hue.neutral.900")
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.800")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.700")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
@ -26,10 +37,8 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.hue.accent[500].toInts()).toEqual(legacy.secondary.toInts())
expect(resolved.hue.accent[300].r + resolved.hue.accent[300].g + resolved.hue.accent[300].b).toBeGreaterThan(
resolved.hue.accent[500].r + resolved.hue.accent[500].g + resolved.hue.accent[500].b,
)
expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts())
expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
@ -37,29 +46,83 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(resolved.contexts["@context:elevated"]?.background.action.secondary.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual(
legacy.primary.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(
selectedForeground(legacy, legacy.primary).toInts(),
legacy.text.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
legacy.backgroundMenu.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual(
legacy.primary.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
})
test("preserves V1 selected foreground behavior on transparent backgrounds", () => {
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const ambiguous = { light: "#808080", dark: "#808080" }
source.theme.accent = ambiguous
source.theme.warning = ambiguous
source.theme.primary = ambiguous
source.theme.error = ambiguous
source.theme.info = ambiguous
source.theme.secondary = "transparent"
source.theme.success = { light: "#ff6666", dark: "#450000" }
const migrated = migrateV1(source)
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")
expect(lightRed[900]).toBe("#ff6666")
expect(darkRed[100]).toBe("#450000")
expect(migrated.light.hue?.orange).toBe("$hue.gray")
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
expect(migrated.light.hue?.green).toBe("$hue.gray")
expect(migrated.light.hue?.cyan).toBe("$hue.gray")
expect(migrated.light.hue?.blue).toBe("$hue.gray")
expect(migrated.light.hue?.purple).toBe("$hue.gray")
expect(migrated.light.hue?.accent).toBe("$hue.gray")
expect(migrated.light.hue?.interactive).toBe("$hue.gray")
expect(() => resolveThemeFile(migrated, "light")).not.toThrow()
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
})
test("builds gray from V1 surfaces and text without using borders", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
const light = resolveV1(source, "light")
const dark = resolveV1(source, "dark")
const migrated = migrateV1(source)
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")
expect(lightGray[100]).toBe(hex(light.background))
expect(lightGray[200]).toBe(hex(light.backgroundPanel))
expect(lightGray[300]).toBe(hex(light.backgroundMenu))
expect(lightGray[700]).toBe(hex(light.textMuted))
expect(lightGray[900]).toBe(hex(light.text))
expect(darkGray[100]).toBe(hex(dark.text))
expect(darkGray[300]).toBe(hex(dark.textMuted))
expect(darkGray[700]).toBe(hex(dark.backgroundMenu))
expect(darkGray[800]).toBe(hex(dark.backgroundPanel))
expect(darkGray[900]).toBe(hex(dark.background))
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)
})
test("uses the default text reference for primary actions 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.text?.action?.primary?.default).toBe("#000000")
expect(migrated.dark.text?.action?.primary?.default).toBe("#ffffff")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
})
test("retains V1 circular reference errors", () => {