diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx
index 5bd2b1bfef..5f8e5cdc9c 100644
--- a/packages/tui/src/context/local.tsx
+++ b/packages/tui/src/context/local.tsx
@@ -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)
})
diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx
index 53daa59366..6446dcca7a 100644
--- a/packages/tui/src/feature-plugins/home/footer.tsx
+++ b/packages/tui/src/feature-plugins/home/footer.tsx
@@ -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 }) {
>
+ setMode(mode() === "dark" ? "light" : "dark")}>
+ {modeLabel()}
+
{InstallationVersion}
diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx
index ffe1fb8dab..9fb23e15e0 100644
--- a/packages/tui/src/routes/session/permission.tsx
+++ b/packages/tui/src/routes/session/permission.tsx
@@ -656,7 +656,7 @@ function Prompt>(props: {
) {
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, {
diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts
index 9f48dbe5a5..c1dcdec3a9 100644
--- a/packages/tui/src/theme/v2/defaults.ts
+++ b/packages/tui/src/theme/v2/defaults.ts
@@ -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" } },
},
},
},
diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts
index d063a9d3be..7a9d3e0983 100644
--- a/packages/tui/src/theme/v2/resolve.ts
+++ b/packages/tui/src/theme/v2/resolve.ts
@@ -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])
diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts
index 02a6f753c7..26f032f2c9 100644
--- a/packages/tui/src/theme/v2/schema.ts
+++ b/packages/tui/src/theme/v2/schema.ts
@@ -6,7 +6,7 @@ export type HueStep = Schema.Schema.Type
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
export type BaseHue = Schema.Schema.Type
-export const HueAlias = Schema.Literals(["accent", "neutral"])
+export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
export type HueAlias = Schema.Schema.Type
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
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
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
diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts
index 814cfe52ed..70129419d4 100644
--- a/packages/tui/src/theme/v2/v1-migrate.ts
+++ b/packages/tui/src/theme/v2/v1-migrate.ts
@@ -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
+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>
+ >((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
}
-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
+}
+
+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 = [
diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts
index 456f57aefa..cb0b46c800 100644
--- a/packages/tui/test/theme/v2/component.test.ts
+++ b/packages/tui/test/theme/v2/component.test.ts
@@ -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)
diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts
index a6f2d429d2..e029aa23b1 100644
--- a/packages/tui/test/theme/v2/resolve.test.ts
+++ b/packages/tui/test/theme/v2/resolve.test.ts
@@ -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(
{
diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts
index f19666b937..eec8f7d9d3 100644
--- a/packages/tui/test/theme/v2/types.test.ts
+++ b/packages/tui/test/theme/v2/types.test.ts
@@ -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
diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts
index 6271d81ae7..d9c00c0973 100644
--- a/packages/tui/test/theme/v2/v1-migrate.test.ts
+++ b/packages/tui/test/theme/v2/v1-migrate.test.ts
@@ -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", () => {