From b26d948b76f606cf8fb52f3cc75f159c691f45e8 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Jul 2026 10:19:42 -0400 Subject: [PATCH] feat(tui): add hovered theme state (#37404) --- .../tui/src/component/devtools-sidebar.tsx | 2 +- .../src/routes/session/subagent-footer.tsx | 6 ++--- packages/tui/src/theme/v2/component.ts | 23 ++++++++++--------- packages/tui/src/theme/v2/defaults.ts | 10 ++++++-- packages/tui/src/theme/v2/expand.ts | 7 +++--- packages/tui/src/theme/v2/resolve.ts | 3 +-- packages/tui/src/theme/v2/schema.ts | 20 ++++++---------- packages/tui/src/theme/v2/types.ts | 5 ++-- packages/tui/src/theme/v2/v1-migrate.ts | 10 ++++++-- packages/tui/test/theme/v2/component.test.ts | 20 +++++++++++++++- packages/tui/test/theme/v2/resolve.test.ts | 19 +++++++++++++++ packages/tui/test/theme/v2/types.test.ts | 17 +++++++++++--- 12 files changed, 97 insertions(+), 45 deletions(-) diff --git a/packages/tui/src/component/devtools-sidebar.tsx b/packages/tui/src/component/devtools-sidebar.tsx index 677e6691c9..ae2aafec09 100644 --- a/packages/tui/src/component/devtools-sidebar.tsx +++ b/packages/tui/src/component/devtools-sidebar.tsx @@ -20,7 +20,7 @@ export function DevToolsSidebar() { {(group) => ( - + {group.title} diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index a3da913043..6885013c11 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -83,7 +83,7 @@ export function SubagentFooter() { onMouseOver={() => setHover("parent")} onMouseOut={() => setHover(null)} onMouseUp={() => keymap.dispatch("session.parent")} - backgroundColor={hover() === "parent" ? themeV2.background.action("focused") : themeV2.background()} + backgroundColor={hover() === "parent" ? themeV2.background.action("hovered") : themeV2.background()} > Parent {shortcuts.get("session.parent")} @@ -93,7 +93,7 @@ export function SubagentFooter() { onMouseOver={() => setHover("prev")} onMouseOut={() => setHover(null)} onMouseUp={() => keymap.dispatch("session.child.previous")} - backgroundColor={hover() === "prev" ? themeV2.background.action("focused") : themeV2.background()} + backgroundColor={hover() === "prev" ? themeV2.background.action("hovered") : themeV2.background()} > Prev {shortcuts.get("session.child.previous")} @@ -103,7 +103,7 @@ export function SubagentFooter() { onMouseOver={() => setHover("next")} onMouseOut={() => setHover(null)} onMouseUp={() => keymap.dispatch("session.child.next")} - backgroundColor={hover() === "next" ? themeV2.background.action("focused") : themeV2.background()} + backgroundColor={hover() === "next" ? themeV2.background.action("hovered") : themeV2.background()} > Next {shortcuts.get("session.child.next")} diff --git a/packages/tui/src/theme/v2/component.ts b/packages/tui/src/theme/v2/component.ts index f6ffd765c7..7487473bd2 100644 --- a/packages/tui/src/theme/v2/component.ts +++ b/packages/tui/src/theme/v2/component.ts @@ -2,14 +2,12 @@ import type { RGBA } from "@opentui/core" import type { Accessor } from "solid-js" import type { ActionVariant, - FormfieldState, ResolvedActionState, - ResolvedFormfieldState, ResolvedThemeView, } from "./index" import { ActionState, HueStep } from "./schema" -export type ActionStates = Partial> +type StateFlags = Partial> export function createComponentTheme(current: Accessor) { const textAction = actions((variant, state) => current().text.action[variant][state]) @@ -124,18 +122,21 @@ export function createComponentTheme(current: Accessor) { } function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) { - const action = (variant: ActionVariant) => (states: ActionState | "default" | ActionStates = "default") => { - if (typeof states === "string") return get(variant, states) - return get(variant, ActionState.literals.find((state) => states[state]) ?? "default") - } - const primary = action("primary") + const primary = stateful((state) => get("primary", state)) return Object.assign(primary, { - destructive: action("destructive"), + destructive: stateful((state) => get("destructive", state)), }) } -function formfield(get: (state: ResolvedFormfieldState) => RGBA) { - return (state: FormfieldState | "default" = "default") => get(state) +function formfield(get: (state: ResolvedActionState) => RGBA) { + return stateful(get) +} + +function stateful(get: (state: ResolvedActionState) => RGBA) { + return (states: ActionState | "default" | StateFlags = "default") => { + if (typeof states === "string") return get(states) + return get(ActionState.literals.find((state) => states[state]) ?? "default") + } } export type ComponentTheme = ReturnType diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts index 79dcac2be0..f58b2af256 100644 --- a/packages/tui/src/theme/v2/defaults.ts +++ b/packages/tui/src/theme/v2/defaults.ts @@ -126,6 +126,7 @@ export const DEFAULT_THEME = { action: { primary: { default: "$hue.interactive.600", + $hovered: "$hue.interactive.700", $focused: "$hue.interactive.700", $pressed: "$hue.interactive.800", $selected: "$hue.interactive.700", @@ -133,6 +134,7 @@ export const DEFAULT_THEME = { }, destructive: { default: "$hue.red.600", + $hovered: "$hue.red.700", $focused: "$hue.red.700", $pressed: "$hue.red.800", $selected: "$hue.red.700", @@ -141,6 +143,7 @@ export const DEFAULT_THEME = { }, formfield: { default: "$background.default", + $hovered: "$background.surface.offset", $focused: "$background.action.primary.default", $pressed: "$hue.interactive.800", $disabled: "$background.default", @@ -200,7 +203,7 @@ export const DEFAULT_THEME = { text: { action: { primary: { default: "$hue.neutral.100" } } }, background: { default: "$background.surface.offset", - action: { primary: { default: "$hue.interactive.500" } }, + action: { primary: { default: "$hue.interactive.500", $hovered: "$background.surface.overlay" } }, }, }, "@context:overlay": { @@ -335,6 +338,7 @@ export const DEFAULT_THEME = { action: { primary: { default: "$hue.interactive.500", + $hovered: "$hue.interactive.600", $focused: "$hue.interactive.600", $pressed: "$hue.interactive.800", $selected: "$hue.interactive.600", @@ -342,6 +346,7 @@ export const DEFAULT_THEME = { }, destructive: { default: "$hue.red.600", + $hovered: "$hue.red.700", $focused: "$hue.red.700", $pressed: "$hue.red.800", $selected: "$hue.red.700", @@ -350,6 +355,7 @@ export const DEFAULT_THEME = { }, formfield: { default: "$background.default", + $hovered: "$background.surface.offset", $focused: "$background.action.primary.default", $pressed: "$hue.interactive.800", $disabled: "$background.default", @@ -409,7 +415,7 @@ export const DEFAULT_THEME = { text: { action: { primary: { default: "$hue.neutral.100" } } }, background: { default: "$background.surface.offset", - action: { primary: { default: "$hue.interactive.400" } }, + action: { primary: { default: "$hue.interactive.400", $hovered: "$background.surface.overlay" } }, }, }, "@context:overlay": { diff --git a/packages/tui/src/theme/v2/expand.ts b/packages/tui/src/theme/v2/expand.ts index e12237102d..6601c476a3 100644 --- a/packages/tui/src/theme/v2/expand.ts +++ b/packages/tui/src/theme/v2/expand.ts @@ -1,12 +1,11 @@ import type { BackgroundDefinition, - FormfieldColorDefinition, ModeDefinition, StatefulColorDefinition, TextDefinition, ThemeTokensDefinition, } from "./index" -import { ActionState, FormfieldState } from "./schema" +import { ActionState } from "./schema" export function expandTheme(definition: Definition): Definition { return { @@ -73,12 +72,12 @@ function expandBackground(definition: BackgroundDefinition | undefined): Backgro } } -function expandFormfield(definition: FormfieldColorDefinition | undefined, path: string) { +function expandFormfield(definition: StatefulColorDefinition | undefined, path: string) { if (!definition?.default) return definition return { ...definition, ...Object.fromEntries( - FormfieldState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]), + ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]), ), } } diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts index 7c2ebde323..a017494c5c 100644 --- a/packages/tui/src/theme/v2/resolve.ts +++ b/packages/tui/src/theme/v2/resolve.ts @@ -8,7 +8,6 @@ import { ActionVariant, BaseHue, FeedbackKind, - FormfieldState, HueAlias, HueStep, ThemeDefinition, @@ -244,7 +243,7 @@ function createResolver(source: Record) { function resolvedKey(key: string) { if (!key.startsWith("$")) return key const state = key.slice(1) - return ([...ActionState.literals, ...FormfieldState.literals] as readonly string[]).includes(state) ? state : key + return (ActionState.literals as readonly string[]).includes(state) ? state : key } function read(source: Record, path: string) { diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts index a04653b0bc..fcc512e99e 100644 --- a/packages/tui/src/theme/v2/schema.ts +++ b/packages/tui/src/theme/v2/schema.ts @@ -12,12 +12,12 @@ export type HueAlias = Schema.Schema.Type export const ActionVariant = Schema.Literals(["primary", "destructive"]) export type ActionVariant = Schema.Schema.Type -export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "selected"]) +export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "selected", "hovered"]) export type ActionState = Schema.Schema.Type export type ActionStateKey = `$${ActionState}` -export const FormfieldState = Schema.Literals(["focused", "pressed", "disabled", "selected"]) -export type FormfieldState = Schema.Schema.Type +export const FormfieldState = ActionState +export type FormfieldState = ActionState export type FormfieldStateKey = `$${FormfieldState}` export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"]) @@ -75,6 +75,7 @@ export type HueOverrideDefinition = Schema.Schema.Type -const FormfieldColorDefinition = Schema.Struct({ - default: Schema.optional(ColorValue), - $focused: Schema.optional(ColorValue), - $pressed: Schema.optional(ColorValue), - $disabled: Schema.optional(ColorValue), - $selected: Schema.optional(ColorValue), -}) -export type FormfieldColorDefinition = Schema.Schema.Type +export type FormfieldColorDefinition = StatefulColorDefinition const ActionColorDefinition = Schema.Struct({ primary: Schema.optional(StatefulColorDefinition), @@ -109,7 +103,7 @@ const TextDefinition = Schema.Struct({ default: Schema.optional(ColorValue), subdued: Schema.optional(ColorValue), action: Schema.optional(ActionColorDefinition), - formfield: Schema.optional(FormfieldColorDefinition), + formfield: Schema.optional(StatefulColorDefinition), feedback: Schema.optional( Schema.Struct({ error: Schema.optional(TextFeedbackDefinition), @@ -130,7 +124,7 @@ const BackgroundDefinition = Schema.Struct({ }), ), action: Schema.optional(ActionColorDefinition), - formfield: Schema.optional(FormfieldColorDefinition), + formfield: Schema.optional(StatefulColorDefinition), feedback: Schema.optional( Schema.Struct({ error: Schema.optional(BackgroundFeedbackDefinition), diff --git a/packages/tui/src/theme/v2/types.ts b/packages/tui/src/theme/v2/types.ts index b22c01c31c..e31ff17c25 100644 --- a/packages/tui/src/theme/v2/types.ts +++ b/packages/tui/src/theme/v2/types.ts @@ -4,7 +4,6 @@ import type { ActionVariant, BaseHue, FeedbackKind, - FormfieldState, HueAlias, HueStep, MarkdownToken, @@ -13,11 +12,11 @@ import type { } from "./schema" export type ResolvedActionState = "default" | ActionState -export type ResolvedFormfieldState = "default" | FormfieldState +export type ResolvedFormfieldState = ResolvedActionState export type HueScale = Readonly> export type Hue = Readonly> export type StatefulColor = Readonly> -export type FormfieldColor = Readonly> +export type FormfieldColor = StatefulColor export type ResolvedThemeView = { readonly hue: Hue diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts index 7138e95a6c..959a2bf48d 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -60,6 +60,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] { }, formfield: { default: text, + $hovered: primary, $focused: primary, $pressed: primary, $disabled: textMuted, @@ -79,7 +80,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] { overlay: backgroundMenu, }, action: { - primary: { default: "transparent", $focused: primary, $selected: primary }, + primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: primary }, destructive: { default: color("error") }, }, formfield: { @@ -142,7 +143,12 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] { imageText: color("markdownImageText"), codeBlock: color("markdownCodeBlock"), }, - "@context:elevated": { background: { default: "$background.surface.offset" } }, + "@context:elevated": { + background: { + default: "$background.surface.offset", + action: { primary: { $hovered: "$background.surface.overlay" } }, + }, + }, "@context:overlay": { background: { default: "$background.surface.overlay" } }, } } diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 8f69addb44..0c78db2b61 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -29,17 +29,24 @@ test("provides reactive property, variant, state, and context accessors", () => expect(theme.increase(unmatched, 1)).toBe(unmatched) expect(theme.text.subdued()).toBe(resolved().text.subdued) expect(theme.text.action()).toBe(resolved().text.action.primary.default) + expect(theme.text.action("hovered")).toBe(resolved().text.action.primary.hovered) expect(theme.text.action("pressed")).toBe(resolved().text.action.primary.pressed) expect(theme.text.action("selected")).toBe(resolved().text.action.primary.selected) expect(theme.background.action("selected")).toBe(resolved().background.action.primary.selected) + expect(theme.background.action("hovered")).toBe(resolved().background.action.primary.hovered) expect(theme.background.action({ selected: true })).toBe(resolved().background.action.primary.selected) + expect(theme.background.action({ selected: true, hovered: true })).toBe( + resolved().background.action.primary.selected, + ) expect(theme.background.action({ focused: true, selected: true })).toBe( resolved().background.action.primary.focused, ) expect(theme.background.action({ pressed: true, focused: true, selected: true })).toBe( resolved().background.action.primary.pressed, ) - expect(theme.background.action({ disabled: true, pressed: true, focused: true, selected: true })).toBe( + expect( + theme.background.action({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }), + ).toBe( resolved().background.action.primary.disabled, ) expect(theme.background.action({ disabled: false, selected: false })).toBe( @@ -48,6 +55,16 @@ test("provides reactive property, variant, state, and context accessors", () => expect(theme.background.action.destructive("disabled")).toBe( resolved().background.action.destructive.disabled, ) + expect(theme.background.formfield("hovered")).toBe(resolved().background.formfield.hovered) + expect(theme.background.formfield({ selected: true, hovered: true })).toBe( + resolved().background.formfield.selected, + ) + expect(theme.background.formfield({ focused: true, selected: true, hovered: true })).toBe( + resolved().background.formfield.focused, + ) + expect( + theme.background.formfield({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }), + ).toBe(resolved().background.formfield.disabled) expect(theme.background.surface.offset()).toBe(resolved().background.surface.offset) expect(theme.background.surface.overlay()).toBe(resolved().background.surface.overlay) expect(theme.scrollbar()).toBe(resolved().scrollbar.default) @@ -58,6 +75,7 @@ test("provides reactive property, variant, state, and context accessors", () => expect(theme.background.action("focused")).toBe( resolved().contexts["@context:elevated"]!.background.action.primary.focused, ) + expect(theme.background.action("hovered")).toBe(resolved().background.surface.overlay) expect(theme.background.formfield("selected")).toBe( resolved().contexts["@context:elevated"]!.background.formfield.selected, ) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index c5a31a9d18..6091390c62 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -144,10 +144,29 @@ test("resolves matched action variants and states", () => { const theme = resolveTheme(light) expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA) + expect(theme.text.action.primary.hovered).toBeInstanceOf(RGBA) expect(theme.text.action.primary.selected).toBeInstanceOf(RGBA) expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA) + expect(theme.background.action.primary.hovered).toBeInstanceOf(RGBA) expect(theme.background.action.primary.selected).toBeInstanceOf(RGBA) expect(theme.background.action.destructive.disabled).toBeInstanceOf(RGBA) + expect(theme.background.formfield.hovered).toBeInstanceOf(RGBA) +}) + +test("resolves elevated hover surfaces from direct colors", () => { + const theme = resolveThemeFile( + { + version: 2, + light: { background: { surface: { offset: "#123456", overlay: "#234567" } } }, + dark: {}, + }, + "light", + ) + + expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255]) + expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([ + 35, 69, 103, 255, + ]) }) test("resolves transparent colors", () => { diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts index d403319a4e..acbc008a6d 100644 --- a/packages/tui/test/theme/v2/types.test.ts +++ b/packages/tui/test/theme/v2/types.test.ts @@ -5,7 +5,11 @@ const text = { default: "$hue.neutral.900", subdued: "$hue.neutral.600", action: { - primary: { default: "$hue.neutral.100", $pressed: "$hue.neutral.200" }, + primary: { + default: "$hue.neutral.100", + $hovered: "$hue.neutral.200", + $pressed: "$hue.neutral.300", + }, destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" }, }, formfield: { default: "$hue.neutral.600", $selected: "$hue.neutral.100" }, @@ -20,12 +24,17 @@ const background = { action: { primary: { default: "$hue.interactive.600", + $hovered: "$hue.interactive.700", $pressed: "$hue.interactive.800", $selected: "$hue.interactive.700", }, destructive: { default: "$hue.red.600" }, }, - formfield: { default: "$hue.neutral.100", $selected: "$hue.interactive.600" }, + formfield: { + default: "$hue.neutral.100", + $hovered: "$hue.neutral.200", + $selected: "$hue.interactive.600", + }, feedback: { error: { default: "$hue.red.100" } }, } satisfies BackgroundDefinition @@ -44,10 +53,12 @@ const definition = { const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile test("supports property-first definitions, variants, states, and contexts", () => { - expect(text.action.primary.$pressed).toBe("$hue.neutral.200") + expect(text.action.primary.$hovered).toBe("$hue.neutral.200") + expect(text.action.primary.$pressed).toBe("$hue.neutral.300") expect(text.formfield.$selected).toBe("$hue.neutral.100") expect(background.action.destructive.default).toBe("$hue.red.600") expect(background.action.primary.$selected).toBe("$hue.interactive.700") + expect(background.formfield.$hovered).toBe("$hue.neutral.200") expect(background.surface.offset).toBe("$hue.neutral.200") expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800") expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")