feat(tui): add hovered theme state (#37404)

This commit is contained in:
James Long 2026-07-17 10:19:42 -04:00 committed by GitHub
commit b26d948b76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 97 additions and 45 deletions

View file

@ -20,7 +20,7 @@ export function DevToolsSidebar() {
{(group) => (
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.background.action()} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text.action()} attributes={TextAttributes.BOLD}>
{group.title}
</text>
</box>

View file

@ -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()}
>
<text fg={themeV2.text()}>
Parent <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.parent")}</span>
@ -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()}
>
<text fg={themeV2.text()}>
Prev <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.child.previous")}</span>
@ -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()}
>
<text fg={themeV2.text()}>
Next <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.child.next")}</span>

View file

@ -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<Record<ActionState, boolean>>
type StateFlags = Partial<Record<ActionState, boolean>>
export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
const textAction = actions((variant, state) => current().text.action[variant][state])
@ -124,18 +122,21 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
}
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<typeof createComponentTheme>

View file

@ -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": {

View file

@ -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 extends ModeDefinition>(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`]),
),
}
}

View file

@ -8,7 +8,6 @@ import {
ActionVariant,
BaseHue,
FeedbackKind,
FormfieldState,
HueAlias,
HueStep,
ThemeDefinition,
@ -244,7 +243,7 @@ function createResolver(source: Record<string, unknown>) {
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<string, unknown>, path: string) {

View file

@ -12,12 +12,12 @@ export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
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<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const FormfieldState = Schema.Literals(["focused", "pressed", "disabled", "selected"])
export type FormfieldState = Schema.Schema.Type<typeof FormfieldState>
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<typeof HueOverrideDefinit
const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$hovered: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
@ -82,14 +83,7 @@ const StatefulColorDefinition = Schema.Struct({
})
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
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<typeof FormfieldColorDefinition>
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),

View file

@ -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<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = Readonly<Record<ResolvedFormfieldState, RGBA>>
export type FormfieldColor = StatefulColor
export type ResolvedThemeView = {
readonly hue: Hue

View file

@ -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" } },
}
}