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

This commit is contained in:
James Long
2026-07-16 14:01:25 -04:00
committed by GitHub
parent c764732aea
commit 7a7075d86f
12 changed files with 314 additions and 120 deletions
+22 -11
View File
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js" import { batch, createEffect, createMemo } from "solid-js"
import { useEvent } from "./event" import { useEvent } from "./event"
@@ -54,7 +55,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData() const data = useData()
const client = useClient() const client = useClient()
const toast = useToast() const toast = useToast()
const { theme, themeV2 } = useTheme() const { theme, themeV2, mode } = useTheme()
const route = useRoute() const route = useRoute()
const paths = useTuiPaths() const paths = useTuiPaths()
const args = useArgs() const args = useArgs()
@@ -83,15 +84,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore({ const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined, current: undefined as string | undefined,
}) })
const colors = createMemo(() => [ const colors = createMemo(() => {
themeV2.hue.accent(500), const step = mode() === "light" ? 800 : 200
theme.accent, return dedupeWith(
theme.success, [
theme.warning, themeV2.hue.blue(step),
theme.primary, themeV2.hue.purple(step),
theme.error, themeV2.hue.green(step),
theme.info, themeV2.hue.orange(step),
]) themeV2.hue.red(step),
themeV2.hue.cyan(step),
],
(first, second) => first.equals(second),
)
})
return { return {
list() { list() {
return agents() return agents()
@@ -441,7 +447,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
const slots = createMemo(() => { 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) return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
}) })
@@ -50,8 +50,9 @@ function Mcp(props: { context: Plugin.Context }) {
} }
function View(props: { context: Plugin.Context }) { function View(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme() const { themeV2, mode, setMode } = useTheme()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const modeLabel = createMemo(() => (mode() === "dark" ? "Light mode" : "Dark mode"))
const mcpWidth = createMemo(() => { const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
if (list.length === 0) return 0 if (list.length === 0) return 0
@@ -72,10 +73,16 @@ function View(props: { context: Plugin.Context }) {
> >
<Directory <Directory
context={props.context} 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} /> <Mcp context={props.context} />
<box flexGrow={1} /> <box flexGrow={1} />
<box flexShrink={0} onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}>
<text fg={themeV2.text.action.secondary()}>{modeLabel()}</text>
</box>
<box flexShrink={0}> <box flexShrink={0}>
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text> <text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
</box> </box>
@@ -656,7 +656,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box <box
backgroundColor={themeV2.background()} backgroundColor={themeV2.background()}
border={["left"]} border={["left"]}
borderColor={themeV2.text.feedback.warning()} borderColor={themeV2.background.action.primary("focused")}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded {...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" } ? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
+1
View File
@@ -25,6 +25,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
blue: (step: HueStep) => current().hue.blue[step], blue: (step: HueStep) => current().hue.blue[step],
purple: (step: HueStep) => current().hue.purple[step], purple: (step: HueStep) => current().hue.purple[step],
accent: (step: HueStep) => current().hue.accent[step], accent: (step: HueStep) => current().hue.accent[step],
interactive: (step: HueStep) => current().hue.interactive[step],
neutral: (step: HueStep) => current().hue.neutral[step], neutral: (step: HueStep) => current().hue.neutral[step],
} }
const text = Object.assign(() => current().text.default, { const text = Object.assign(() => current().text.default, {
+12 -10
View File
@@ -93,6 +93,7 @@ export const DEFAULT_THEME = {
900: "#581c87", 900: "#581c87",
}, },
accent: "$hue.blue", accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray", neutral: "$hue.gray",
}, },
text: { text: {
@@ -108,7 +109,7 @@ export const DEFAULT_THEME = {
$focused: "$text.action.primary.default", $focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100", $pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500", $disabled: "$hue.neutral.500",
$selected: "$hue.accent.600", $selected: "$hue.interactive.600",
}, },
feedback: { feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" }, error: { default: "$hue.red.700", subdued: "$hue.red.600" },
@@ -125,7 +126,7 @@ export const DEFAULT_THEME = {
}, },
action: { action: {
primary: { 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", $disabled: "$hue.neutral.300",
}, },
secondary: { secondary: {
@@ -140,7 +141,7 @@ export const DEFAULT_THEME = {
formfield: { formfield: {
default: "$background.default", default: "$background.default",
$focused: "$background.action.primary.default", $focused: "$background.action.primary.default",
$pressed: "$hue.accent.800", $pressed: "$hue.interactive.800",
$disabled: "$background.default", $disabled: "$background.default",
$selected: "$background.formfield.default", $selected: "$background.formfield.default",
}, },
@@ -196,14 +197,14 @@ export const DEFAULT_THEME = {
text: { action: { primary: { default: "$hue.neutral.100" } } }, text: { action: { primary: { default: "$hue.neutral.100" } } },
background: { background: {
default: "$background.surface.offset", default: "$background.surface.offset",
action: { primary: { default: "$hue.accent.500" } }, action: { primary: { default: "$hue.interactive.500" } },
}, },
}, },
"@context:overlay": { "@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } }, text: { action: { primary: { default: "$hue.neutral.100" } } },
background: { background: {
default: "$background.surface.overlay", 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", 900: "#581c87",
}, },
accent: "$hue.blue", accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray", neutral: "$hue.gray",
}, },
text: { text: {
@@ -313,7 +315,7 @@ export const DEFAULT_THEME = {
$focused: "$text.action.primary.default", $focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100", $pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500", $disabled: "$hue.neutral.500",
$selected: "$hue.accent.500", $selected: "$hue.interactive.500",
}, },
feedback: { feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" }, error: { default: "$hue.red.300", subdued: "$hue.red.400" },
@@ -330,7 +332,7 @@ export const DEFAULT_THEME = {
}, },
action: { action: {
primary: { 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", $disabled: "$hue.neutral.800",
}, },
secondary: { secondary: {
@@ -345,7 +347,7 @@ export const DEFAULT_THEME = {
formfield: { formfield: {
default: "$background.default", default: "$background.default",
$focused: "$background.action.primary.default", $focused: "$background.action.primary.default",
$pressed: "$hue.accent.800", $pressed: "$hue.interactive.800",
$disabled: "$background.default", $disabled: "$background.default",
$selected: "$background.formfield.default", $selected: "$background.formfield.default",
}, },
@@ -401,14 +403,14 @@ export const DEFAULT_THEME = {
text: { action: { primary: { default: "$hue.neutral.100" } } }, text: { action: { primary: { default: "$hue.neutral.100" } } },
background: { background: {
default: "$background.surface.offset", default: "$background.surface.offset",
action: { primary: { default: "$hue.accent.400" } }, action: { primary: { default: "$hue.interactive.400" } },
}, },
}, },
"@context:overlay": { "@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.900" } } }, text: { action: { primary: { default: "$hue.neutral.900" } } },
background: { background: {
default: "$background.surface.overlay", default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.400" } }, action: { primary: { default: "$hue.interactive.400" } },
}, },
}, },
}, },
-1
View File
@@ -157,7 +157,6 @@ function resolveHue(definition: HueDefinition) {
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`) if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
const value = source[name] const value = source[name]
if (typeof value === "string") { 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) const match = /^\$hue\.([^.]+)$/.exec(value)
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`) if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
const result = resolve(match[1], [...stack, name]) const result = resolve(match[1], [...stack, name])
+24 -22
View File
@@ -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 const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
export type BaseHue = Schema.Schema.Type<typeof BaseHue> 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 type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"]) 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> export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor) 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({ const HueDefinition = Schema.Struct({
gray: HueScaleDefinition, gray: HueValueDefinition,
red: HueScaleDefinition, red: HueValueDefinition,
orange: HueScaleDefinition, orange: HueValueDefinition,
yellow: HueScaleDefinition, yellow: HueValueDefinition,
green: HueScaleDefinition, green: HueValueDefinition,
cyan: HueScaleDefinition, cyan: HueValueDefinition,
blue: HueScaleDefinition, blue: HueValueDefinition,
purple: HueScaleDefinition, purple: HueValueDefinition,
accent: HueAliasDefinition, accent: HueValueDefinition,
neutral: HueAliasDefinition, interactive: HueValueDefinition,
neutral: HueValueDefinition,
}) })
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition> export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueOverrideDefinition = Schema.Struct({ const HueOverrideDefinition = Schema.Struct({
gray: Schema.optional(HueScaleDefinition), gray: Schema.optional(HueValueDefinition),
red: Schema.optional(HueScaleDefinition), red: Schema.optional(HueValueDefinition),
orange: Schema.optional(HueScaleDefinition), orange: Schema.optional(HueValueDefinition),
yellow: Schema.optional(HueScaleDefinition), yellow: Schema.optional(HueValueDefinition),
green: Schema.optional(HueScaleDefinition), green: Schema.optional(HueValueDefinition),
cyan: Schema.optional(HueScaleDefinition), cyan: Schema.optional(HueValueDefinition),
blue: Schema.optional(HueScaleDefinition), blue: Schema.optional(HueValueDefinition),
purple: Schema.optional(HueScaleDefinition), purple: Schema.optional(HueValueDefinition),
accent: Schema.optional(HueAliasDefinition), accent: Schema.optional(HueValueDefinition),
neutral: Schema.optional(HueAliasDefinition), interactive: Schema.optional(HueValueDefinition),
neutral: Schema.optional(HueValueDefinition),
}) })
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition> export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
+133 -47
View File
@@ -1,9 +1,16 @@
import { RGBA } from "@opentui/core" import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
import type { Theme, ThemeJson } from "../index" import type { Theme, ThemeJson } from "../index"
import { DEFAULT_THEME } from "./defaults" import { DEFAULT_THEME } from "./defaults"
import type { ThemeFile } from "./index" import type { ThemeFile } from "./index"
import { HueStep } from "./schema"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText"> 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 { export function migrateV1(theme: ThemeJson): ThemeFile {
return { return {
@@ -18,33 +25,48 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
const color = (key: ThemeColor) => hex(theme[key]) const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary)) const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error)) 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 { return {
hue: { hue: {
...DEFAULT_THEME[mode].hue, gray: neutralScale(theme, mode),
accent: hueScale(theme.secondary), ...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: { text: {
default: color("text"), default: text,
subdued: color("textMuted"), subdued: textMuted,
action: { action: {
primary: { primary: {
default: selected, default: "$text.default",
$disabled: color("textMuted"), $disabled: textMuted,
$focused: selected, $focused: selected,
}, },
secondary: { secondary: {
default: "$text.default", default: "$text.default",
$disabled: color("textMuted"), $disabled: textMuted,
}, },
destructive: { default: destructive, $disabled: color("textMuted") }, destructive: { default: destructive, $disabled: textMuted },
}, },
formfield: { formfield: {
default: color("text"), default: text,
$focused: color("primary"), $focused: primary,
$pressed: color("primary"), $pressed: primary,
$disabled: color("textMuted"), $disabled: textMuted,
$selected: color("primary"), $selected: primary,
}, },
feedback: { feedback: {
error: { default: color("error") }, error: { default: color("error") },
@@ -54,13 +76,13 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
}, },
}, },
background: { background: {
default: color("background"), default: background,
surface: { surface: {
offset: color("backgroundPanel"), offset: backgroundPanel,
overlay: color("backgroundMenu"), overlay: backgroundMenu,
}, },
action: { action: {
primary: { default: color("primary"), $focused: color("primary") }, primary: { default: "transparent", $focused: primary },
secondary: { secondary: {
default: "$background.default", default: "$background.default",
$focused: color("backgroundElement"), $focused: color("backgroundElement"),
@@ -131,21 +153,44 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
"@context:elevated": { "@context:elevated": {
background: { background: {
default: "$background.surface.offset", default: "$background.surface.offset",
action: {
primary: {
default: color("primary"),
$focused: color("primary"),
},
secondary: {
default: "$background.surface.offset",
},
},
}, },
}, },
"@context:overlay": { background: { default: "$background.surface.overlay" } }, "@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 { function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
const defs = theme.defs ?? {} const defs = theme.defs ?? {}
@@ -192,28 +237,66 @@ function selectedForeground(theme: Theme, background: RGBA) {
: RGBA.fromInts(255, 255, 255) : RGBA.fromInts(255, 255, 255)
} }
function hueScale(color: RGBA) { function hueScale(color: RGBA, mode: "light" | "dark") {
return { const value = toOklch(color)
100: mix(color, 255, 0.8), const anchor = mode === "light" ? 900 : 100
200: mix(color, 255, 0.6), const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
300: mix(color, 255, 0.4), const alpha = color.toInts()[3]
400: mix(color, 255, 0.2), return Object.fromEntries(
500: hex(color), HueStep.literals.map((step) => {
600: mix(color, 0, 0.15), if (step === anchor) return [step, hex(color)]
700: mix(color, 0, 0.3), const progress = mode === "light" ? (900 - step) / 800 : (step - 100) / 800
800: mix(color, 0, 0.45), const generated = oklchToHex({
900: mix(color, 0, 0.6), 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) { function neutralScale(theme: Theme, mode: "light" | "dark") {
const [r, g, b, a] = color.toInts() const anchors = neutralAnchors(theme, mode)
return hexInts( return Object.fromEntries(
Math.round(r + (target - r) * amount), HueStep.literals.map((step) => {
Math.round(g + (target - g) * amount), const exact = anchors.find((anchor) => anchor.step === step)
Math.round(b + (target - b) * amount), if (exact) return [step, hex(exact.color)]
a, 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) { function hex(color: RGBA) {
@@ -221,10 +304,13 @@ function hex(color: RGBA) {
} }
function hexInts(r: number, g: number, b: number, a: number) { 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)}` 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) { function ansi(code: number) {
if (code < 16) { if (code < 16) {
const colors = [ const colors = [
@@ -16,6 +16,7 @@ test("provides reactive property, variant, state, and context accessors", () =>
expect(theme.text()).toBe(resolved().text.default) expect(theme.text()).toBe(resolved().text.default)
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500]) 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.hue.gray(200)).toBe(resolved().hue.gray[200])
expect(theme.text.subdued()).toBe(resolved().text.subdued) expect(theme.text.subdued()).toBe(resolved().text.subdued)
expect(theme.text.action()).toBe(resolved().text.action.primary.default) expect(theme.text.action()).toBe(resolved().text.action.primary.default)
+26 -4
View File
@@ -13,6 +13,7 @@ test("resolves independent definitions and hue aliases", () => {
const darkTheme = resolveTheme(dark) const darkTheme = resolveTheme(dark)
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue) 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.hue.neutral).toBe(lightTheme.hue.gray)
expect(lightTheme.text.default).toBeInstanceOf(RGBA) expect(lightTheme.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.background.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.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( 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"]?.background.default).toBe(lightTheme.background.surface.offset)
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe( expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
lightTheme.hue.neutral[100], lightTheme.hue.neutral[100],
) )
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe( 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"]?.background.default).toBe(lightTheme.background.surface.overlay)
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe( expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
lightTheme.hue.neutral[100], lightTheme.hue.neutral[100],
) )
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( 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( expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[100], darkTheme.hue.neutral[100],
) )
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe( 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( expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[900], 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", () => { test("merges partial files with the selected OpenCode defaults", () => {
const theme = resolveThemeFile( const theme = resolveThemeFile(
{ {
+2 -2
View File
@@ -19,11 +19,11 @@ const background = {
default: "$hue.neutral.100", default: "$hue.neutral.100",
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" }, surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
action: { action: {
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" }, primary: { default: "$hue.interactive.600", $pressed: "$hue.interactive.800" },
secondary: { default: "$hue.neutral.200" }, secondary: { default: "$hue.neutral.200" },
destructive: { default: "$hue.red.600" }, 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" } }, feedback: { error: { default: "$hue.red.100" } },
} satisfies BackgroundDefinition } satisfies BackgroundDefinition
+83 -20
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test" 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 { resolveThemeFile } from "../../../src/theme/v2/resolve"
import { migrateV1 } from "../../../src/theme/v2/v1-migrate" 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.standalone).toBeTrue()
expect(migrated.light.hue?.accent).toBeObject() 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?.interactive).toBeObject()
expect(migrated.light.hue.accent[500]).toBe(hex(legacy.secondary)) if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
expect(migrated.light.background?.default).toBe(hex(legacy.background)) throw new Error("Expected concrete accent and interactive scales")
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.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.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg)) expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph)) 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.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts()) expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.formfield.focused.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[900].toInts()).toEqual(legacy.accent.toInts())
expect(resolved.hue.accent[300].r + resolved.hue.accent[300].g + resolved.hue.accent[300].b).toBeGreaterThan( expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
resolved.hue.accent[500].r + resolved.hue.accent[500].g + resolved.hue.accent[500].b,
)
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts()) expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual( expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(), 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( expect(resolved.contexts["@context:elevated"]?.background.action.secondary.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(), legacy.backgroundPanel.toInts(),
) )
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual( expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
legacy.primary.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual( 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( expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
legacy.backgroundMenu.toInts(), legacy.backgroundMenu.toInts(),
) )
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual( expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
legacy.primary.toInts(),
)
}) })
test("preserves V1 selected foreground behavior on transparent backgrounds", () => { 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("uses the default text reference for primary actions on transparent backgrounds", () => {
const source = structuredClone(DEFAULT_THEMES.opencode) const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.background = "transparent" source.theme.background = "transparent"
source.theme.primary = { light: "#ffffff", dark: "#000000" } source.theme.primary = { light: "#ffffff", dark: "#000000" }
delete source.theme.selectedListItemText delete source.theme.selectedListItemText
const migrated = migrateV1(source) const migrated = migrateV1(source)
expect(migrated.light.text?.action?.primary?.default).toBe("#000000") expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.dark.text?.action?.primary?.default).toBe("#ffffff") expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
}) })
test("retains V1 circular reference errors", () => { test("retains V1 circular reference errors", () => {