refactor(theme): replace theme contexts with a dialog surface (#49661)

This commit is contained in:
James Long
2026-09-18 12:47:21 -04:00
committed by GitHub
parent 437cdc03a9
commit d1e828c217
67 changed files with 428 additions and 522 deletions
+1 -1
View File
@@ -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(() => ({
+2 -16
View File
@@ -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
+1 -5
View File
@@ -11,11 +11,7 @@ export function expandTheme<Definition extends ModeDefinition>(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"]) } : {}),
}
}
+1 -2
View File
@@ -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,
+28 -32
View File
@@ -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<SurfaceName, ResolvedTheme>
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<ThemeTokensDefinition["text"]>
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
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<Record<ActionVariant, StatefulColorDefinition>> | undefined,
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
surface: Partial<Record<ActionVariant, StatefulColorDefinition>> | 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,
]
+6 -9
View File
@@ -16,6 +16,9 @@ export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "s
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const SurfaceName = Schema.Literal("dialog")
export type SurfaceName = Schema.Schema.Type<typeof SurfaceName>
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<typeof CategoricalDefinition>
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
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<typeof ThemeDefinition>
@@ -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<typeof FileThemeDefinition>
@@ -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<typeof MergeModeDefinition>
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
+5 -3
View File
@@ -7,6 +7,7 @@ import type {
HueAlias,
HueStep,
MarkdownToken,
SurfaceName,
SyntaxToken,
} from "./schema.js"
@@ -69,8 +70,9 @@ export type ResolvedThemeTokens = {
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
export type ContextName = "elevated" | "overlay"
export type ResolvedTheme = ResolvedThemeTokens & {
readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
/** 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
}
+1 -2
View File
@@ -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" } },
})
}
+2 -2
View File
@@ -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)
}
})
+8 -8
View File
@@ -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<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@@ -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 (
<box
@@ -487,7 +487,7 @@ function PanelBox(props: ParentProps) {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
flexDirection="column"
onMouseUp={(event) => {
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 (
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{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 (
<box flexDirection="row">
<text fg={theme.text.subdued}>{props.label}</text>
@@ -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 (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
props.hoverBackground && hovered() && !props.disabled ? theme.background.raised.high : undefined
}
onMouseOver={() => 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 "--"
@@ -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)
@@ -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()])
@@ -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<string>()
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 (
<box gap={1} paddingBottom={1}>
@@ -441,7 +441,7 @@ function CommandView(props: { title: string; output: string; message: string })
</text>
</box>
<box
backgroundColor={overlayTheme.background.default}
backgroundColor={overlayTheme.background.raised.high}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
@@ -467,7 +467,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<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<string>()
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 (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -852,7 +852,7 @@ function textAnswer(
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
dialog.replace(
() => {
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
return (
<DialogPrompt
+1 -1
View File
@@ -40,7 +40,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
const client = useClient()
const location = useLocation()
const toast = useToast()
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const current = () => location.ref ?? data.location.default()
const servers = createMemo(() =>
pipe(
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
@@ -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()
@@ -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<string>()
+1 -1
View File
@@ -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<number>()
+1 -1
View File
@@ -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() ?? [])
+1 -1
View File
@@ -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<string>()
const [active, setActive] = createSignal(0)
const controller = new AbortController()
@@ -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: {
</box>
<scrollbox
height={height()}
backgroundColor={overlayTheme.background.default}
backgroundColor={overlayTheme.background.raised.high}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
@@ -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()
@@ -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<InputRenderable>()
@@ -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<Progress>()
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}
+6 -5
View File
@@ -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 (
<box
id="session-panel"
@@ -27,7 +30,7 @@ export function PanelHost(props: {
minWidth={0}
minHeight={0}
focusable
backgroundColor={theme.background.default}
backgroundColor={background()}
onMouseDown={props.onFocus}
>
<Slot
@@ -55,9 +58,7 @@ export function PanelHost(props: {
return (
<InteractivityProvider enabled={props.focused}>
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
<Content />
</ThemeContextProvider>
<Content />
</InteractivityProvider>
)
}
@@ -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()}
+2 -2
View File
@@ -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 (
<box
@@ -21,7 +21,7 @@ export function Reconnecting(props: { managed?: boolean }) {
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
@@ -10,29 +10,29 @@ export function SessionTabsRailControls(props: {
tabs: SessionTabsController
belowHighlighted: boolean
}) {
const theme = useTheme("elevated")
const theme = useTheme()
const keymap = Keymap.use()
const [hovered, setHovered] = createSignal(false)
const hoverColor = createMemo(() =>
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 (
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.default}>
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.raised.base}>
<SessionTabHalfRow
top={-1}
edge="top"
width={props.width}
color={hovered() ? hoverColor() : theme.background.default}
background={theme.background.default}
color={hovered() ? hoverColor() : theme.background.raised.base}
background={theme.background.raised.base}
/>
<box
height={1}
position="relative"
flexDirection="row"
justifyContent="center"
backgroundColor={hovered() ? theme.background.action.primary.hovered : undefined}
backgroundColor={hovered() ? theme.background.raised.high : undefined}
onMouseOver={() => 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)}
+44 -39
View File
@@ -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 }}
>
<box flexShrink={0} flexDirection="column" gap={1} paddingY={compact() ? 1 : 0}>
@@ -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()
}
/>
<SessionTabHalfRow
@@ -900,7 +905,7 @@ function VerticalSessionTabs(props: {
: highlighted(items()[index() + 1]?.sessionID)
)
? highlightColor()
: theme.background.default
: background()
}
/>
</Show>
@@ -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()}
/>
<Show when={index() === items().length - 1}>
<TabPulse
@@ -970,18 +975,18 @@ function VerticalSessionTabs(props: {
outerComplete={false}
glow={glows()}
outerGlow={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
color={tint(background(), theme.text.default, 0.04)}
width={indicatorWidth}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
outerColor={tint(background(), theme.text.default, 0.006)}
flashColor={tint(background(), theme.text.default, 0.18)}
flashTail={8}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowColor={tint(background(), glowHue(), 0.1 * glowLevel())}
outerGlowColor={background()}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
completionColor={tint(background(), glowHue(), 0.1 * glowLevel())}
outerCompletionColor={background()}
backgroundColor={background()}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
@@ -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()}
/>
<SessionTabHalfRow
top={1}
edge="bottom"
width={width()}
color={highlightColor()}
background={theme.background.default}
background={background()}
/>
</Show>
<text
@@ -1211,10 +1216,10 @@ function VerticalSessionTabs(props: {
top={0}
edge="top"
width={tooltipWidth()}
color={theme.background.default}
color={background()}
background={base.background.default}
/>
<box height={2} paddingX={1} backgroundColor={theme.background.default}>
<box height={2} paddingX={1} backgroundColor={background()}>
<text fg={theme.text.default} wrapMode="none" selectable={false}>
{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}
/>
</box>
@@ -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 (
<Show when={show()}>
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
<box backgroundColor={theme.background.default} paddingLeft={1} paddingRight={1}>
<box backgroundColor={theme.background.raised.base} paddingLeft={1} paddingRight={1}>
<Spinner color={theme.text.subdued}>{text()}</Spinner>
</box>
</box>
+4 -4
View File
@@ -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<string>()
@@ -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()
+17 -20
View File
@@ -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 (
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
{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)
@@ -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 }) {
<scrollbox
ref={(element: ScrollBoxRenderable) => (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}
/>
</box>
</scrollbox>
@@ -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) => {
@@ -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 (
<box width={props.width} height="100%" minWidth={0} minHeight={0} flexShrink={0} flexDirection="column">
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.default} />
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.raised.base} />
<box
flexGrow={1}
minWidth={0}
@@ -64,7 +64,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
>
<box id="diff-source-header" height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
<box
@@ -137,8 +137,8 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
}
const background = () => {
// 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 "≡ "
@@ -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[]) =>
() =>
@@ -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 (
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.default}>
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.raised.base}>
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
<text fg={theme.text.default}>{props.title}</text>
<Show when={props.details?.length}>
@@ -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 (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.raised.base}>
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Workerd Modal workspace driver
+10 -11
View File
@@ -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),
},
@@ -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}
@@ -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<CodeRenderable>
}) {
const theme = useTheme("elevated")
const theme = useTheme().surface("dialog")
const syntax = useThemes().currentSyntax
const gutter = createMemo(() =>
props.content
+8 -8
View File
@@ -59,7 +59,7 @@ const drafts = new Map<string, FormDraft>()
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 (
<box
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
border={["left"]}
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
customBorderChars={SplitBorder.customBorderChars}
@@ -813,7 +813,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
? theme.background.formfield.selected
: tabHover() === index()
? theme.background.formfield.focused
: theme.background.default
: theme.background.raised.base
}
onMouseOver={() => 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 }) {
>
<box flexDirection="row">
<box
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
paddingRight={1}
>
<text
@@ -951,7 +951,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>{`${i() + 1}.`}</text>
</box>
<box
backgroundColor={active() ? theme.background.formfield.focused : theme.background.default}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
flexDirection="row"
>
<Show when={multi()}>
@@ -997,7 +997,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
paddingRight={1}
>
<text fg={other() ? theme.text.formfield.focused : theme.text.subdued}>
@@ -1007,7 +1007,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
flexDirection="row"
flexGrow={1}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.default}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
>
<Show when={multi()}>
<text
+21 -28
View File
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@@ -1936,7 +1936,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
const config = useConfig()
const data = useData()
const local = useLocal()
const theme = useTheme("elevated")
const theme = useTheme()
const model = createMemo(
() =>
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}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
>
<text fg={theme.text.subdued}>
{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}
>
<SessionImages images={images()} paddingLeft={2} />
<box
@@ -2339,7 +2339,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
flexShrink={0}
>
<text fg={theme.text.default}>{props.message.text}</text>
@@ -2351,13 +2351,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
<span
style={{
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
fg: theme.background.default,
fg: theme.background.raised.base,
bold: true,
}}
>
{" skill "}
</span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
<span style={{ bg: theme.raise(theme.background.raised.base), fg: theme.text.subdued }}>
{` ${skill.name} `}
</span>
</text>
@@ -2375,13 +2375,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
<span
style={{
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
fg: theme.background.default,
fg: theme.background.raised.base,
bold: true,
}}
>
{` ${label} `}
</span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
<span style={{ bg: theme.raise(theme.background.raised.base), fg: theme.text.subdued }}>
{" "}
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
</span>
@@ -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"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
@@ -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 (
<text flexShrink={0} bg={theme.raise(theme.background.default)} fg={theme.text.subdued}>
<text flexShrink={0} bg={theme.raise(background())} fg={theme.text.subdued}>
{" "}
{props.children}{" "}
</text>
@@ -2781,16 +2782,8 @@ type BlockToolProps = {
}
function BlockTool(props: BlockToolProps) {
const parentTheme = useTheme()
return (
<ThemeContextProvider context="elevated">
<BlockToolContent {...props} borderColor={parentTheme.background.default} />
</ThemeContextProvider>
)
}
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: {
</Show>
</Show>
<Show when={props.background}>
<StatusBadge>Background</StatusBadge>
<StatusBadge raised>Background</StatusBadge>
</Show>
</box>
</BlockTool>
@@ -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 (
@@ -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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(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"}
>
+4 -4
View File
@@ -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 (
<Show when={session()}>
<box
backgroundColor={theme.background.default}
backgroundColor={theme.background.raised.base}
width={SESSION_SIDEBAR_WIDTH}
height="100%"
paddingTop={1}
@@ -37,7 +37,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
title: withTimestampedFallback(session()),
}}
enabled={config.animations ?? true}
backdrop={theme.background.default}
backdrop={theme.background.raised.base}
attributes={
data.session.title.pending(props.sessionID) && config.animations === false
? TextAttributes.DIM
@@ -61,7 +61,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
width: 1,
height: "100%",
trackOptions: {
backgroundColor: theme.background.default,
backgroundColor: theme.background.raised.base,
foregroundColor: theme.scrollbar.default,
},
}}
+15 -13
View File
@@ -1,17 +1,12 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode/theme/tui"
import type { ResolvedTheme, SurfaceName } from "@opencode/theme/tui"
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
return Object.assign(createComponentThemeView(current, mode), {
contextual: {
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
},
})
}
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
export function createComponentTheme(
view: Accessor<ResolvedTheme>,
// Shared across a theme's surface views so `surface()` stays absolute at the wrapper level too.
surfaces = new Map<SurfaceName, ComponentTheme>(),
): ComponentTheme {
return {
get hue() {
return view().hue
@@ -43,8 +38,15 @@ export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, 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<typeof createComponentTheme>
export type ComponentTheme = ResolvedTheme
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
})
@@ -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) {
<box
paddingLeft={4}
paddingRight={4}
backgroundColor={overlayTheme.background.default}
backgroundColor={overlayTheme.background.raised.high}
onMouseUp={() => confirm("copy")}
>
<text fg={overlayTheme.text.default}>Copy</text>
+1 -1
View File
@@ -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?.()
+1 -1
View File
@@ -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(() => ({
+1 -1
View File
@@ -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<TextareaRenderable>()
+2 -2
View File
@@ -107,7 +107,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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
+6 -4
View File
@@ -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 (
<box
<ThemeContextProvider context="dialog">
<box
onMouseDown={() => {
dismiss = !!renderer.getSelection()
}}
@@ -67,7 +68,8 @@ export function Dialog(
>
{props.children}
</box>
</box>
</box>
</ThemeContextProvider>
)
}
+2 -2
View File
@@ -6,7 +6,7 @@ export function PaneResizeHandle(props: {
left: number
highlight?: "left" | "right"
}) {
const theme = useTheme("elevated")
const theme = useTheme()
return (
<box
@@ -25,7 +25,7 @@ export function PaneResizeHandle(props: {
height="100%"
marginLeft={props.highlight === "right" ? 1 : 0}
backgroundColor={
props.resize.hovered() || props.resize.resizing() ? theme.background.action.primary.hovered : undefined
props.resize.hovered() || props.resize.resizing() ? theme.background.raised.high : undefined
}
/>
</box>
+2 -2
View File
@@ -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}
>
<Show
when={props.toast.title}
+16 -73
View File
@@ -2,8 +2,6 @@
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { createSignal } from "solid-js"
import { DEFAULT_THEME, selectTheme } from "@opencode/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
@@ -121,29 +119,21 @@ test.each([
}
})
test("contextual hooks resolve overrides and fall back to a standalone theme's base view", async () => {
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<typeof useThemes> | undefined
let theme: ReturnType<typeof useTheme> | undefined
let explicit: ReturnType<typeof useTheme> | undefined
let contextual: ReturnType<typeof useTheme> | undefined
function ContextProbe() {
theme = useTheme()
explicit = useTheme("elevated")
return <text>{theme.text.default.toString()}</text>
contextual = useTheme()
return <text>{contextual.text.default.toString()}</text>
}
function Probe() {
themes = useThemes()
theme = useTheme()
return (
<ThemeContextProvider context="elevated">
<ThemeContextProvider context="dialog">
<ContextProbe />
</ThemeContextProvider>
)
@@ -151,8 +141,8 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "standalone", mode: "dark" } })}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ standalone }) }}>
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode: "dark" } })}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<Probe />
</ThemeProvider>
</ConfigProvider>
@@ -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<typeof useTheme> | undefined
let themes: ReturnType<typeof useThemes> | undefined
let mounts = 0
function Probe() {
mounts++
theme = useTheme()
themes = useThemes()
return <text fg={theme.text.default}>probe</text>
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
<ThemeProvider mode={mode} source={{ discover: async () => ({}) }}>
<ThemeContextProvider context={parent()}>
<ThemeContextProvider context={context()}>
<Probe />
</ThemeContextProvider>
</ThemeContextProvider>
</ThemeProvider>
</ConfigProvider>
))
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()
}
},
)
@@ -38,7 +38,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
let theme!: ReturnType<typeof useTheme>
function Colors() {
config = useConfig()
theme = orientation === "vertical" ? useTheme("elevated") : useTheme()
theme = orientation === "vertical" ? useTheme() : useTheme()
return null
}
const controller = {
+13 -15
View File
@@ -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))
+19 -41
View File
@@ -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<ContextName>()
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<ContextName>()
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])
})
+84 -73
View File
@@ -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")
})
@@ -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
@@ -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", () => {
@@ -19,7 +19,7 @@ for (const mode of ["dark", "light"] as const) {
let theme!: ReturnType<typeof useTheme>
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)
+14 -6
View File
@@ -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.