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
|
|
@ -1,4 +1,5 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { dedupeWith } from "effect/Array"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { useEvent } from "./event"
|
||||
|
|
@ -54,7 +55,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { theme, themeV2 } = useTheme()
|
||||
const { theme, themeV2, mode } = useTheme()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const args = useArgs()
|
||||
|
|
@ -83,15 +84,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const [agentStore, setAgentStore] = createStore({
|
||||
current: undefined as string | undefined,
|
||||
})
|
||||
const colors = createMemo(() => [
|
||||
themeV2.hue.accent(500),
|
||||
theme.accent,
|
||||
theme.success,
|
||||
theme.warning,
|
||||
theme.primary,
|
||||
theme.error,
|
||||
theme.info,
|
||||
])
|
||||
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 {
|
||||
list() {
|
||||
return agents()
|
||||
|
|
@ -441,7 +447,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -50,8 +50,9 @@ function Mcp(props: { context: Plugin.Context }) {
|
|||
}
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const { themeV2, mode, setMode } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const modeLabel = createMemo(() => (mode() === "dark" ? "Light mode" : "Dark mode"))
|
||||
const mcpWidth = createMemo(() => {
|
||||
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
|
||||
if (list.length === 0) return 0
|
||||
|
|
@ -72,10 +73,16 @@ function View(props: { context: Plugin.Context }) {
|
|||
>
|
||||
<Directory
|
||||
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} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0} onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}>
|
||||
<text fg={themeV2.text.action.secondary()}>{modeLabel()}</text>
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -656,7 +656,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
<box
|
||||
backgroundColor={themeV2.background()}
|
||||
border={["left"]}
|
||||
borderColor={themeV2.text.feedback.warning()}
|
||||
borderColor={themeV2.background.action.primary("focused")}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
{...(store.expanded
|
||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ test("provides reactive property, variant, state, and context accessors", () =>
|
|||
|
||||
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.text.subdued()).toBe(resolved().text.subdued)
|
||||
expect(theme.text.action()).toBe(resolved().text.action.primary.default)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ test("resolves independent definitions and hue aliases", () => {
|
|||
const darkTheme = resolveTheme(dark)
|
||||
|
||||
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.text.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.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
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"]?.text.action.primary.default).toBe(
|
||||
lightTheme.hue.neutral[100],
|
||||
)
|
||||
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"]?.text.action.primary.default).toBe(
|
||||
lightTheme.hue.neutral[100],
|
||||
)
|
||||
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(
|
||||
darkTheme.hue.neutral[100],
|
||||
)
|
||||
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(
|
||||
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", () => {
|
||||
const theme = resolveThemeFile(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ const background = {
|
|||
default: "$hue.neutral.100",
|
||||
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
|
||||
action: {
|
||||
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
|
||||
primary: { default: "$hue.interactive.600", $pressed: "$hue.interactive.800" },
|
||||
secondary: { default: "$hue.neutral.200" },
|
||||
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" } },
|
||||
} satisfies BackgroundDefinition
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { 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.light.hue?.accent).toBeObject()
|
||||
if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale")
|
||||
expect(migrated.light.hue.accent[500]).toBe(hex(legacy.secondary))
|
||||
expect(migrated.light.background?.default).toBe(hex(legacy.background))
|
||||
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?.interactive).toBeObject()
|
||||
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
|
||||
throw new Error("Expected concrete accent and interactive scales")
|
||||
}
|
||||
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.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
|
||||
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.selected.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[300].r + resolved.hue.accent[300].g + resolved.hue.accent[300].b).toBeGreaterThan(
|
||||
resolved.hue.accent[500].r + resolved.hue.accent[500].g + resolved.hue.accent[500].b,
|
||||
)
|
||||
expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts())
|
||||
expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
|
||||
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
|
||||
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(
|
||||
legacy.backgroundPanel.toInts(),
|
||||
)
|
||||
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual(
|
||||
legacy.primary.toInts(),
|
||||
)
|
||||
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
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(
|
||||
legacy.backgroundMenu.toInts(),
|
||||
)
|
||||
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual(
|
||||
legacy.primary.toInts(),
|
||||
)
|
||||
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
})
|
||||
|
||||
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)
|
||||
source.theme.background = "transparent"
|
||||
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
||||
delete source.theme.selectedListItemText
|
||||
const migrated = migrateV1(source)
|
||||
|
||||
expect(migrated.light.text?.action?.primary?.default).toBe("#000000")
|
||||
expect(migrated.dark.text?.action?.primary?.default).toBe("#ffffff")
|
||||
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
|
||||
})
|
||||
|
||||
test("retains V1 circular reference errors", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue