Explorar o código

feat(tui): add categorical theme hues (#37985)

James Long hai 3 semanas
pai
achega
79d0077f63

+ 2 - 15
packages/tui/src/context/local.tsx

@@ -41,10 +41,7 @@ export function parseModel(model: string) {
   }
 }
 
-export function recentModels(
-  model: ModelPreferenceModel,
-  recent: ModelPreferenceModel[],
-) {
+export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
   const seen = new Set<string>()
   return [model, ...recent]
     .filter((item) => {
@@ -94,17 +91,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       })
       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 dedupeWith(themeV2.categorical(step), (first, second) => first.equals(second))
       })
       return {
         list() {

+ 22 - 26
packages/tui/src/theme/v2/component.ts

@@ -1,11 +1,6 @@
 import type { RGBA } from "@opentui/core"
 import type { Accessor } from "solid-js"
-import type {
-  ActionVariant,
-  Mode,
-  ResolvedActionState,
-  ResolvedThemeView,
-} from "./index"
+import type { ActionVariant, Mode, ResolvedActionState, ResolvedThemeView } from "./index"
 import { ActionState, HueStep } from "./schema"
 
 type StateFlags = Partial<Record<ActionState, boolean>>
@@ -77,6 +72,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode:
 
   return {
     hue,
+    categorical: (step: HueStep) => current().categorical.map((scale) => scale[step]),
     source: (color: RGBA) => current().source(color),
     increase: (color: RGBA, amount = 1) => current().increase(color, amount),
     decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
@@ -86,29 +82,29 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode:
     border: () => current().border.default,
     scrollbar: () => current().scrollbar.default,
     diff: {
-        text: {
-          added: () => current().diff.text.added,
-          removed: () => current().diff.text.removed,
-          context: () => current().diff.text.context,
-          hunkHeader: () => current().diff.text.hunkHeader,
-        },
+      text: {
+        added: () => current().diff.text.added,
+        removed: () => current().diff.text.removed,
+        context: () => current().diff.text.context,
+        hunkHeader: () => current().diff.text.hunkHeader,
+      },
+      background: {
+        added: () => current().diff.background.added,
+        removed: () => current().diff.background.removed,
+        context: () => current().diff.background.context,
+      },
+      highlight: {
+        added: () => current().diff.highlight.added,
+        removed: () => current().diff.highlight.removed,
+      },
+      lineNumber: {
+        text: () => current().diff.lineNumber.text,
         background: {
-          added: () => current().diff.background.added,
-          removed: () => current().diff.background.removed,
-          context: () => current().diff.background.context,
-        },
-        highlight: {
-          added: () => current().diff.highlight.added,
-          removed: () => current().diff.highlight.removed,
-        },
-        lineNumber: {
-          text: () => current().diff.lineNumber.text,
-          background: {
-            added: () => current().diff.lineNumber.background.added,
-            removed: () => current().diff.lineNumber.background.removed,
-          },
+          added: () => current().diff.lineNumber.background.added,
+          removed: () => current().diff.lineNumber.background.removed,
         },
       },
+    },
     syntax: {
       comment: () => current().syntax.comment,
       keyword: () => current().syntax.keyword,

+ 12 - 1
packages/tui/src/theme/v2/defaults.ts

@@ -1,4 +1,13 @@
-import type { ThemeFile } from "./index"
+import type { HueName, ThemeFile } from "./schema"
+
+export const DEFAULT_CATEGORICAL = [
+  "blue",
+  "purple",
+  "green",
+  "orange",
+  "red",
+  "cyan",
+] as const satisfies readonly HueName[]
 
 export const DEFAULT_THEME = {
   version: 2,
@@ -96,6 +105,7 @@ export const DEFAULT_THEME = {
       interactive: "$hue.blue",
       neutral: "$hue.gray",
     },
+    categorical: DEFAULT_CATEGORICAL,
     text: {
       default: "$hue.neutral.800",
       subdued: "$hue.neutral.600",
@@ -308,6 +318,7 @@ export const DEFAULT_THEME = {
       interactive: "$hue.blue",
       neutral: "$hue.gray",
     },
+    categorical: DEFAULT_CATEGORICAL,
     text: {
       default: "$hue.neutral.200",
       subdued: "$hue.neutral.400",

+ 4 - 0
packages/tui/src/theme/v2/index.ts

@@ -3,10 +3,12 @@ export {
   type ActionStateKey,
   ActionVariant,
   BaseHue,
+  CategoricalDefinition,
   FeedbackKind,
   FormfieldState,
   type FormfieldStateKey,
   HueAlias,
+  HueName,
   HueStep,
   MarkdownDefinition,
   MarkdownToken,
@@ -30,6 +32,7 @@ export {
 } from "./schema"
 
 export type {
+  Categorical,
   FormfieldColor,
   Hue,
   HueSource,
@@ -40,5 +43,6 @@ export type {
   ResolvedThemeView,
   StatefulColor,
 } from "./types"
+export { DEFAULT_CATEGORICAL } from "./defaults"
 export { migrateV1 } from "./v1-migrate"
 export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"

+ 14 - 12
packages/tui/src/theme/v2/resolve.ts

@@ -1,6 +1,6 @@
 import { RGBA } from "@opentui/core"
 import { Schema } from "effect"
-import { DEFAULT_THEME } from "./defaults"
+import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
 import { expandTheme, expandTokens, mergeTheme } from "./expand"
 import { fallback } from "./fallback"
 import {
@@ -56,11 +56,12 @@ export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name
   const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
   const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
   const core = expandTokens(fallback())
-  const merged = decoded.standalone
-    ? mergeTheme(core, definition)
-    : mergeTheme(core, defaults, definition)
+  const merged = decoded.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
   if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
-  return resolveExpandedTheme(merged as ThemeDefinition)
+  return resolveExpandedTheme({
+    ...merged,
+    categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
+  } as ThemeDefinition)
 }
 
 export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
@@ -69,15 +70,16 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
 
 function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
   const hue = resolveHue(definition.hue)
+  const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
   const hueSteps = compileHueSteps(hue)
   const base = tokens(definition)
-  const resolved = resolveView(base, hue, hueSteps)
+  const resolved = resolveView(base, hue, categorical, hueSteps)
   const contexts = Object.fromEntries(
     Object.entries(definition)
       .filter(([key]) => key.startsWith("@context:"))
       .map(([key, override]) => {
         const contextual = contextualize(base, override as ThemeTokensDefinition)
-        return [key, resolveView(contextual, hue, hueSteps)]
+        return [key, resolveView(contextual, hue, categorical, hueSteps)]
       }),
   )
 
@@ -141,15 +143,14 @@ function contextualActions(
 function resolveView(
   definition: ThemeTokensDefinition,
   hue: ResolvedThemeView["hue"],
+  categorical: ResolvedThemeView["categorical"],
   hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
 ): ResolvedThemeView {
   const source: Record<string, unknown> = { hue, ...definition }
-  return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, ...hueSteps }
+  return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps }
 }
 
-function compileHueSteps(
-  hue: ResolvedThemeView["hue"],
-): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
+function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
   const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
   for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
     HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
@@ -201,7 +202,8 @@ function resolveHue(definition: HueDefinition) {
       }),
     ) as HueScale
     for (const step of Object.keys(value)) {
-      if (!HueStep.literals.includes(Number(step) as HueStep)) throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
+      if (!HueStep.literals.includes(Number(step) as HueStep))
+        throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
     }
     cache.set(name, result)
     return result

+ 7 - 1
packages/tui/src/theme/v2/schema.ts

@@ -34,7 +34,10 @@ const ColorValue = Schema.Union([
   Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
 ])
 
-const HueName = Schema.Union([BaseHue, HueAlias])
+export const HueName = Schema.Union([BaseHue, HueAlias])
+export type HueName = Schema.Schema.Type<typeof HueName>
+export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1))
+export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
 const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
 
 const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
@@ -214,6 +217,7 @@ export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinit
 
 const ThemeDefinitionFields = Schema.Struct({
   hue: HueDefinition,
+  categorical: Schema.optional(CategoricalDefinition),
   ...ThemeTokensDefinition.fields,
   "@context:elevated": Schema.optional(ThemeTokensDefinition),
   "@context:overlay": Schema.optional(ThemeTokensDefinition),
@@ -223,6 +227,7 @@ export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
 
 const FileThemeDefinition = Schema.Struct({
   hue: Schema.optional(HueOverrideDefinition),
+  categorical: Schema.optional(CategoricalDefinition),
   ...ThemeTokensDefinition.fields,
   "@context:elevated": Schema.optional(ThemeTokensDefinition),
   "@context:overlay": Schema.optional(ThemeTokensDefinition),
@@ -232,6 +237,7 @@ export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
 const MergeModeDefinition = Schema.Struct({
   mergeMode: Schema.Literal(true),
   hue: Schema.optional(HueOverrideDefinition),
+  categorical: Schema.optional(CategoricalDefinition),
   ...ThemeTokensDefinition.fields,
   "@context:elevated": Schema.optional(ThemeTokensDefinition),
   "@context:overlay": Schema.optional(ThemeTokensDefinition),

+ 2 - 0
packages/tui/src/theme/v2/types.ts

@@ -16,11 +16,13 @@ export type ResolvedFormfieldState = ResolvedActionState
 export type HueScale = Readonly<Record<HueStep, RGBA>>
 export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
 export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
+export type Categorical = readonly HueScale[]
 export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
 export type FormfieldColor = StatefulColor
 
 export type ResolvedThemeView = {
   readonly hue: Hue
+  readonly categorical: Categorical
   readonly source: (color: RGBA) => HueSource | undefined
   readonly increase: (color: RGBA, amount?: number) => RGBA
   readonly decrease: (color: RGBA, amount?: number) => RGBA

+ 49 - 18
packages/tui/src/theme/v2/v1-migrate.ts

@@ -1,14 +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 { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
 import type { FileThemeDefinition, Mode, ThemeFile } from "./index"
 import { HueStep } from "./schema"
 
 type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
 type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
+type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
 
 const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
+const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
 const minimumChroma = 0.03
 const lightThreshold = 0.6
 
@@ -44,6 +46,11 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
   const selected = hex(selectedForeground(theme, theme.primary))
   const destructive = hex(selectedForeground(theme, theme.error))
   const hues = inferHues(theme, mode)
+  const categorical = categoricalTokens.flatMap((token) => {
+    const hue = hues.byToken[token]
+    return hue ? [hue] : []
+  })
+  const uniqueCategorical = categorical.filter((hue, index) => categorical.indexOf(hue) === index)
   const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
   const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
   const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
@@ -56,7 +63,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
       gray: neutralScale(theme, mode),
       ...Object.fromEntries(
         chromaticHues.map((name) => {
-          const match = hues[name]
+          const match = hues.byHue[name]
           return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
         }),
       ),
@@ -64,6 +71,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
       interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
       neutral: "$hue.gray",
     },
+    categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
     text: {
       default: text,
       subdued: textMuted,
@@ -172,22 +180,45 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
 }
 
 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 } }
-  }, {})
+  const colors: readonly [V1HueToken, RGBA][] = [
+    ["accent", theme.accent],
+    ["success", theme.success],
+    ["warning", theme.warning],
+    ["primary", theme.primary],
+    ["error", theme.error],
+    ["info", theme.info],
+    ["secondary", theme.secondary],
+  ]
+  return colors.reduce<{
+    byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
+    byToken: Partial<Record<V1HueToken, ChromaticHue>>
+  }>(
+    (result, [token, color]) => {
+      const nearest = inferHue(color, mode)
+      if (!nearest) return result
+      const current = result.byHue[nearest.name]
+      return {
+        byHue:
+          current && current.distance <= nearest.distance
+            ? result.byHue
+            : { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
+        byToken: { ...result.byToken, [token]: nearest.name },
+      }
+    },
+    { byHue: {}, byToken: {} },
+  )
+}
+
+function inferHue(color: RGBA, mode: Mode) {
+  const value = toOklch(color)
+  if (ambiguous(color, value.c)) return
+  const anchor = inferenceAnchor(value.l)
+  return 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]
 }
 
 function inferenceAnchor(lightness: number): HueStep {

+ 8 - 16
packages/tui/test/theme/v2/component.test.ts

@@ -13,13 +13,14 @@ test("provides reactive property, variant, state, and context accessors", () =>
   const [context, setContext] = createSignal<ContextKey>()
   const theme = createComponentTheme(() => {
     const key = context()
-    return key ? resolved().contexts[key] ?? resolved() : resolved()
+    return key ? (resolved().contexts[key] ?? resolved()) : resolved()
   }, mode)
 
   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.categorical(500)).toEqual(resolved().categorical.map((scale) => scale[500]))
   expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[400])
   expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[400])
   expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100])
@@ -39,30 +40,20 @@ test("provides reactive property, variant, state, and context accessors", () =>
   expect(theme.background.action("selected")).toBe(resolved().background.action.primary.selected)
   expect(theme.background.action("hovered")).toBe(resolved().background.action.primary.hovered)
   expect(theme.background.action({ selected: true })).toBe(resolved().background.action.primary.selected)
-  expect(theme.background.action({ selected: true, hovered: true })).toBe(
-    resolved().background.action.primary.selected,
-  )
-  expect(theme.background.action({ focused: true, selected: true })).toBe(
-    resolved().background.action.primary.focused,
-  )
+  expect(theme.background.action({ selected: true, hovered: true })).toBe(resolved().background.action.primary.selected)
+  expect(theme.background.action({ focused: true, selected: true })).toBe(resolved().background.action.primary.focused)
   expect(theme.background.action({ pressed: true, focused: true, selected: true })).toBe(
     resolved().background.action.primary.pressed,
   )
-  expect(
-    theme.background.action({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }),
-  ).toBe(
+  expect(theme.background.action({ disabled: true, pressed: true, focused: true, selected: true, hovered: true })).toBe(
     resolved().background.action.primary.disabled,
   )
   expect(theme.background.action({ disabled: false, selected: false })).toBe(
     resolved().background.action.primary.default,
   )
-  expect(theme.background.action.destructive("disabled")).toBe(
-    resolved().background.action.destructive.disabled,
-  )
+  expect(theme.background.action.destructive("disabled")).toBe(resolved().background.action.destructive.disabled)
   expect(theme.background.formfield("hovered")).toBe(resolved().background.formfield.hovered)
-  expect(theme.background.formfield({ selected: true, hovered: true })).toBe(
-    resolved().background.formfield.selected,
-  )
+  expect(theme.background.formfield({ selected: true, hovered: true })).toBe(resolved().background.formfield.selected)
   expect(theme.background.formfield({ focused: true, selected: true, hovered: true })).toBe(
     resolved().background.formfield.focused,
   )
@@ -75,6 +66,7 @@ test("provides reactive property, variant, state, and context accessors", () =>
   expect(theme.diff.text.added()).toBe(resolved().diff.text.added)
 
   setContext("@context:elevated")
+  expect(theme.categorical(500)).toEqual(resolved().categorical.map((scale) => scale[500]))
   expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
   expect(theme.background.action("focused")).toBe(
     resolved().contexts["@context:elevated"]!.background.action.primary.focused,

+ 27 - 0
packages/tui/test/theme/v2/resolve.test.ts

@@ -14,6 +14,8 @@ test("resolves one-mode files with defaults for the available mode", () => {
 
   expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
   expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
+  expect(resolvedLight.categorical.length).toBeGreaterThan(0)
+  expect(resolvedDark.categorical.length).toBeGreaterThan(0)
 })
 
 test("rejects theme files without a mode", () => {
@@ -21,6 +23,30 @@ test("rejects theme files without a mode", () => {
   expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme")
 })
 
+test("validates and resolves categorical hues in configured order", () => {
+  const theme = resolveThemeFile({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light")
+
+  expect(theme.categorical[0]).toBe(theme.hue.accent)
+  expect(theme.categorical[1]).toBe(theme.hue.red)
+  expect(theme.categorical[2]).toBe(theme.hue.interactive)
+  expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical)
+  expect(() => resolveThemeFile({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
+  expect(() =>
+    resolveThemeFile(
+      // @ts-expect-error Runtime decoding rejects unknown categorical hue names.
+      { version: 2, light: { categorical: ["magenta"] } },
+      "light",
+    ),
+  ).toThrow("Invalid theme")
+})
+
+test("uses the default categorical order for direct definitions", () => {
+  const theme = resolveTheme({ ...light, categorical: undefined })
+
+  expect(theme.categorical[0]).toBe(theme.hue.blue)
+  expect(theme.categorical[1]).toBe(theme.hue.purple)
+})
+
 test("resolves independent definitions and hue aliases", () => {
   const lightTheme = resolveTheme(light)
   const darkTheme = resolveTheme(dark)
@@ -31,6 +57,7 @@ test("resolves independent definitions and hue aliases", () => {
   expect(lightTheme.hue.interactive[500].equals(lightTheme.hue.blue[500])).toBeTrue()
   expect(lightTheme.hue.neutral).not.toBe(lightTheme.hue.gray)
   expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
+  expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
   expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
   expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
   expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })

+ 15 - 2
packages/tui/test/theme/v2/select.test.ts

@@ -3,8 +3,12 @@ import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/the
 import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
 
 const hue = {} as HueDefinition
-const light = { hue, text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
-const dark = { hue, text: { default: "#eeeeee", subdued: "#dddddd" } } satisfies ThemeDefinition
+const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
+const dark = {
+  hue,
+  categorical: ["purple"],
+  text: { default: "#eeeeee", subdued: "#dddddd" },
+} satisfies ThemeDefinition
 
 test("requires and selects independent light and dark themes", () => {
   const file = { version: 2, light, dark } satisfies ThemeFile
@@ -27,6 +31,15 @@ test("merges an expanded mode override over the other mode", () => {
   expect(selected.text?.subdued).toBe("$text.default")
 })
 
+test("replaces categorical order in a merge mode", () => {
+  const selected = selectTheme(
+    { version: 2, light, dark: { mergeMode: true, categorical: ["accent", "cyan"] } },
+    "dark",
+  )
+
+  expect(selected.categorical).toEqual(["accent", "cyan"])
+})
+
 test("selects the available mode when the requested mode is missing", () => {
   const lightOnly = { version: 2, light } satisfies ThemeFile
   const darkOnly = { version: 2, dark } satisfies ThemeFile

+ 2 - 0
packages/tui/test/theme/v2/types.test.ts

@@ -40,6 +40,7 @@ const background = {
 
 const definition = {
   hue: {} as ThemeDefinition["hue"],
+  categorical: ["blue", "accent"],
   text,
   background,
   border: { default: "$hue.neutral.300" },
@@ -66,6 +67,7 @@ test("supports property-first definitions, variants, states, and contexts", () =
   expect(background.surface.offset).toBe("$hue.neutral.200")
   expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
   expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
+  expect(definition.categorical).toEqual(["blue", "accent"])
   expect(file.light).toBe(definition)
   expect(lightOnly.light).toBe(definition)
   expect(darkOnly.dark).toBe(definition)

+ 38 - 0
packages/tui/test/theme/v2/v1-migrate.test.ts

@@ -3,6 +3,7 @@ import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
 import { resolveThemeFile } from "../../../src/theme/v2/resolve"
 import { selectThemeMode, themeModes } from "../../../src/theme/v2/select"
 import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
+import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults"
 
 test("migrates resolved V1 modes into literal V2 tokens", () => {
   const migrated = migrateV1(DEFAULT_THEMES.opencode)
@@ -11,6 +12,8 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
   const resolved = resolveThemeFile(migrated, "light")
 
   expect(migrated.standalone).toBeTrue()
+  expect(migrated.light.categorical?.length).toBeGreaterThan(0)
+  expect(migrated.dark.categorical?.length).toBeGreaterThan(0)
   expect(migrated.light.hue?.accent).toBeObject()
   expect(migrated.light.hue?.interactive).toBeObject()
   if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
@@ -84,6 +87,41 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
   expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
 })
 
+test("orders categorical hues by V1 semantic color mapping", () => {
+  const source = structuredClone(DEFAULT_THEMES.opencode)
+  const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
+    light: DEFAULT_THEME.light.hue[name][700],
+    dark: DEFAULT_THEME.dark.hue[name][300],
+  })
+  source.theme.secondary = mapped("purple")
+  source.theme.accent = mapped("orange")
+  source.theme.success = mapped("green")
+  source.theme.warning = mapped("yellow")
+  source.theme.primary = mapped("blue")
+  source.theme.error = mapped("red")
+
+  const migrated = migrateV1(source)
+  expect(migrated.light?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
+  expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
+
+  source.theme.accent = source.theme.secondary
+  expect(migrateV1(source).light?.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
+})
+
+test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
+  const source = structuredClone(DEFAULT_THEMES.opencode)
+  source.theme.secondary = "transparent"
+  source.theme.accent = "transparent"
+  source.theme.success = "transparent"
+  source.theme.warning = "transparent"
+  source.theme.primary = "transparent"
+  source.theme.error = "transparent"
+
+  const migrated = migrateV1(source)
+  expect(migrated.light?.categorical).toEqual(DEFAULT_CATEGORICAL)
+  expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
+})
+
 test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
   const source = structuredClone(DEFAULT_THEMES.opencode)
   source.theme.background = { light: "#eeeeee", dark: "#111111" }