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

This commit is contained in:
James Long 2026-07-20 15:28:53 -04:00 committed by GitHub
commit 79d0077f63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 202 additions and 91 deletions

View file

@ -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() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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