diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 5bd2b1bfef..5f8e5cdc9c 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -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) }) diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 53daa59366..6446dcca7a 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -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 }) { > + setMode(mode() === "dark" ? "light" : "dark")}> + {modeLabel()} + {InstallationVersion} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index ffe1fb8dab..9fb23e15e0 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -656,7 +656,7 @@ function Prompt>(props: { ) { 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, { diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts index 9f48dbe5a5..c1dcdec3a9 100644 --- a/packages/tui/src/theme/v2/defaults.ts +++ b/packages/tui/src/theme/v2/defaults.ts @@ -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" } }, }, }, }, diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts index d063a9d3be..7a9d3e0983 100644 --- a/packages/tui/src/theme/v2/resolve.ts +++ b/packages/tui/src/theme/v2/resolve.ts @@ -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]) diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts index 02a6f753c7..26f032f2c9 100644 --- a/packages/tui/src/theme/v2/schema.ts +++ b/packages/tui/src/theme/v2/schema.ts @@ -6,7 +6,7 @@ export type HueStep = Schema.Schema.Type export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"]) export type BaseHue = Schema.Schema.Type -export const HueAlias = Schema.Literals(["accent", "neutral"]) +export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"]) export type HueAlias = Schema.Schema.Type 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 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 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 diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts index 814cfe52ed..70129419d4 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -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 +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> + >((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 } -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 +} + +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 = [ diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 456f57aefa..cb0b46c800 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -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) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index a6f2d429d2..e029aa23b1 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -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( { diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts index f19666b937..eec8f7d9d3 100644 --- a/packages/tui/test/theme/v2/types.test.ts +++ b/packages/tui/test/theme/v2/types.test.ts @@ -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 diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 6271d81ae7..d9c00c0973 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -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", () => {