Quellcode durchsuchen

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

James Long vor 1 Monat
Ursprung
Commit
7a7075d86f

+ 22 - 11
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)
       })
 

+ 9 - 2
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 }) {
     >
       <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>

+ 1 - 1
packages/tui/src/routes/session/permission.tsx

@@ -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" }

+ 1 - 0
packages/tui/src/theme/v2/component.ts

@@ -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, {

+ 12 - 10
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" } },
       },
     },
   },

+ 0 - 1
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])

+ 24 - 22
packages/tui/src/theme/v2/schema.ts

@@ -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>
 

+ 133 - 47
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<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 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 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 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 = [

+ 1 - 0
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)

+ 26 - 4
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(
     {

+ 2 - 2
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
 

+ 83 - 20
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("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("preserves V1 selected foreground behavior on transparent backgrounds", () => {
+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", () => {