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

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