feat(tui): refine V2 theme colors (#37346)
This commit is contained in:
parent
c764732aea
commit
7a7075d86f
12 changed files with 314 additions and 120 deletions
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 mix(color: RGBA, target: number, amount: number) {
|
||||
const [r, g, b, a] = color.toInts()
|
||||
return hexInts(
|
||||
Math.round(r + (target - r) * amount),
|
||||
Math.round(g + (target - g) * amount),
|
||||
Math.round(b + (target - b) * amount),
|
||||
a,
|
||||
)
|
||||
function neutralScale(theme: Theme, mode: "light" | "dark") {
|
||||
const anchors = neutralAnchors(theme, mode)
|
||||
return Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
const exact = anchors.find((anchor) => anchor.step === step)
|
||||
if (exact) return [step, hex(exact.color)]
|
||||
const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
|
||||
const upper = anchors.find((anchor) => anchor.step > step)!
|
||||
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
|
||||
}),
|
||||
) as Record<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 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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue