diff --git a/packages/cli/dev/error-overlay.tsx b/packages/cli/dev/error-overlay.tsx index 82997b7ed1c..fbae6cbc81b 100644 --- a/packages/cli/dev/error-overlay.tsx +++ b/packages/cli/dev/error-overlay.tsx @@ -9,7 +9,7 @@ import { Keymap } from "../../tui/src/context/keymap" export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) { const renderer = useRenderer() const dimensions = useTerminalDimensions() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const focus = renderer.currentFocusedRenderable onCleanup(Keymap.use().mode.push("modal")) Keymap.createLayer(() => ({ diff --git a/packages/theme/src/tui/defaults.ts b/packages/theme/src/tui/defaults.ts index 7f3d0100a4c..1904d27faa1 100644 --- a/packages/theme/src/tui/defaults.ts +++ b/packages/theme/src/tui/defaults.ts @@ -218,20 +218,13 @@ export const DEFAULT_THEME = { imageText: "$hue.cyan.600", codeBlock: "$hue.neutral.900", }, - "@context:elevated": { + "@dialog": { text: { action: { primary: { default: "$hue.neutral.100" } } }, background: { default: "$background.raised.base", action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } }, }, }, - "@context:overlay": { - text: { action: { primary: { default: "$hue.neutral.100" } } }, - background: { - default: "$background.raised.high", - action: { primary: { default: "$hue.interactive.500" } }, - }, - }, }, dark: { hue: { @@ -440,19 +433,12 @@ export const DEFAULT_THEME = { imageText: "$hue.cyan.400", codeBlock: "$hue.neutral.100", }, - "@context:elevated": { + "@dialog": { text: { action: { primary: { default: "$hue.neutral.200" } } }, background: { default: "$background.raised.base", action: { primary: { default: "$hue.interactive.400", $hovered: "$background.raised.high" } }, }, }, - "@context:overlay": { - text: { action: { primary: { default: "$hue.neutral.200" } } }, - background: { - default: "$background.raised.high", - action: { primary: { default: "$hue.interactive.400" } }, - }, - }, }, } satisfies ThemeDocument diff --git a/packages/theme/src/tui/expand.ts b/packages/theme/src/tui/expand.ts index fb39a8d7e28..cb972352742 100644 --- a/packages/theme/src/tui/expand.ts +++ b/packages/theme/src/tui/expand.ts @@ -11,11 +11,7 @@ export function expandTheme(definition: Defin return { ...definition, ...expandTokens(definition), - ...Object.fromEntries( - Object.entries(definition) - .filter(([key]) => key.startsWith("@context:")) - .map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]), - ), + ...(definition["@dialog"] ? { "@dialog": expandTokens(definition["@dialog"]) } : {}), } } diff --git a/packages/theme/src/tui/index.ts b/packages/theme/src/tui/index.ts index d6c304cfeb4..75899cdef34 100644 --- a/packages/theme/src/tui/index.ts +++ b/packages/theme/src/tui/index.ts @@ -13,6 +13,7 @@ export { MarkdownDefinition, MarkdownToken, ModeDefinition, + SurfaceName, SyntaxDefinition, SyntaxToken, ThemeDefinition, @@ -26,14 +27,12 @@ export { type MergeModeDefinition, type Mode, type StatefulColorDefinition, - type ContextKey, type TextDefinition, type ThemeTokensDefinition, } from "./schema.js" export type { Categorical, - ContextName, FormfieldColor, Hue, HueSource, diff --git a/packages/theme/src/tui/resolve.ts b/packages/theme/src/tui/resolve.ts index 2d3860369b7..82064226770 100644 --- a/packages/theme/src/tui/resolve.ts +++ b/packages/theme/src/tui/resolve.ts @@ -7,17 +7,17 @@ import { ActionState, ActionVariant, BaseHue, - FeedbackKind, HueAlias, HueStep, + SurfaceName, ThemeDefinition, ThemeDocument, } from "./schema.js" import type { ActionStateKey, - ContextName, HueDefinition, HueScale, + Mode, ResolvedActionState, ResolvedTheme, ResolvedThemeTokens, @@ -42,40 +42,40 @@ export function themeDecodeError(error: unknown, name: string) { return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error }) } -export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") { +export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) { const selected = selectThemeMode(document, mode) const definition = selected.expanded ? selected.theme : expandTheme(selected.theme) const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode)) const core = expandTokens(fallback(selected.mode)) const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition) if (!merged["hue"]) throw new Error("Standalone themes must provide hues") - return resolveExpandedTheme({ - ...merged, - categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL, - } as ThemeDefinition) + return resolveExpandedTheme( + { + ...merged, + categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL, + } as ThemeDefinition, + selected.mode, + ) } -export function resolveTheme(definition: ThemeDefinition): ResolvedTheme { - return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition))) +export function resolveTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme { + return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)), mode) } -function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme { +function resolveExpandedTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme { const hue = resolveHue(definition.hue) const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name]) const hueSteps = compileHueSteps(hue) + const raise = (color: RGBA) => (mode === "light" ? hueSteps.increase(color) : hueSteps.decrease(color)) const base = tokens(definition) - const resolved = resolveView(base, hue, categorical, hueSteps) - const context = (name: ContextName) => { - const override = definition[`@context:${name}`] - if (!override) return resolved - return resolveView(contextualize(base, override), hue, categorical, hueSteps) - } - const contextual = { - elevated: context("elevated"), - overlay: context("overlay"), - } - - return { ...resolved, contextual } as ResolvedTheme + const views = {} as Record + const view = (tokens: ThemeTokensDefinition): ResolvedTheme => ({ + ...resolveView(tokens, hue, categorical, hueSteps), + raise, + surface: (name) => views[name], + }) + views.dialog = definition["@dialog"] ? view(contextualize(base, definition["@dialog"])) : view(base) + return view(base) } function tokens(definition: ThemeDefinition): ThemeTokensDefinition { @@ -92,27 +92,23 @@ function tokens(definition: ThemeDefinition): ThemeTokensDefinition { function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) { const result = mergeTheme(base, override) - const baseText = base.text?.action - const contextText = override.text?.action - const baseBackground = base.background?.action - const contextBackground = override.background?.action const text = result["text"] as NonNullable const background = result["background"] as NonNullable return { ...result, - text: { ...text, action: contextualActions(baseText, contextText) }, - background: { ...background, action: contextualActions(baseBackground, contextBackground) }, + text: { ...text, action: contextualActions(base.text?.action, override.text?.action) }, + background: { ...background, action: contextualActions(base.background?.action, override.background?.action) }, } as ThemeTokensDefinition } function contextualActions( base: Partial> | undefined, - context: Partial> | undefined, + surface: Partial> | undefined, ) { return Object.fromEntries( ActionVariant.literals.map((variant) => { const baseVariant = base?.[variant] - const contextVariant = context?.[variant] + const surfaceVariant = surface?.[variant] return [ variant, Object.fromEntries( @@ -120,8 +116,8 @@ function contextualActions( const key = state === "default" ? undefined : (`$${state}` as ActionStateKey) return [ key ?? "default", - (key ? contextVariant?.[key] : undefined) ?? - contextVariant?.default ?? + (key ? surfaceVariant?.[key] : undefined) ?? + surfaceVariant?.default ?? (key ? baseVariant?.[key] : undefined) ?? baseVariant?.default, ] diff --git a/packages/theme/src/tui/schema.ts b/packages/theme/src/tui/schema.ts index f3213f13152..bcf2b47fb7e 100644 --- a/packages/theme/src/tui/schema.ts +++ b/packages/theme/src/tui/schema.ts @@ -16,6 +16,9 @@ export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "s export type ActionState = Schema.Schema.Type export type ActionStateKey = `$${ActionState}` +export const SurfaceName = Schema.Literal("dialog") +export type SurfaceName = Schema.Schema.Type + export const FormfieldState = ActionState export type FormfieldState = ActionState export type FormfieldStateKey = `$${FormfieldState}` @@ -40,9 +43,6 @@ export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLen export type CategoricalDefinition = Schema.Schema.Type const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])]) -const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"]) -export type ContextKey = Schema.Schema.Type - const HueScaleDefinition = Schema.Record(HueStep, HexColor) const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition]) @@ -229,8 +229,7 @@ const ThemeDefinitionFields = Schema.Struct({ hue: HueDefinition, categorical: Schema.optional(CategoricalDefinition), ...ThemeTokensDefinition.fields, - "@context:elevated": Schema.optional(ThemeTokensDefinition), - "@context:overlay": Schema.optional(ThemeTokensDefinition), + "@dialog": Schema.optional(ThemeTokensDefinition), }) export const ThemeDefinition = ThemeDefinitionFields export type ThemeDefinition = Schema.Schema.Type @@ -239,8 +238,7 @@ const FileThemeDefinition = Schema.Struct({ hue: Schema.optional(HueOverrideDefinition), categorical: Schema.optional(CategoricalDefinition), ...ThemeTokensDefinition.fields, - "@context:elevated": Schema.optional(ThemeTokensDefinition), - "@context:overlay": Schema.optional(ThemeTokensDefinition), + "@dialog": Schema.optional(ThemeTokensDefinition), }) export type FileThemeDefinition = Schema.Schema.Type @@ -249,8 +247,7 @@ const MergeModeDefinition = Schema.Struct({ hue: Schema.optional(HueOverrideDefinition), categorical: Schema.optional(CategoricalDefinition), ...ThemeTokensDefinition.fields, - "@context:elevated": Schema.optional(ThemeTokensDefinition), - "@context:overlay": Schema.optional(ThemeTokensDefinition), + "@dialog": Schema.optional(ThemeTokensDefinition), }) export type MergeModeDefinition = Schema.Schema.Type export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition]) diff --git a/packages/theme/src/tui/types.ts b/packages/theme/src/tui/types.ts index 12a76c8cfe6..c5b4ef2e069 100644 --- a/packages/theme/src/tui/types.ts +++ b/packages/theme/src/tui/types.ts @@ -7,6 +7,7 @@ import type { HueAlias, HueStep, MarkdownToken, + SurfaceName, SyntaxToken, } from "./schema.js" @@ -69,8 +70,9 @@ export type ResolvedThemeTokens = { readonly markdown: Readonly> } -export type ContextName = "elevated" | "overlay" - export type ResolvedTheme = ResolvedThemeTokens & { - readonly contextual: Readonly> + /** Lift a ramp color one step away from the background: lighter in light mode, darker in dark mode. */ + readonly raise: (color: RGBA) => RGBA + /** The same theme re-resolved on a raised surface. Absolute: every view's surfaces are the base theme's. */ + readonly surface: (name: SurfaceName) => ResolvedTheme } diff --git a/packages/theme/src/tui/v1-migrate.ts b/packages/theme/src/tui/v1-migrate.ts index d27a8859c50..4843669d5d2 100644 --- a/packages/theme/src/tui/v1-migrate.ts +++ b/packages/theme/src/tui/v1-migrate.ts @@ -173,13 +173,12 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition { imageText: color("markdownImageText"), codeBlock: color("markdownCodeBlock"), }, - "@context:elevated": { + "@dialog": { background: { default: "$background.raised.base", action: { primary: { $hovered: "$background.raised.high" } }, }, }, - "@context:overlay": { background: { default: "$background.raised.high" } }, }) } diff --git a/packages/theme/test/status.test.ts b/packages/theme/test/status.test.ts index c47d6489dd2..a95ac6bcfb5 100644 --- a/packages/theme/test/status.test.ts +++ b/packages/theme/test/status.test.ts @@ -14,7 +14,7 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors" expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue() expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue() expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue() - expect(theme.contextual.elevated.text.status).toEqual(theme.text.status) + expect(theme.surface("dialog").text.status).toEqual(theme.text.status) } }) @@ -34,7 +34,7 @@ test.each(["light", "dark"] as const)("custom %s themes inherit the unread atten expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue() expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue() expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue() - expect(theme.contextual.elevated.text.status).toEqual(theme.text.status) + expect(theme.surface("dialog").text.status).toEqual(theme.text.status) } }) diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index 8b6f3efbe90..f301bfd719b 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -39,7 +39,7 @@ export function DevToolsBar() { const renderer = useRenderer() const dimensions = useTerminalDimensions() const { current: theme, mode, supports, setMode } = themes - const elevatedTheme = useTheme("elevated") + const elevatedTheme = useTheme() const [panel, setPanel] = createSignal() const [dumping, setDumping] = createSignal(false) const [dumpPath, setDumpPath] = createSignal() @@ -474,7 +474,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) { } function PanelBox(props: ParentProps) { - const theme = useTheme("elevated") + const theme = useTheme() const renderer = useRenderer() return ( { if (renderer.getSelection()?.getSelectedText()) return @@ -500,7 +500,7 @@ function PanelBox(props: ParentProps) { } function PanelTitle(props: ParentProps) { - const theme = useTheme("elevated") + const theme = useTheme() return ( {props.children} @@ -509,7 +509,7 @@ function PanelTitle(props: ParentProps) { } function Row(props: { label: string; value: string }) { - const theme = useTheme("elevated") + const theme = useTheme() return ( {props.label} @@ -520,12 +520,12 @@ function Row(props: { label: string; value: string }) { } function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) { - const theme = useTheme("elevated") + const theme = useTheme() const [hovered, setHovered] = createSignal(false) return ( setHovered(true)} onMouseOut={() => setHovered(false)} @@ -545,7 +545,7 @@ function cpuPercent(microseconds: number, milliseconds: number) { } function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) { - const theme = useTheme("elevated") + const theme = useTheme() const value = () => { const value = props.values.at(-1) if (value === undefined) return "--" diff --git a/packages/tui/src/component/dialog-error-details.tsx b/packages/tui/src/component/dialog-error-details.tsx index eeb5239cb40..2930bb1b651 100644 --- a/packages/tui/src/component/dialog-error-details.tsx +++ b/packages/tui/src/component/dialog-error-details.tsx @@ -27,7 +27,7 @@ export function DialogErrorDetails(props: { const location = useLocation() const route = useRoute() const toast = useToast() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const dimensions = useTerminalDimensions() const config = useConfig().data const [copied, setCopied] = createSignal(false) diff --git a/packages/tui/src/component/dialog-image-preview.tsx b/packages/tui/src/component/dialog-image-preview.tsx index 42fdfa78155..3d060e324db 100644 --- a/packages/tui/src/component/dialog-image-preview.tsx +++ b/packages/tui/src/component/dialog-image-preview.tsx @@ -13,7 +13,7 @@ type ImagePreviewItem = Readonly<{ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) { const dialog = useDialog() const dimensions = useTerminalDimensions() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial))) const [failed, setFailed] = createSignal(false) const current = createMemo(() => props.images[index()]) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 1704ebd9124..b89852125cf 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -78,7 +78,7 @@ export function DialogIntegration( const data = useData() const currentLocation = useLocation() const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const location = currentLocation.ref ?? data.location.default() const integrations = createMemo(() => integrationOptions(data.location.integration.list(location) ?? []).filter( @@ -153,7 +153,7 @@ function manageConnections( const data = useData() const client = useClient() const toast = useToast() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const shortcuts = Keymap.useShortcuts() const [deleting, setDeleting] = createSignal() const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id) @@ -427,8 +427,8 @@ function CommandPending(props: { function CommandView(props: { title: string; output: string; message: string }) { const dialog = useDialog() - const theme = useTheme("elevated") - const overlayTheme = useTheme("overlay") + const theme = useTheme().surface("dialog") + const overlayTheme = useTheme() onMount(() => dialog.setSize("large")) return ( @@ -441,7 +441,7 @@ function CommandView(props: { title: string; output: string; message: string }) () return ( @@ -672,7 +672,7 @@ function OAuthCode(props: { const dialog = useDialog() const client = useClient() const toast = useToast() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const [error, setError] = createSignal() let settled = false @@ -724,7 +724,7 @@ function OAuthView(props: { open?: boolean }) { const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") return ( @@ -852,7 +852,7 @@ function textAnswer( return new Promise((resolve) => { dialog.replace( () => { - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const [error, setError] = createSignal() return ( location.ref ?? data.location.default() const servers = createMemo(() => pipe( diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index b55c138f5c3..fee28144a15 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -44,7 +44,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: const sessionTabs = useSessionTabs() const toast = useToast() const themes = useThemes() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const mode = themes.mode const paths = useTuiPaths() const dimensions = useTerminalDimensions() diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx index 5f0aa2569f3..bc6ea06775a 100644 --- a/packages/tui/src/component/dialog-pair.tsx +++ b/packages/tui/src/component/dialog-pair.tsx @@ -17,7 +17,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) { const client = useClient() const dialog = useDialog() const dimensions = useTerminalDimensions() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const [loadError, setLoadError] = createSignal() const [showPassword, setShowPassword] = createSignal(false) const [passwordHover, setPasswordHover] = createSignal(false) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 4c234f63837..2cf2581e78e 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -30,7 +30,7 @@ export function DialogSessionList() { const route = useRoute() const data = useData() const themes = useThemes() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const mode = themes.mode const client = useClient() const local = useLocal() diff --git a/packages/tui/src/component/dialog-shell-output.tsx b/packages/tui/src/component/dialog-shell-output.tsx index 52e9e5a9a32..07f19c12944 100644 --- a/packages/tui/src/component/dialog-shell-output.tsx +++ b/packages/tui/src/component/dialog-shell-output.tsx @@ -13,7 +13,7 @@ const PAGE_BYTES = 64 * 1024 export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) { const client = useClient() const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const dimensions = useTerminalDimensions() const [info, setInfo] = createSignal(props.shell) const [output, setOutput] = createSignal() diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index cd8d12bcbf1..4113c7fd969 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string { export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const shortcuts = Keymap.useShortcuts() const [toDelete, setToDelete] = createSignal() diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx index 70d2e32a0a1..3fed887c9f9 100644 --- a/packages/tui/src/component/dialog-status.tsx +++ b/packages/tui/src/component/dialog-status.tsx @@ -6,7 +6,7 @@ import { For, Match, Switch, Show, createMemo } from "solid-js" export function DialogStatus() { const data = useData() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const dialog = useDialog() const mcp = createMemo(() => data.location.mcp.server.list() ?? []) diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx index e8c6618b661..aee62c5e3a4 100644 --- a/packages/tui/src/component/dialog-update.tsx +++ b/packages/tui/src/component/dialog-update.tsx @@ -15,7 +15,7 @@ export function DialogUpdate(props: { restart: () => void }) { const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const [error, setError] = createSignal() const [active, setActive] = createSignal(0) const controller = new AbortController() diff --git a/packages/tui/src/component/dialog-workspace-file-changes.tsx b/packages/tui/src/component/dialog-workspace-file-changes.tsx index 334467dd7df..6ed7d5d276b 100644 --- a/packages/tui/src/component/dialog-workspace-file-changes.tsx +++ b/packages/tui/src/component/dialog-workspace-file-changes.tsx @@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: { message?: string }) { const dialog = useDialog() - const theme = useTheme("elevated") - const overlayTheme = useTheme("overlay") + const theme = useTheme().surface("dialog") + const overlayTheme = useTheme() const config = useConfig().data const dimensions = useTerminalDimensions() const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) @@ -86,7 +86,7 @@ export function DialogWorkspaceFileChanges(props: { diff --git a/packages/tui/src/component/dialog-workspaces.tsx b/packages/tui/src/component/dialog-workspaces.tsx index 98ce47e6f3b..5cfcd10694d 100644 --- a/packages/tui/src/component/dialog-workspaces.tsx +++ b/packages/tui/src/component/dialog-workspaces.tsx @@ -40,7 +40,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) { const dialog = useDialog() const client = useClient() const dimensions = useTerminalDimensions() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const sessionData = useData() const route = useRoute() const toast = useToast() diff --git a/packages/tui/src/component/dialog-worktree-name.tsx b/packages/tui/src/component/dialog-worktree-name.tsx index 0bef10d54b1..7be47324084 100644 --- a/packages/tui/src/component/dialog-worktree-name.tsx +++ b/packages/tui/src/component/dialog-worktree-name.tsx @@ -8,7 +8,7 @@ import { useConfig } from "../config" export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) { const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const shortcuts = Keymap.useShortcuts() const config = useConfig().data const [inputTarget, setInputTarget] = createSignal() diff --git a/packages/tui/src/component/migration-overlay.tsx b/packages/tui/src/component/migration-overlay.tsx index 21943529af7..adfd390c95f 100644 --- a/packages/tui/src/component/migration-overlay.tsx +++ b/packages/tui/src/component/migration-overlay.tsx @@ -10,7 +10,7 @@ type Progress = { label: string; numerator?: number; denominator?: number } export function MigrationOverlay() { const client = useClient() const toast = useToast() - const theme = useTheme("overlay") + const theme = useTheme() const [progress, setProgress] = createSignal() const abort = new AbortController() @@ -52,7 +52,7 @@ export function MigrationOverlay() { top={1} right={2} flexDirection="row" - backgroundColor={theme.background.default} + backgroundColor={theme.background.raised.high} border={["left"]} borderColor={theme.text.feedback.info.default} customBorderChars={SplitBorder.customBorderChars} diff --git a/packages/tui/src/component/panel-host.tsx b/packages/tui/src/component/panel-host.tsx index 944275871bb..6c119ca4af0 100644 --- a/packages/tui/src/component/panel-host.tsx +++ b/packages/tui/src/component/panel-host.tsx @@ -2,7 +2,7 @@ import type { BoxRenderable } from "@opentui/core" import { onCleanup, onMount } from "solid-js" import { usePanel, type PanelTarget } from "../context/panel" import { InteractivityProvider } from "../context/interactivity" -import { ThemeContextProvider, useTheme } from "../context/theme" +import { useTheme } from "../context/theme" import { Slot } from "../plugin/render" export function PanelHost(props: { @@ -19,6 +19,9 @@ export function PanelHost(props: { const Content = () => { const theme = useTheme() + // Side panels sit on a raised surface; fullscreen takes over the base background. + const background = () => + panels.presentation() === "panel" ? theme.background.raised.base : theme.background.default return ( - - - + ) } diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 26060880cca..6e652bca88c 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -77,7 +77,7 @@ export function Autocomplete(props: { const data = useData() const keymap = Keymap.use() const keymapCommands = Keymap.useCommands() - const theme = useTheme("overlay") + const theme = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() const config = useConfig().data @@ -887,7 +887,7 @@ export function Autocomplete(props: { scroll = r scroll.verticalScrollBar.on("change", syncSelectionWindow) }} - backgroundColor={theme.background.default} + backgroundColor={theme.background.raised.high} height={height()} scrollbarOptions={{ visible: false }} scrollAcceleration={scrollAcceleration()} diff --git a/packages/tui/src/component/reconnecting.tsx b/packages/tui/src/component/reconnecting.tsx index bb7423e00eb..55c969028b6 100644 --- a/packages/tui/src/component/reconnecting.tsx +++ b/packages/tui/src/component/reconnecting.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { Spinner } from "./spinner" export function Reconnecting(props: { managed?: boolean }) { - const theme = useTheme("elevated") + const theme = useTheme() return ( - tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a), + tint(theme.background.raised.base, theme.background.raised.high, theme.background.raised.high.a), ) let pressed = false const search = () => (props.tabs.search ? props.tabs.search() : keymap.dispatch("session.list")) return ( - + setHovered(true)} onMouseOut={() => setHovered(false)} onMouseDown={(event) => { @@ -59,7 +59,7 @@ export function SessionTabsRailControls(props: { height={1} zIndex={2} fg={hoverColor()} - bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.default} + bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.raised.base} selectable={false} > {(hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)} diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index f76f4dd350b..3e48fb26995 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -366,7 +366,9 @@ export function createTabMarquee(animations: () => boolean) { function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) { const dimensions = useTerminalDimensions() - const theme = useTheme("elevated") + const theme = useTheme() + const background = () => theme.background.raised.base + const actionHovered = () => theme.background.raised.high const dialog = useDialog() onCleanup(Keymap.use().mode.push("menu")) Keymap.createLayer(() => ({ @@ -430,7 +432,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo height={actions().length} width={CONTEXT_MENU_WIDTH} flexDirection="column" - backgroundColor={theme.background.default} + backgroundColor={background()} onMouseDown={(event) => { if (event.button === RIGHT_MOUSE_BUTTON) props.onClose() event.preventDefault() @@ -443,7 +445,7 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo width="100%" paddingLeft={1} paddingRight={1} - backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined} + backgroundColor={selected() === index() ? actionHovered() : undefined} onMouseOver={() => setSelected(index())} onMouseOut={() => setSelected(undefined)} onMouseUp={(event) => { @@ -515,7 +517,10 @@ function VerticalSessionTabs(props: { const data = props.controller ? undefined : useData() const dimensions = useTerminalDimensions() const renderer = useRenderer() - const theme = useTheme("elevated") + const theme = useTheme() + const background = () => theme.background.raised.base + const actionSelected = () => theme.background.action.primary.selected + const actionHovered = () => theme.background.raised.high const base = useTheme() const config = useConfig().data const animations = () => props.animations ?? config.animations ?? true @@ -525,9 +530,9 @@ function VerticalSessionTabs(props: { const stride = () => (compact() ? 2 : 3) const unreadColor = () => theme.text.status.unread const activeNumber = () => theme.text.status.running - const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55) - const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04)) - const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05)) + const idleNumber = () => tint(theme.text.formfield.default, background(), 0.55) + const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.default, 0.04)) + const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.default, 0.05)) const [addHovered, setAddHovered] = createSignal(false) const marquee = createTabMarquee(animations) const hovered = marquee.hovered @@ -564,7 +569,7 @@ function VerticalSessionTabs(props: { }) const items = ordered const highlightColor = createMemo(() => - tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a), + tint(background(), actionHovered(), actionHovered().a), ) const highlighted = (sessionID: string | undefined) => sessionID !== undefined && (activeID() === sessionID || hovered() === sessionID || dragging() === sessionID) @@ -671,7 +676,7 @@ function VerticalSessionTabs(props: { flexDirection="column" position="relative" paddingTop={1} - backgroundColor={theme.background.default} + backgroundColor={background()} onMouseOut={marquee.leaveHovered} onMouseUp={(event) => { if (event.button === RIGHT_MOUSE_BUTTON) return @@ -694,7 +699,7 @@ function VerticalSessionTabs(props: { }} flexGrow={1} minHeight={0} - backgroundColor={theme.background.default} + backgroundColor={background()} scrollbarOptions={{ visible: false }} > @@ -732,13 +737,13 @@ function VerticalSessionTabs(props: { const detailFades = createMemo( () => marqueeOverflows(tabDetail(), titleWidth()) && titleWidth() > FADE_WIDTH, ) - const background = createMemo(() => { - if (selected() && !compact()) return theme.background.action.primary.selected + const tabBackground = createMemo(() => { + if (selected() && !compact()) return actionSelected() if ((compact() && selected()) || hovered() === tab.sessionID || dragging() === tab.sessionID) - return theme.background.action.primary.hovered - return theme.background.default + return actionHovered() + return background() }) - const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a)) + const pulseBackground = createMemo(() => tint(background(), tabBackground(), tabBackground().a)) const runs = () => status().runs const numberIgnition = createNumberIgnition(runs, () => status().promptPulse, animations) const numberColor = () => { @@ -815,10 +820,10 @@ function VerticalSessionTabs(props: { return lastPreviousGlowHue ?? unreadColor() } const separatorUpperColor = createMemo(() => - tint(theme.background.default, previousGlowHue(), 0.1 * previousGlowLevel()), + tint(background(), previousGlowHue(), 0.1 * previousGlowLevel()), ) const separatorLowerColor = createMemo(() => - tint(theme.background.default, glowHue(), 0.12 * glowLevel()), + tint(background(), glowHue(), 0.12 * glowLevel()), ) const titleColor = (index: number, separator: boolean) => { const level = titleGlow.value().level @@ -843,7 +848,7 @@ function VerticalSessionTabs(props: { width="100%" position="relative" flexDirection="column" - backgroundColor={background()} + backgroundColor={tabBackground()} onMouseOver={(event) => { setHoverY(event.y) marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth()) @@ -885,7 +890,7 @@ function VerticalSessionTabs(props: { width={width()} color={pulseBackground()} background={ - highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : theme.background.default + highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : background() } /> @@ -946,8 +951,8 @@ function VerticalSessionTabs(props: { color={separatorLowerPulseColor()} width={indicatorWidth} outerColor={separatorUpperPulseColor()} - flashColor={tint(theme.background.default, theme.text.default, 0.22)} - outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)} + flashColor={tint(background(), theme.text.default, 0.22)} + outerFlashColor={tint(background(), theme.text.default, 0.18)} flashTail={8} glowColor={separatorLowerColor()} outerGlowColor={separatorUpperColor()} @@ -955,7 +960,7 @@ function VerticalSessionTabs(props: { outerGlowTail={5} completionColor={separatorLowerColor()} outerCompletionColor={separatorUpperColor()} - backgroundColor={theme.background.default} + backgroundColor={background()} /> @@ -1113,10 +1118,10 @@ function VerticalSessionTabs(props: { alignItems="center" backgroundColor={ newTab() && !compact() - ? theme.background.action.primary.selected + ? actionSelected() : addHovered() || (compact() && newTab()) - ? theme.background.action.primary.hovered - : theme.background.default + ? actionHovered() + : background() } onMouseOver={() => setAddHovered(true)} onMouseOut={() => setAddHovered(false)} @@ -1145,14 +1150,14 @@ function VerticalSessionTabs(props: { edge="top" width={width()} color={highlightColor()} - background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : theme.background.default} + background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : background()} /> - + {Locale.truncateWidth( data?.session.get(sessionID())?.title ?? @@ -1231,7 +1236,7 @@ function VerticalSessionTabs(props: { top={3} edge="bottom" width={tooltipWidth()} - color={theme.background.default} + color={background()} background={base.background.default} /> diff --git a/packages/tui/src/component/startup-loading.tsx b/packages/tui/src/component/startup-loading.tsx index 22823d22443..bb6f489a7ae 100644 --- a/packages/tui/src/component/startup-loading.tsx +++ b/packages/tui/src/component/startup-loading.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { Spinner } from "./spinner" export function StartupLoading(props: { ready: () => boolean }) { - const theme = useTheme("elevated") + const theme = useTheme() const [show, setShow] = createSignal(false) const text = createMemo(() => (props.ready() ? "Finishing startup…" : "Loading plugins…")) let wait: NodeJS.Timeout | undefined @@ -54,7 +54,7 @@ export function StartupLoading(props: { ready: () => boolean }) { return ( - + {text()} diff --git a/packages/tui/src/component/terminal-pane.tsx b/packages/tui/src/component/terminal-pane.tsx index 05515c211a0..0f79611a771 100644 --- a/packages/tui/src/component/terminal-pane.tsx +++ b/packages/tui/src/component/terminal-pane.tsx @@ -32,7 +32,7 @@ export function TerminalPane(props: { const client = useClient() const keymap = Keymap.use() const leader = Keymap.useLeaderActive() - const theme = useTheme("elevated") + const theme = useTheme() const themes = useThemes() const renderer = useRenderer() const [failure, setFailure] = createSignal() @@ -155,8 +155,8 @@ export function TerminalPane(props: { }) createEffect(() => { - const tokens = themes.currentTokens().contextual.elevated - terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default) + const tokens = themes.currentTokens() + terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.raised.base) applyTerminalTheme() }) @@ -284,7 +284,7 @@ export function TerminalPane(props: { minWidth={0} minHeight={0} overflow="hidden" - backgroundColor={themes.currentTokens().contextual.elevated.background.default} + backgroundColor={themes.currentTokens().background.raised.base} onSizeChange={function () { size = { cols: Math.max(1, this.width - 2), rows: this.height } if (controller && restored) interact() diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 380c97b91e9..1c3b7e4a05a 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -5,7 +5,7 @@ import { resolveThemeDocument, themeModes, type ResolvedTheme, - type ContextName, + type SurfaceName, } from "@opencode/theme/tui" import { DEFAULT_THEMES, @@ -23,7 +23,7 @@ import { } from "../theme" import { generateSystem, terminalMode } from "../theme/system" import { discoverThemes } from "../theme/discovery" -import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component" +import { createComponentTheme, type ComponentTheme } from "../theme/component" import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -122,7 +122,7 @@ type Themes = { } type ThemeContextValue = { - current: ComponentTheme["contextual"][ContextName] + current: ComponentTheme themes: Themes readonly ready: boolean } @@ -322,7 +322,7 @@ const themeContext = createSimpleContext({ const tokens = () => selected().theme tokens() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) - const current = createComponentTheme(tokens, mode) + const current = createComponentTheme(tokens) createEffect(() => renderer.setBackgroundColor(tokens().background.default)) @@ -377,27 +377,18 @@ const themeContext = createSimpleContext({ export function useThemes() { return themeContext.use().themes } -export function useTheme(): ComponentTheme -export function useTheme(context: ContextName): ComponentTheme["contextual"][ContextName] -export function useTheme(context?: ContextName) { - const value = themeContext.use() - return context ? value.themes.current.contextual[context] : value.current +export function useTheme(): ComponentTheme { + return themeContext.use().current } export const ThemeProvider = themeContext.provider -function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors { - return Boolean( - colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]), - ) -} - -/** Switches context without remounting children; undefined inherits the enclosing view. */ -export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) { +/** Switches the ambient theme surface without remounting children; undefined inherits the enclosing view. */ +export function ThemeContextProvider(props: ParentProps<{ context: SurfaceName | undefined }>) { const value = themeContext.use() - const current = createComponentThemeView(() => { + const current = createComponentTheme(() => { const name = props.context - return name ? value.themes.currentTokens().contextual[name] : value.current - }, value.themes.mode) + return name ? value.themes.currentTokens().surface(name) : value.current + }) return ( {props.children} @@ -405,6 +396,12 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName | ) } +function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors { + return Boolean( + colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]), + ) +} + function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") { const document = parseTheme(source, name) const modes = themeModes(document) diff --git a/packages/tui/src/feature-plugins/prompt/btw.tsx b/packages/tui/src/feature-plugins/prompt/btw.tsx index 4c65a4fc767..1023f12a522 100644 --- a/packages/tui/src/feature-plugins/prompt/btw.tsx +++ b/packages/tui/src/feature-plugins/prompt/btw.tsx @@ -86,8 +86,8 @@ function Answer(props: { question: string; answer: string }) { const toast = useToast() const clipboard = useClipboard() const plugins = usePlugin() - const theme = useTheme("elevated") - const overlay = useTheme("overlay") + const theme = useTheme().surface("dialog") + const overlay = useTheme() const syntax = useThemes().currentSyntax const config = useConfig().data const [copied, setCopied] = createSignal(false) @@ -135,7 +135,7 @@ function Answer(props: { question: string; answer: string }) { (scroll = element)} maxHeight={20} - backgroundColor={overlay.background.default} + backgroundColor={overlay.background.raised.high} scrollbarOptions={{ visible: false }} scrollAcceleration={getScrollAcceleration(config)} > @@ -148,7 +148,7 @@ function Answer(props: { question: string; answer: string }) { internalBlockMode="top-level" tableOptions={{ style: "grid", cellPaddingX: 1 }} fg={overlay.markdown.text} - bg={overlay.background.default} + bg={overlay.background.raised.high} /> diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-file-menu.tsx b/packages/tui/src/feature-plugins/system/diff-viewer-file-menu.tsx index 06a8d3f5b80..baf0b01c15e 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer-file-menu.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer-file-menu.tsx @@ -12,7 +12,7 @@ export function DiffFileMenu(props: { onClose: () => void }) { const dimensions = useTerminalDimensions() - const theme = props.context.theme.contextual.overlay + const theme = props.context.theme const [hovered, setHovered] = createSignal(false) const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete") const width = () => Math.min(19, dimensions().width) @@ -63,7 +63,7 @@ export function DiffFileMenu(props: { height={1} paddingLeft={1} paddingRight={1} - backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default} + backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.raised.high} onMouseOver={() => setHovered(true)} onMouseOut={() => setHovered(false)} onMouseDown={(event) => { diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx index 0a2f2b33c46..cbf38e9762d 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx @@ -27,7 +27,7 @@ export type DiffViewerFileTreeProps = { } export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { - const theme = useTheme("elevated") + const theme = useTheme() const [sourceHovered, setSourceHovered] = createSignal(false) const list = () => props.layout === "list" const tree = createMemo(() => buildFileTree(props.files)) @@ -37,9 +37,9 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { : flattenFileTree(tree(), props.expandedNodes), ) // Quieter than subdued text: markers are affordances, not content. - const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45)) + const faint = createMemo(() => tint(theme.text.subdued, theme.background.raised.base, 0.45)) // Rails are pure texture; keep them barely above the surface. - const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.7)) + const rail = createMemo(() => tint(theme.text.subdued, theme.background.raised.base, 0.7)) const reviewedCount = createMemo(() => props.files.filter((file) => props.reviewedFileNames?.has(file.file)).length) const contentWidth = () => Math.max(0, props.width - 4 - FILE_TREE_STATUS_WIDTH - 1) let scroll: ScrollBoxRenderable | undefined @@ -56,7 +56,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { return ( - + { // Elevated context maps this to a quiet neutral surface step, not the loud accent. - if (hovered()) return theme.background.action.primary.hovered - return theme.background.default + if (hovered()) return theme.background.raised.high + return theme.background.raised.base } const marker = () => { if (row.kind !== "directory") return "≡ " diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index f664d4da5cd..ea9c8f16236 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -214,7 +214,7 @@ function DiffBaseDialog(props: { current?: string onSelect: (ref: string) => void }) { - const theme = props.context.theme.contextual.elevated + const theme = props.context.theme.surface("dialog") const [search, setSearch] = createDebouncedSignal("", 150) const [branches] = createResource(search, (search) => props.context.client.vcs.branch.list({ location: props.location, search, limit: 100 }), @@ -1079,7 +1079,7 @@ export function DiffViewerContent(props: { function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) { const dimensions = useTerminalDimensions() - const theme = props.context.theme.contextual.elevated + const theme = props.context.theme.surface("dialog") const shortcut = (...ids: string[]) => () => diff --git a/packages/tui/src/feature-plugins/system/storybook/footer.tsx b/packages/tui/src/feature-plugins/system/storybook/footer.tsx index e9a44099df0..61100776fff 100644 --- a/packages/tui/src/feature-plugins/system/storybook/footer.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/footer.tsx @@ -14,10 +14,10 @@ export function StoryFooter(props: { message?: string controls: readonly StoryFooterControl[] }) { - const theme = props.context.theme.contextual.elevated + const theme = props.context.theme return ( - + {props.title} diff --git a/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx b/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx index 428fed7ae91..dff680c01f4 100644 --- a/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx @@ -11,7 +11,7 @@ const directory = "/Users/kit/code/open-source/opencode-workerd-profile" function SessionLocationMissingStory(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() - const theme = props.context.theme.contextual.elevated + const theme = props.context.theme const [message, setMessage] = createSignal("Choose another directory to continue") const open = () => props.context.ui.dialog.show(() => ( @@ -45,7 +45,7 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) { })) return ( - + Workerd Modal workspace driver diff --git a/packages/tui/src/mini/theme.ts b/packages/tui/src/mini/theme.ts index c8018c8068e..d3c19e22e03 100644 --- a/packages/tui/src/mini/theme.ts +++ b/packages/tui/src/mini/theme.ts @@ -115,7 +115,6 @@ function map( syntax?: SyntaxStyle, system = false, ): RunTheme { - const elevated = theme.contextual.elevated // V1 system migration serializes colors; restore terminal defaults before quantizing scrollback. const exact = (color: RGBA) => { if (system && color.equals(theme.text.default)) return RGBA.defaultForeground(color) @@ -137,13 +136,13 @@ function map( return { background: RGBA.defaultBackground(theme.background.default), footer: { - actionSecondaryText: exact(elevated.text.action.secondary.default), - actionFocusedBg: exact(elevated.background.action.primary.focused), - actionFocusedText: exact(elevated.text.action.primary.focused), - formfieldText: exact(elevated.text.formfield.default), - formfieldFocusedBg: exact(elevated.background.formfield.focused), - formfieldFocusedText: exact(elevated.text.formfield.focused), - selection: exact(elevated.text.formfield.selected), + actionSecondaryText: exact(theme.text.action.secondary.default), + actionFocusedBg: exact(theme.background.action.primary.focused), + actionFocusedText: exact(theme.text.action.primary.focused), + formfieldText: exact(theme.text.formfield.default), + formfieldFocusedBg: exact(theme.background.formfield.focused), + formfieldFocusedText: exact(theme.text.formfield.focused), + selection: exact(theme.text.formfield.selected), running: exact(theme.text.status.running), question: exact(theme.text.status.question), permission: exact(theme.text.status.permission), @@ -157,9 +156,9 @@ function map( error: exact(theme.text.feedback.error.default), muted: exact(theme.text.subdued), text: exact(theme.text.default), - shade: exact(elevated.background.default), - surface: exact(elevated.background.default), - pane: exact(theme.contextual.overlay.background.default), + shade: exact(theme.background.raised.base), + surface: exact(theme.background.raised.base), + pane: exact(theme.background.raised.high), border: exact(theme.border.default), line: exact(theme.background.raised.high), }, diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx index c9970536acf..617298f5cf0 100644 --- a/packages/tui/src/routes/session/composer/index.tsx +++ b/packages/tui/src/routes/session/composer/index.tsx @@ -42,7 +42,7 @@ export type ComposerProps = { } export function Composer(props: ComposerProps) { - const theme = useTheme("elevated") + const theme = useTheme() const config = useConfig().data const [store, setStore] = createStore({ @@ -114,7 +114,7 @@ export function Composer(props: ComposerProps) { {...SplitBorder} border={["left"]} borderColor={theme.border.default} - backgroundColor={theme.background.default} + backgroundColor={theme.background.raised.base} paddingLeft={1} paddingRight={2} paddingTop={1} diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 89d50dbd6fb..5fd4733b2cd 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -21,7 +21,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { const dialog = useDialog() const clipboard = useClipboard() const toast = useToast() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const dimensions = useTerminalDimensions() const config = useConfig().data const [copied, setCopied] = createSignal<"code" | "output">() @@ -224,7 +224,7 @@ function GutteredCode(props: { digits: number blocks: Set }) { - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const syntax = useThemes().currentSyntax const gutter = createMemo(() => props.content diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index c3b38da128f..5241ca4368b 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -59,7 +59,7 @@ const drafts = new Map() export function FormPrompt(props: { form: FormWithLocation }) { const data = useData() const themes = useThemes() - const theme = useTheme("elevated") + const theme = useTheme() const themeMode = themes.mode const renderer = useRenderer() const dimensions = useTerminalDimensions() @@ -768,7 +768,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { return ( setTabHover(index())} onMouseOut={() => setTabHover(null)} @@ -835,7 +835,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { ? theme.background.formfield.selected : tabHover() === "confirm" ? theme.background.formfield.focused - : theme.background.default + : theme.background.raised.base } onMouseOver={() => setTabHover("confirm")} onMouseOut={() => setTabHover(null)} @@ -943,7 +943,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { > {`${i() + 1}.`} @@ -997,7 +997,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { > @@ -1007,7 +1007,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { ctx @@ -2179,7 +2179,7 @@ function RevertMessage(props: { }> }) { const ctx = use() - const theme = useTheme("elevated") + const theme = useTheme() const route = useRouteData("session") const client = useClient() const toast = useToast() @@ -2204,13 +2204,13 @@ function RevertMessage(props: { marginTop={1} border={["left"]} customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.background.default} + borderColor={theme.background.raised.base} > {props.count} message{props.count === 1 ? "" : "s"} reverted @@ -2282,7 +2282,7 @@ function UserMessage(props: { message: SessionMessageUser }) { ), ) const themes = useThemes() - const theme = useTheme("elevated") + const theme = useTheme() const mode = themes.mode const [hover, setHover] = createSignal(false) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) @@ -2301,7 +2301,7 @@ function UserMessage(props: { message: SessionMessageUser }) { border={["left"]} borderColor={delivery() ? theme.border.default : color()} customBorderChars={SplitBorder.customBorderChars} - backgroundColor={theme.background.default} + backgroundColor={theme.background.raised.base} > {props.message.text} @@ -2351,13 +2351,13 @@ function UserMessage(props: { message: SessionMessageUser }) { {" skill "} - + {` ${skill.name} `} @@ -2375,13 +2375,13 @@ function UserMessage(props: { message: SessionMessageUser }) { {` ${label} `} - + {" "} {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "} @@ -2398,7 +2398,7 @@ function UserMessage(props: { message: SessionMessageUser }) { } function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) { - const theme = useTheme("elevated") + const theme = useTheme() const [hover, setHover] = createSignal(false) const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " ")) @@ -2417,7 +2417,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp paddingBottom={1} paddingLeft={2} paddingRight={1} - backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default} + backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base} flexDirection="row" > @@ -2758,10 +2758,11 @@ function InlineTool(props: { ) } -function StatusBadge(props: { children: string }) { +function StatusBadge(props: { children: string; raised?: boolean }) { const theme = useTheme() + const background = () => (props.raised ? theme.background.raised.base : theme.background.default) return ( - + {" "} {props.children}{" "} @@ -2781,16 +2782,8 @@ type BlockToolProps = { } function BlockTool(props: BlockToolProps) { - const parentTheme = useTheme() - return ( - - - - ) -} - -function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) { const theme = useTheme() + const background = theme.background.raised.base const ctx = use() const renderer = useRenderer() const [hover, setHover] = createSignal(false) @@ -2806,9 +2799,9 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) { paddingBottom={1} paddingLeft={2} gap={1} - backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default} + backgroundColor={hover() ? theme.raise(background) : background} customBorderChars={SplitBorder.customBorderChars} - borderColor={props.borderColor} + borderColor={theme.background.default} onMouseOver={() => props.onClick && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -3041,7 +3034,7 @@ function ShellDisplay(props: { - Background + Background diff --git a/packages/tui/src/routes/session/location-missing.tsx b/packages/tui/src/routes/session/location-missing.tsx index 7279b60e005..816e1ab6ba3 100644 --- a/packages/tui/src/routes/session/location-missing.tsx +++ b/packages/tui/src/routes/session/location-missing.tsx @@ -13,7 +13,7 @@ export function SessionLocationMissing(props: { directory: string; projectID: st export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) { const paths = useTuiPaths() - const theme = useTheme("elevated") + const theme = useTheme() const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72)) return ( diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index f6f7279ee78..7a780587bfd 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -277,7 +277,7 @@ function RejectPrompt(props: { }) { let input: TextareaRenderable const enabled = useInteractivity() - const theme = useTheme("elevated") + const theme = useTheme() const config = useConfig().data const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) @@ -314,7 +314,7 @@ function RejectPrompt(props: { role: "dialog", label: `Reject permission: ${props.action}`, }))} - backgroundColor={theme.background.default} + backgroundColor={theme.background.raised.base} border={["left"]} borderColor={theme.text.feedback.error.default} customBorderChars={SplitBorder.customBorderChars} @@ -335,7 +335,7 @@ function RejectPrompt(props: { paddingLeft={2} paddingRight={3} paddingBottom={1} - backgroundColor={theme.raise(theme.background.default)} + backgroundColor={theme.raise(theme.background.raised.base)} justifyContent={narrow() ? "flex-start" : "space-between"} alignItems={narrow() ? "flex-start" : "center"} gap={1} @@ -418,7 +418,7 @@ export function SessionQuestion>(props: { fullscreen?: boolean onSelect: (option: keyof T) => void }) { - const theme = useTheme("elevated") + const theme = useTheme() const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ @@ -507,7 +507,7 @@ export function SessionQuestion>(props: { label: props.semanticLabel ?? props.title, expanded: store.expanded, }))} - backgroundColor={theme.background.default} + backgroundColor={theme.background.raised.base} border={["left"]} borderColor={theme.background.action.primary.focused} customBorderChars={SplitBorder.customBorderChars} @@ -546,7 +546,7 @@ export function SessionQuestion>(props: { paddingLeft={2} paddingRight={3} paddingBottom={1} - backgroundColor={theme.raise(theme.background.default)} + backgroundColor={theme.raise(theme.background.raised.base)} justifyContent={narrow() ? "flex-start" : "space-between"} alignItems={narrow() ? "flex-start" : "center"} > diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 03b04cb5a0c..8f44bc5a63d 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -12,7 +12,7 @@ import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const data = useData() - const theme = useTheme("elevated") + const theme = useTheme() const config = useConfig().data const session = createMemo(() => data.session.get(props.sessionID)) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) @@ -20,7 +20,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { return ( , mode: Accessor) { - return Object.assign(createComponentThemeView(current, mode), { - contextual: { - elevated: createComponentThemeView(() => current().contextual.elevated, mode), - overlay: createComponentThemeView(() => current().contextual.overlay, mode), - }, - }) -} - -export function createComponentThemeView(view: Accessor, mode: Accessor) { +export function createComponentTheme( + view: Accessor, + // Shared across a theme's surface views so `surface()` stays absolute at the wrapper level too. + surfaces = new Map(), +): ComponentTheme { return { get hue() { return view().hue @@ -43,8 +38,15 @@ export function createComponentThemeView(view: Accessor, mo source: (color: RGBA) => view().source(color), increase: (color: RGBA, amount = 1) => view().increase(color, amount), decrease: (color: RGBA, amount = 1) => view().decrease(color, amount), - raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)), + raise: (color: RGBA) => view().raise(color), + surface(name: SurfaceName) { + const cached = surfaces.get(name) + if (cached) return cached + const created = createComponentTheme(() => view().surface(name), surfaces) + surfaces.set(name, created) + return created + }, } } -export type ComponentTheme = ReturnType +export type ComponentTheme = ResolvedTheme diff --git a/packages/tui/src/ui/dialog-alert.tsx b/packages/tui/src/ui/dialog-alert.tsx index 2a4d8521883..bb7d86e2abd 100644 --- a/packages/tui/src/ui/dialog-alert.tsx +++ b/packages/tui/src/ui/dialog-alert.tsx @@ -11,7 +11,7 @@ export type DialogAlertProps = { export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") Keymap.createLayer(() => ({ mode: "modal", diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index f0bfa58a172..1062c9aaca0 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -19,7 +19,7 @@ export type DialogConfirmProps = { export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index eef090b974a..30b30d8013e 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -23,8 +23,8 @@ type Active = ExportFormat | "thinking" | "tools" | "sanitize" | "copy" | "expor export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() - const theme = useTheme("elevated") - const overlayTheme = useTheme("overlay") + const theme = useTheme().surface("dialog") + const overlayTheme = useTheme() const [store, setStore] = createStore({ format: "markdown" as ExportFormat, thinking: props.defaultThinking, @@ -247,7 +247,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { confirm("copy")} > Copy diff --git a/packages/tui/src/ui/dialog-export-result.tsx b/packages/tui/src/ui/dialog-export-result.tsx index 9ae5d5cc1cc..bb10a49b1e4 100644 --- a/packages/tui/src/ui/dialog-export-result.tsx +++ b/packages/tui/src/ui/dialog-export-result.tsx @@ -5,7 +5,7 @@ import { useDialog, type DialogContext } from "./dialog" export function DialogExportResult(props: { path: string; onClose?: () => void }) { const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const close = () => { props.onClose?.() diff --git a/packages/tui/src/ui/dialog-help.tsx b/packages/tui/src/ui/dialog-help.tsx index ebf05b5e5a1..2a8d1cd63b6 100644 --- a/packages/tui/src/ui/dialog-help.tsx +++ b/packages/tui/src/ui/dialog-help.tsx @@ -5,7 +5,7 @@ import { useDialog } from "./dialog" export function DialogHelp() { const dialog = useDialog() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const shortcuts = Keymap.useShortcuts() Keymap.createLayer(() => ({ diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index afe01170c6d..817a082e476 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -22,7 +22,7 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const renderer = useRenderer() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const shortcuts = Keymap.useShortcuts() const config = useConfig().data const [textareaTarget, setTextareaTarget] = createSignal() diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 5b31dacc6c6..fafdd844a7b 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -107,7 +107,7 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const themes = useThemes() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const mode = themes.mode const config = useConfig().data const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) @@ -843,7 +843,7 @@ function Option(props: { activeColor?: RGBA onMouseOver?: () => void }) { - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const text = createMemo(() => { if (props.active && !props.muted) return props.activeColor ?? theme.text.action.primary.focused if (props.muted && (props.active || props.current)) return theme.text.subdued diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 4dae6fff822..b39d192c191 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -1,7 +1,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" import { Keymap } from "../context/keymap" -import { useTheme } from "../context/theme" +import { ThemeContextProvider, useTheme } from "../context/theme" import { InputRenderable, MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" @@ -25,12 +25,13 @@ export function Dialog( }>, ) { const dimensions = useTerminalDimensions() - const theme = useTheme("elevated") + const theme = useTheme().surface("dialog") const renderer = useRenderer() let dismiss = false return ( - + { dismiss = !!renderer.getSelection() }} @@ -67,7 +68,8 @@ export function Dialog( > {props.children} - + + ) } diff --git a/packages/tui/src/ui/pane-resize-handle.tsx b/packages/tui/src/ui/pane-resize-handle.tsx index edd1e04b75e..f0eea2caf46 100644 --- a/packages/tui/src/ui/pane-resize-handle.tsx +++ b/packages/tui/src/ui/pane-resize-handle.tsx @@ -6,7 +6,7 @@ export function PaneResizeHandle(props: { left: number highlight?: "left" | "right" }) { - const theme = useTheme("elevated") + const theme = useTheme() return ( diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index 2e03900b9ba..5128b954839 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -22,7 +22,7 @@ function ToastSurface(props: { onHover?: (hovered: boolean) => void onActivate: () => void }) { - const theme = useTheme("overlay") + const theme = useTheme() const dimensions = useTerminalDimensions() const renderer = useRenderer() const [hovered, setHovered] = createSignal(false) @@ -66,7 +66,7 @@ function ToastSurface(props: { paddingRight={2} paddingTop={1} paddingBottom={1} - backgroundColor={theme.background.default} + backgroundColor={theme.background.raised.high} > { - const standalone = { - version: 2, - standalone: true, - dark: { - hue: selectTheme(DEFAULT_THEME, "dark").hue, - "@context:elevated": { text: { default: "#abcdef" } }, - }, - } as const +test("dialog surfaces are absolute and can be inherited through the theme context", async () => { let themes: ReturnType | undefined let theme: ReturnType | undefined - let explicit: ReturnType | undefined + let contextual: ReturnType | undefined function ContextProbe() { - theme = useTheme() - explicit = useTheme("elevated") - return {theme.text.default.toString()} + contextual = useTheme() + return {contextual.text.default.toString()} } function Probe() { themes = useThemes() + theme = useTheme() return ( - + ) @@ -151,8 +141,8 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b const app = await testRender( () => ( - - Promise.resolve({ standalone }) }}> + + ({}) }}> @@ -163,62 +153,15 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b try { await wait(() => themes?.ready === true) - if (!themes) throw new Error("Theme provider is not mounted") - if (!theme) throw new Error("Contextual theme is not mounted") - if (!explicit) throw new Error("Explicit contextual theme is not mounted") - expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue() - expect(theme.text.default).toBe(explicit.text.default) - expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default) - expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default) + if (!themes || !theme || !contextual) throw new Error("Theme provider is not mounted") + const dialog = theme.surface("dialog") + expect(theme.surface("dialog")).toBe(dialog) + expect(dialog.surface("dialog")).toBe(dialog) + expect(dialog.background.default).toBe(themes.currentTokens().background.raised.base) + expect(contextual.background.default).toBe(dialog.background.default) + expect(contextual.text.default).toBe(dialog.text.default) + expect(dialog.raise(dialog.background.raised.base)).toBe(themes.currentTokens().hue.neutral[600]) } finally { app.renderer.destroy() } }) - -test.each(["dark", "light"] as const)( - "reactive %s theme contexts change without remounting their contents", - async (mode) => { - const [context, setContext] = createSignal<"elevated" | undefined>("elevated") - const [parent, setParent] = createSignal<"overlay" | undefined>() - let theme: ReturnType | undefined - let themes: ReturnType | undefined - let mounts = 0 - function Probe() { - mounts++ - theme = useTheme() - themes = useThemes() - return probe - } - const app = await testRender(() => ( - - ({}) }}> - - - - - - - - )) - app.renderer.start() - try { - await wait(() => themes?.ready === true) - if (!theme || !themes) throw new Error("Theme provider is not mounted") - const view = theme - expect(view.background.default).toBe(themes.current.contextual.elevated.background.default) - setContext(undefined) - await app.flush() - expect(view.background.default).toBe(themes.current.background.default) - setParent("overlay") - await app.flush() - expect(view.background.default).toBe(themes.current.contextual.overlay.background.default) - setContext("elevated") - await app.flush() - expect(view.text.default).toBe(themes.current.contextual.elevated.text.default) - expect(theme).toBe(view) - expect(mounts).toBe(1) - } finally { - app.renderer.destroy() - } - }, -) diff --git a/packages/tui/test/component/session-tabs-status.test.tsx b/packages/tui/test/component/session-tabs-status.test.tsx index bf1ba6ee3b1..bf83183a5e7 100644 --- a/packages/tui/test/component/session-tabs-status.test.tsx +++ b/packages/tui/test/component/session-tabs-status.test.tsx @@ -38,7 +38,7 @@ for (const orientation of ["horizontal", "vertical"] as const) { let theme!: ReturnType function Colors() { config = useConfig() - theme = orientation === "vertical" ? useTheme("elevated") : useTheme() + theme = orientation === "vertical" ? useTheme() : useTheme() return null } const controller = { diff --git a/packages/tui/test/mini/theme.test.ts b/packages/tui/test/mini/theme.test.ts index 32c78ef194f..89712f0c6f2 100644 --- a/packages/tui/test/mini/theme.test.ts +++ b/packages/tui/test/mini/theme.test.ts @@ -83,21 +83,21 @@ function expectFooter(actual: RunTheme, theme: ResolvedTheme) { muted: theme.text.subdued, warning: theme.text.feedback.warning.default, error: theme.text.feedback.error.default, - actionSecondaryText: theme.contextual.elevated.text.action.secondary.default, - actionFocusedBg: theme.contextual.elevated.background.action.primary.focused, - actionFocusedText: theme.contextual.elevated.text.action.primary.focused, - formfieldText: theme.contextual.elevated.text.formfield.default, - formfieldFocusedBg: theme.contextual.elevated.background.formfield.focused, - formfieldFocusedText: theme.contextual.elevated.text.formfield.focused, - selection: theme.contextual.elevated.text.formfield.selected, + actionSecondaryText: theme.text.action.secondary.default, + actionFocusedBg: theme.background.action.primary.focused, + actionFocusedText: theme.text.action.primary.focused, + formfieldText: theme.text.formfield.default, + formfieldFocusedBg: theme.background.formfield.focused, + formfieldFocusedText: theme.text.formfield.focused, + selection: theme.text.formfield.selected, running: theme.text.status.running, question: theme.text.status.question, permission: theme.text.status.permission, success: theme.text.feedback.success.default, link: theme.markdown.link, - shade: theme.contextual.elevated.background.default, - surface: theme.contextual.elevated.background.default, - pane: theme.contextual.overlay.background.default, + shade: theme.background.raised.base, + surface: theme.background.raised.base, + pane: theme.background.raised.high, border: theme.border.default, line: theme.background.raised.high, } @@ -195,13 +195,11 @@ test.each(["light", "dark"] as const)( hue: DEFAULT_THEME[mode].hue, text: { default: "#123456", - formfield: { default: "#234567", $selected: "#345678" }, + action: { primary: { $focused: "#56789a" } }, + formfield: { default: "#234567", $selected: "#345678", $focused: "#6789ab" }, feedback: { warning: { default: "#456789" } }, }, - "@context:elevated": { - text: { action: { primary: { $focused: "#56789a" } }, formfield: { $focused: "#6789ab" } }, - background: { action: { primary: { $focused: "#789abc" } }, formfield: { $focused: "#89abcd" } }, - }, + background: { action: { primary: { $focused: "#789abc" } }, formfield: { $focused: "#89abcd" } }, }, } await Bun.write(path.join(tmp.path, "themes", "mini-custom.json"), JSON.stringify(source)) diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 20afe840a46..6d44231a154 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -1,18 +1,13 @@ import { expect, test } from "bun:test" import { createSignal } from "solid-js" import { RGBA } from "@opentui/core" -import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode/theme/tui" -import { createComponentTheme, createComponentThemeView } from "../../../src/theme/component" +import { DEFAULT_THEME, resolveTheme, selectTheme } from "@opencode/theme/tui" +import { createComponentTheme } from "../../../src/theme/component" -test("provides reactive properties, states, contexts, and color operations", () => { - const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) - const [mode, setMode] = createSignal<"light" | "dark">("light") - const theme = createComponentTheme(resolved, mode) - const [context, setContext] = createSignal() - const current = () => { - const name = context() - return name ? theme.contextual[name] : theme - } +test("provides reactive properties, states, surfaces, and color operations", () => { + const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"), "light")) + const theme = createComponentTheme(resolved) + const current = theme.surface("dialog") expect(theme.text.default).toBe(resolved().text.default) expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500]) @@ -51,35 +46,18 @@ test("provides reactive properties, states, contexts, and color operations", () expect(theme.scrollbar.default).toBe(resolved().scrollbar.default) expect(theme.diff.text.added).toBe(resolved().diff.text.added) - setContext("elevated") - expect("contexts" in current()).toBeFalse() - expect(current().categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500])) - expect(current().text.default).toBe(resolved().contextual.elevated.text.default) - expect(current().background.action.primary.focused).toBe( - resolved().contextual.elevated.background.action.primary.focused, - ) - expect(current().background.action.primary.hovered).toBe(resolved().background.raised.high) - expect(current().background.formfield.selected).toBe(resolved().contextual.elevated.background.formfield.selected) + expect(theme.surface("dialog")).toBe(current) + expect(current.surface("dialog")).toBe(current) + expect(current.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500])) + expect(current.text.default).toBe(resolved().surface("dialog").text.default) + expect(current.background.default).toBe(resolved().background.raised.base) + expect(current.background.action.primary.focused).toBe(resolved().surface("dialog").background.action.primary.focused) + expect(current.background.action.primary.hovered).toBe(resolved().background.raised.high) + expect(current.background.formfield.selected).toBe(resolved().surface("dialog").background.formfield.selected) - setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) - setMode("dark") - expect(current().text.default).toBe(resolved().contextual.elevated.text.default) - expect(current().decrease(current().background.raised.base, 1)).toBe(resolved().hue.neutral[600]) - expect(current().raise(current().background.raised.base)).toBe(resolved().hue.neutral[600]) -}) - -test("a stable component theme view follows presentation context changes", () => { - const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) - const [context, setContext] = createSignal() - const theme = createComponentThemeView( - () => (context() ? resolved().contextual[context()!] : resolved()), - () => "dark", - ) - expect(theme.background.default).toBe(resolved().background.default) - setContext("elevated") - expect(theme.background.default).toBe(resolved().contextual.elevated.background.default) - setContext(undefined) - expect(theme.background.default).toBe(resolved().background.default) - setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) - expect(theme.text.default).toBe(resolved().text.default) + setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"), "dark")) + expect(current.text.default).toBe(resolved().surface("dialog").text.default) + expect(current.background.default).toBe(resolved().background.raised.base) + expect(current.decrease(current.background.raised.base, 1)).toBe(resolved().hue.neutral[600]) + expect(current.raise(current.background.raised.base)).toBe(resolved().hue.neutral[600]) }) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index 3b5a5d93785..f7592f8fac4 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -22,8 +22,8 @@ test("resolves one-mode documents with defaults for the available mode", () => { const resolvedLight = resolveSource({ version: 2, light: {} }, "dark") const resolvedDark = resolveSource({ version: 2, dark: {} }, "light") - expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue() - expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue() + expect(resolvedLight.background.default.equals(resolveTheme(light, "light").background.default)).toBeTrue() + expect(resolvedDark.background.default.equals(resolveTheme(dark, "dark").background.default)).toBeTrue() expect(resolvedLight.categorical.length).toBeGreaterThan(0) expect(resolvedDark.categorical.length).toBeGreaterThan(0) }) @@ -38,7 +38,7 @@ test("validates and resolves categorical hues in configured order", () => { expect(theme.categorical[0]).toBe(theme.hue.accent) expect(theme.categorical[1]).toBe(theme.hue.red) expect(theme.categorical[2]).toBe(theme.hue.interactive) - expect(theme.contextual.elevated.categorical).toBe(theme.categorical) + expect(theme.surface("dialog").categorical).toBe(theme.categorical) expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme") }) @@ -52,15 +52,15 @@ test("generates syntax with one categorical hue", () => { }) test("uses the default categorical order for direct definitions", () => { - const theme = resolveTheme({ ...light, categorical: undefined }) + const theme = resolveTheme({ ...light, categorical: undefined }, "light") expect(theme.categorical[0]).toBe(theme.hue.blue) expect(theme.categorical[1]).toBe(theme.hue.purple) }) test("resolves independent definitions and hue aliases", () => { - const lightTheme = resolveTheme(light) - const darkTheme = resolveTheme(dark) + const lightTheme = resolveTheme(light, "light") + const darkTheme = resolveTheme(dark, "dark") expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue) expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue() @@ -74,30 +74,36 @@ test("resolves independent definitions and hue aliases", () => { expect(lightTheme.source(lightTheme.background.raised.base)).toEqual({ hue: "neutral", step: 300 }) expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100]) - expect(lightTheme.contextual.elevated.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) + expect(lightTheme.surface("dialog").increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) + expect(lightTheme.raise(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) + expect(darkTheme.raise(darkTheme.hue.red[200])).toBe(darkTheme.hue.red[100]) expect(lightTheme.text.default).toBeInstanceOf(RGBA) expect(darkTheme.background.default).toBeInstanceOf(RGBA) expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[300]) expect(lightTheme.background.raised.high).toBe(lightTheme.hue.neutral[400]) expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA) expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200]) - expect(lightTheme.contextual.elevated.background.action.primary.default).toBe(lightTheme.hue.interactive[500]) - expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.raised.base) - expect(lightTheme.contextual.elevated.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) - expect(lightTheme.contextual.overlay.background.action.primary.default).toBe(lightTheme.hue.interactive[500]) - expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.raised.high) - expect(lightTheme.contextual.overlay.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) - expect(darkTheme.contextual.elevated.background.action.primary.default).toBe(darkTheme.hue.interactive[400]) - expect(darkTheme.contextual.elevated.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) - expect(darkTheme.contextual.overlay.background.action.primary.default).toBe(darkTheme.hue.interactive[400]) - expect(darkTheme.contextual.overlay.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) + // Surfaces re-resolve the palette after applying their theme-provided overrides. + const dialog = lightTheme.surface("dialog") + expect(dialog.background.default).toBe(lightTheme.background.raised.base) + expect(dialog.background.formfield.default).toBe(lightTheme.background.raised.base) + expect(dialog.background.feedback.error.default).toBe(lightTheme.background.raised.base) + expect(dialog.background.action.primary.hovered).toBe(lightTheme.background.raised.high) + expect(dialog.background.action.primary.default).toBe(lightTheme.hue.interactive[500]) + expect(dialog.background.action.primary.focused).toBe(lightTheme.hue.interactive[500]) + expect(dialog.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) + expect(dialog.surface("dialog")).toBe(dialog) + expect(darkTheme.surface("dialog").background.default).toBe(darkTheme.background.raised.base) }) test("resolves base hue aliases and rejects circular hue aliases", () => { - const aliased = resolveTheme({ - ...light, - hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" }, - }) + const aliased = resolveTheme( + { + ...light, + hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" }, + }, + "light", + ) const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light") expect(aliased.hue.blue).not.toBe(aliased.hue.red) @@ -110,23 +116,29 @@ test("resolves base hue aliases and rejects circular hue aliases", () => { expect(aliased.source(aliased.hue.blue[500])).toEqual({ hue: "blue", step: 500 }) expect(aliased.source(aliased.hue.purple[500])).toEqual({ hue: "purple", step: 500 }) expect(() => - resolveTheme({ - ...light, - hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" }, - }), + resolveTheme( + { + ...light, + hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" }, + }, + "light", + ), ).toThrow("Circular hue reference: red -> blue -> red") }) test("steps by hue source when adjacent colors have equal values", () => { if (typeof light.hue.gray !== "object") throw new Error("Expected a concrete gray scale") - const theme = resolveTheme({ - ...light, - hue: { - ...light.hue, - gray: { ...light.hue.gray, 200: "#eee8d5", 300: "#eee8d5", 400: "#d3d7c6" }, - neutral: "$hue.gray", + const theme = resolveTheme( + { + ...light, + hue: { + ...light.hue, + gray: { ...light.hue.gray, 200: "#eee8d5", 300: "#eee8d5", 400: "#d3d7c6" }, + neutral: "$hue.gray", + }, }, - }) + "light", + ) expect(theme.hue.neutral[200]).not.toBe(theme.hue.neutral[300]) expect(theme.hue.neutral[200].equals(theme.hue.neutral[300])).toBeTrue() @@ -198,7 +210,7 @@ test("expands user structural fallbacks before merging defaults", () => { expect(expanded.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255]) expect(isolatedState.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255]) expect(isolatedState.background.action.primary.focused.toInts()).toEqual( - resolveTheme(light).background.action.primary.focused.toInts(), + resolveTheme(light, "light").background.action.primary.focused.toInts(), ) }) @@ -226,7 +238,7 @@ test("uses defaults for the selected mode when it merges the other mode", () => }) test("resolves matched action variants and states", () => { - const theme = resolveTheme(light) + const theme = resolveTheme(light, "light") expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA) expect(theme.text.action.primary.hovered).toBeInstanceOf(RGBA) @@ -240,7 +252,7 @@ test("resolves matched action variants and states", () => { expect(theme.background.formfield.hovered).toBeInstanceOf(RGBA) }) -test("resolves elevated hover surfaces from direct colors", () => { +test("resolves dialog surfaces from direct colors", () => { const theme = resolveSource( { version: 2, @@ -250,8 +262,8 @@ test("resolves elevated hover surfaces from direct colors", () => { "light", ) - expect(theme.contextual.elevated.background.default.toInts()).toEqual([18, 52, 86, 255]) - expect(theme.contextual.elevated.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255]) + expect(theme.surface("dialog").background.default.toInts()).toEqual([18, 52, 86, 255]) + expect(theme.surface("dialog").background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255]) }) test("resolves transparent colors", () => { @@ -277,31 +289,30 @@ test("reports theme decoding failures as native errors", () => { ).toThrow('Invalid theme: custom "opaque" is an invalid value') }) -test("context overrides rewire semantic references and apply state precedence", () => { - const definition = override(light, { - text: { - default: "#111111", - action: { - primary: { default: "$text.default", $pressed: "#222222" }, - }, - }, - "@context:elevated": { +test("surface overrides rewire references and reset action states from their default", () => { + const theme = resolveTheme( + override(light, { text: { - default: "#333333", - action: { primary: { default: "#444444", $focused: "#555555" } }, + default: "#111111", + action: { primary: { default: "$text.default", $pressed: "#222222" } }, }, - }, - }) - const theme = resolveTheme(definition) - const overlay = theme.contextual.elevated - - expect(overlay.text.default.toInts()).toEqual([51, 51, 51, 255]) - expect(overlay.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255]) - expect(overlay.text.action.primary.focused.toInts()).toEqual([85, 85, 85, 255]) + "@dialog": { + text: { + default: "#333333", + action: { primary: { default: "#444444", $focused: "#555555" } }, + }, + }, + }), + "light", + ) + const raised = theme.surface("dialog") + expect(raised.text.default.toInts()).toEqual([51, 51, 51, 255]) + expect(raised.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255]) + expect(raised.text.action.primary.focused.toInts()).toEqual([85, 85, 85, 255]) }) -test("rejects missing, base, and contextual reference cycles", () => { - expect(() => resolveTheme(override(light, { text: { default: "$missing" } }))).toThrow( +test("rejects missing and circular references", () => { + expect(() => resolveTheme(override(light, { text: { default: "$missing" } }), "light")).toThrow( 'Theme reference "$missing" was not found', ) expect(() => @@ -309,29 +320,29 @@ test("rejects missing, base, and contextual reference cycles", () => { override(light, { text: { default: "$text.subdued", subdued: "$text.default" }, }), - ), - ).toThrow("Circular theme reference") - expect(() => - resolveTheme( - override(light, { - "@context:elevated": { text: { default: "$text.default" } }, - }), + "light", ), ).toThrow("Circular theme reference") }) test("validates complete hues, resolved groups, and hue-only syntax", () => { expect(() => - resolveTheme({ - ...light, - hue: { ...light.hue, accent: "$hue.missing" }, - } as unknown as ThemeDefinition), + resolveTheme( + { + ...light, + hue: { ...light.hue, accent: "$hue.missing" }, + } as unknown as ThemeDefinition, + "light", + ), ).toThrow("$hue.missing") expect(() => - resolveTheme({ - ...light, - syntax: { ...light.syntax, keyword: "$text.default" }, - } as unknown as ThemeDefinition), + resolveTheme( + { + ...light, + syntax: { ...light.syntax, keyword: "$text.default" }, + } as unknown as ThemeDefinition, + "light", + ), ).toThrow("$text.default") }) diff --git a/packages/tui/test/theme/v2/types.typecheck.ts b/packages/tui/test/theme/v2/types.typecheck.ts index de0ec73c665..ad4b1d24261 100644 --- a/packages/tui/test/theme/v2/types.typecheck.ts +++ b/packages/tui/test/theme/v2/types.typecheck.ts @@ -43,11 +43,7 @@ const definition = { text, background, border: { default: "$hue.neutral.300" }, - "@context:elevated": { - text: { default: "$hue.neutral.800" }, - background: { default: "$hue.neutral.200" }, - }, - "@context:overlay": { background: { default: "$hue.neutral.300" } }, + "@dialog": { background: { default: "$background.raised.base" } }, } satisfies ThemeDefinition export const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 1ca2b291b6b..779adada4b0 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -47,11 +47,9 @@ test("migrates resolved V1 modes into V2 tokens", () => { expect(resolved.text.action.secondary.default.toInts()).toEqual(legacy.textMuted.toInts()) expect(resolved.text.action.secondary.hovered.toInts()).toEqual(legacy.text.toInts()) expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts()) - expect(resolved.contextual.elevated.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts()) - expect(resolved.contextual.elevated.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) - expect(resolved.contextual.elevated.text.action.primary.default.toInts()).toEqual(legacy.text.toInts()) - expect(resolved.contextual.overlay.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts()) - expect(resolved.contextual.overlay.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) + expect(resolved.surface("dialog").background.default.toInts()).toEqual(legacy.backgroundPanel.toInts()) + expect(resolved.surface("dialog").background.action.primary.default.toInts()).toEqual([0, 0, 0, 0]) + expect(resolved.surface("dialog").text.action.primary.default.toInts()).toEqual(legacy.text.toInts()) }) test("references generated hues from matching token colors", () => { diff --git a/packages/tui/test/ui/pane-resize-handle.test.tsx b/packages/tui/test/ui/pane-resize-handle.test.tsx index 75ad40ff2ef..0c7fd163975 100644 --- a/packages/tui/test/ui/pane-resize-handle.test.tsx +++ b/packages/tui/test/ui/pane-resize-handle.test.tsx @@ -19,7 +19,7 @@ for (const mode of ["dark", "light"] as const) { let theme!: ReturnType let parent!: BoxRenderable function Pane() { - theme = useTheme("elevated") + theme = useTheme() resize = createPaneResize({ value, defaultValue: () => 16, @@ -78,11 +78,11 @@ for (const mode of ["dark", "light"] as const) { await app.renderOnce() expect(resize.hovered()).toBe(true) expect(resize.resizing()).toBe(false) - expect(line.backgroundColor.toInts()).toEqual(theme.background.action.primary.hovered.toInts()) + expect(line.backgroundColor.toInts()).toEqual(theme.background.raised.high.toInts()) expect(handle.backgroundColor.a).toBe(0) for (const row of app.captureSpans().lines) { const colors = row.spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg.toInts())) - expect(colors[23]).toEqual(theme.background.action.primary.hovered.toInts()) + expect(colors[23]).toEqual(theme.background.raised.high.toInts()) expect(colors[24]).not.toEqual(colors[23]) } @@ -107,7 +107,7 @@ for (const mode of ["dark", "light"] as const) { expect(handle.x).toBe(39) expect(resize.hovered()).toBe(false) expect(resize.resizing()).toBe(true) - expect(line.backgroundColor.toInts()).toEqual(theme.background.action.primary.hovered.toInts()) + expect(line.backgroundColor.toInts()).toEqual(theme.background.raised.high.toInts()) expect(commits).toEqual([]) await app.mockMouse.release(55, 2) diff --git a/services/www/src/docs/content/themes.mdx b/services/www/src/docs/content/themes.mdx index 6720980dc53..3506f6e5255 100644 --- a/services/www/src/docs/content/themes.mdx +++ b/services/www/src/docs/content/themes.mdx @@ -246,24 +246,32 @@ Syntax keys are `comment`, `keyword`, `function`, `variable`, `string`, `number` `punctuation`. Markdown keys are `text`, `heading`, `link`, `linkText`, `code`, `blockQuote`, `emphasis`, `strong`, `horizontalRule`, `listItem`, `listEnumeration`, `image`, `imageText`, and `codeBlock`. -## Contexts +## Raised surfaces -Use context overrides for elevated panels and overlays while keeping the base theme elsewhere: +Panels, dialogs, menus, and toasts select their depth from `background.raised`. Dialogs additionally support a +contextual `@dialog` surface. The TUI re-resolves the palette after applying `@dialog`, so tokens that reference +`$background.default` follow the dialog background: ```json title="themes/ocean.json" { "version": 2, "dark": { "background": { - "default": "#071521" + "default": "#071521", + "raised": { + "base": "#0c1f2e", + "high": "#102a3c", + "max": "#16364b" + } }, - "@context:overlay": { + "@dialog": { "background": { - "default": "#102a3c" + "default": "$background.raised.base" } } } } ``` -The available keys are `@context:elevated` and `@context:overlay`. Each accepts the same token groups as the base mode. +`@dialog` accepts the same token groups as the base mode. An action's contextual `default` also becomes the fallback +for its unspecified states; an explicit state override still wins.