feat(tui): add hovered theme state (#37404)
This commit is contained in:
parent
2180fb8e7b
commit
b26d948b76
12 changed files with 97 additions and 45 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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`]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue