refactor(plugin): expose resolved TUI theme (#39536)

This commit is contained in:
James Long 2026-07-29 12:51:55 -04:00 committed by GitHub
commit c2e975c4e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 227 additions and 210 deletions

View file

@ -600,6 +600,7 @@
"zod": "catalog:",
},
"devDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@ -611,12 +612,14 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opencode-ai/theme",
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",

View file

@ -30,12 +30,16 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"@opencode-ai/theme": {
"optional": true
},
"@opentui/core": {
"optional": true
},
@ -50,6 +54,7 @@
}
},
"devDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",

View file

@ -19,6 +19,7 @@ import type {
ShellInfo,
SkillInfo,
} from "@opencode-ai/client"
import type { ResolvedTheme } from "@opencode-ai/theme/tui"
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
@ -356,7 +357,7 @@ export interface Context {
readonly client: OpenCodeClient
readonly data: Data
readonly attention: Attention
readonly theme: any
readonly theme: ResolvedTheme
readonly keymap: Keymap
readonly storage: Storage
readonly ui: UI

View file

@ -33,6 +33,7 @@ export {
export type {
Categorical,
ContextName,
FormfieldColor,
Hue,
HueSource,
@ -40,7 +41,7 @@ export type {
ResolvedActionState,
ResolvedFormfieldState,
ResolvedTheme,
ResolvedThemeView,
ResolvedThemeTokens,
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"

View file

@ -15,11 +15,12 @@ import {
} from "./schema.js"
import type {
ActionStateKey,
ContextName,
HueDefinition,
HueScale,
ResolvedActionState,
ResolvedTheme,
ResolvedThemeView,
ResolvedThemeTokens,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
@ -64,16 +65,17 @@ function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
const hueSteps = compileHueSteps(hue)
const base = tokens(definition)
const resolved = resolveView(base, hue, categorical, hueSteps)
const contexts = Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, override]) => {
const contextual = contextualize(base, override as ThemeTokensDefinition)
return [key, resolveView(contextual, 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, contexts } as ResolvedTheme
return { ...resolved, contextual } as ResolvedTheme
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
@ -132,15 +134,15 @@ function contextualActions(
function resolveView(
definition: ThemeTokensDefinition,
hue: ResolvedThemeView["hue"],
categorical: ResolvedThemeView["categorical"],
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
): ResolvedThemeView {
hue: ResolvedThemeTokens["hue"],
categorical: ResolvedThemeTokens["categorical"],
hueSteps: Pick<ResolvedThemeTokens, "source" | "increase" | "decrease">,
): ResolvedThemeTokens {
const source: Record<string, unknown> = { hue, ...definition }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeTokens), hue, categorical, ...hueSteps }
}
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
function compileHueSteps(hue: ResolvedThemeTokens["hue"]): Pick<ResolvedThemeTokens, "source" | "increase" | "decrease"> {
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
@ -201,7 +203,7 @@ function resolveHue(definition: HueDefinition) {
return Object.fromEntries(
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
) as ResolvedThemeView["hue"]
) as ResolvedThemeTokens["hue"]
}
function createResolver(source: Record<string, unknown>) {

View file

@ -1,7 +1,7 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeView } from "./index.js"
import type { Mode, ResolvedThemeTokens } from "./index.js"
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
const step = mode === "light" ? 800 : 200
const syntax = theme.syntax
const markdown = theme.markdown

View file

@ -7,7 +7,6 @@ import type {
HueAlias,
HueStep,
MarkdownToken,
ContextKey,
SyntaxToken,
} from "./schema.js"
@ -20,7 +19,7 @@ export type Categorical = readonly HueScale[]
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = StatefulColor
export type ResolvedThemeView = {
export type ResolvedThemeTokens = {
readonly hue: Hue
readonly categorical: Categorical
readonly source: (color: RGBA) => HueSource | undefined
@ -63,6 +62,8 @@ export type ResolvedThemeView = {
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
export type ResolvedTheme = ResolvedThemeView & {
readonly contexts: Readonly<Partial<Record<ContextKey, ResolvedThemeView>>>
export type ContextName = "elevated" | "overlay"
export type ResolvedTheme = ResolvedThemeTokens & {
readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
}

View file

@ -7,7 +7,7 @@ import {
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useThemes } from "../context/theme"
import { useTheme, useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { GoUpsellArtPainter } from "./bg-pulse-render"
@ -71,7 +71,7 @@ extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const themes = useThemes()
const theme = themes.contextual("elevated")
const theme = useTheme("elevated")
const mode = themes.mode
const renderer = useRenderer()
let targetFps = renderer.targetFps

View file

@ -36,7 +36,7 @@ export function DevToolsBar() {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = themes.contextual("elevated")
const elevatedTheme = useTheme("elevated")
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
@ -435,7 +435,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
}
function PanelBox(props: ParentProps) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const renderer = useRenderer()
return (
<box
@ -461,7 +461,7 @@ function PanelBox(props: ParentProps) {
}
function PanelTitle(props: ParentProps) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
return (
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
@ -470,7 +470,7 @@ function PanelTitle(props: ParentProps) {
}
function Row(props: { label: string; value: string }) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
return (
<box flexDirection="row">
<text fg={theme.text.subdued}>{props.label}</text>
@ -481,7 +481,7 @@ function Row(props: { label: string; value: string }) {
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
@ -506,7 +506,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"

View file

@ -11,7 +11,7 @@ import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
@ -64,7 +64,7 @@ export function DialogIntegration(
) {
const data = useData()
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
@ -303,8 +303,8 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
onMount(() => dialog.setSize("large"))
return (
<box gap={1} paddingBottom={1}>
@ -341,7 +341,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [error, setError] = createSignal<string>()
return (
@ -516,7 +516,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [error, setError] = createSignal<string>()
let settled = false
@ -561,7 +561,7 @@ function OAuthCode(props: {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">

View file

@ -5,7 +5,7 @@ import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
@ -20,7 +20,7 @@ function statusError(status: McpServer["status"]) {
}
function Status(props: { enabled: boolean; loading: boolean }) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
@ -33,7 +33,7 @@ export function DialogMcp() {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
@ -134,8 +134,8 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)

View file

@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useData } from "../context/data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const sessionData = useData()
const route = useRoute()
const toast = useToast()

View file

@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { renderUnicodeCompact } from "uqr"
import { useClient } from "../context/client"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error"
@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)

View file

@ -2,12 +2,12 @@ import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts()
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable

View file

@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
@ -38,7 +38,7 @@ function panelOverlay(color: RGBA) {
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")

View file

@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
onDone?: () => void
}) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})

View file

@ -7,7 +7,7 @@ import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { useThemes } from "../context/theme"
import { useTheme, useThemes } from "../context/theme"
import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
@ -22,7 +22,7 @@ export function DialogSessionList() {
const route = useRoute()
const data = useData()
const themes = useThemes()
const theme = themes.contextual("elevated")
const theme = useTheme("elevated")
const mode = themes.mode
const client = useClient()
const local = useLocal()

View file

@ -3,7 +3,7 @@ import { DialogSelect } from "../ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash"
function getRelativeTime(timestamp: number): string {
@ -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 = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>()

View file

@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js"
@ -8,7 +8,7 @@ export type DialogStatusProps = {}
export function DialogStatus() {
const data = useData()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])

View file

@ -4,7 +4,7 @@ import type { VcsFileStatus } from "@opencode-ai/client"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { FilePath } from "../ui/file-path"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useConfig } from "../config"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"
@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))

View file

@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime"
import { useConfig } from "../../config"
import { useLocation } from "../../context/location"
import { useThemes } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
@ -57,7 +57,7 @@ export function Autocomplete(props: {
const data = useData()
const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const theme = useThemes().contextual("overlay")
const theme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const config = useConfig().data

View file

@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
return (
<box

View file

@ -1,9 +1,9 @@
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [show, setShow] = createSignal(false)
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
let wait: NodeJS.Timeout | undefined

View file

@ -1,6 +1,12 @@
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui"
import {
generateSyntax,
resolveThemeDocument,
themeModes,
type ResolvedTheme,
type ContextName,
} from "@opencode-ai/theme/tui"
import {
DEFAULT_THEMES,
addTheme,
@ -95,10 +101,9 @@ type State = {
ready: boolean
}
type ContextName = "elevated" | "overlay"
type Themes = {
current: ComponentTheme
contextual(context: ContextName): ComponentTheme
currentTokens: Accessor<ResolvedTheme>
readonly selected: string
all: typeof allThemes
has: typeof hasTheme
@ -115,6 +120,12 @@ type Themes = {
readonly ready: boolean
}
type ThemeContextValue = {
current: ComponentTheme["contextual"][ContextName]
themes: Themes
readonly ready: boolean
}
const [store, setStore] = createStore<State>({
themes: allThemes(),
mode: "dark",
@ -127,7 +138,7 @@ subscribeThemes((themes) => setStore("themes", themes))
const themeContext = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => {
const renderer = useRenderer()
const configState = useConfig()
const config = configState.data
@ -309,21 +320,14 @@ const themeContext = createSimpleContext({
valuesV2()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const current = createComponentTheme(valuesV2, mode)
const contextsV2 = {
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
}
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
function contextual(context: ContextName) {
return contextsV2[context]
}
const service: Themes = {
current,
currentTokens: valuesV2,
currentSyntax,
contextual,
get selected() {
return store.active
},
@ -368,15 +372,20 @@ const themeContext = createSimpleContext({
export function useThemes() {
return themeContext.use().themes
}
export function useTheme() {
return themeContext.use().current
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 const ThemeProvider = themeContext.provider
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
const themes = useThemes()
const value = themeContext.use()
return (
<themeContext.context.Provider value={{ current: themes.contextual(props.context), themes, ready: themes.ready }}>
<themeContext.context.Provider
value={{ current: value.themes.current.contextual[props.context], themes: value.themes, ready: value.ready }}
>
{props.children}
</themeContext.context.Provider>
)

View file

@ -18,6 +18,7 @@ import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import { useThemes } from "../../context/theme"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@ -83,6 +84,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
const config = useConfig()
const dialog = props.context.ui.dialog
const theme = props.context.theme
const currentSyntax = useThemes().currentSyntax
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
@ -834,7 +836,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
diff={patch()}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={theme.syntaxStyle()}
syntaxStyle={currentSyntax()}
showLineNumbers={true}
width="100%"
wrapMode="char"
@ -941,7 +943,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const theme = props.context.theme.contextual("elevated")
const theme = props.context.theme.contextual.elevated
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
const rows = [
{

View file

@ -44,7 +44,7 @@ function Commands(props: { context: Plugin.Context }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const elevatedTheme = props.context.theme.contextual("elevated")
const elevatedTheme = theme.contextual.elevated
const [tabs, setTabs] = createSignal(FIXTURE_TABS.slice(0, 6))
const [active, setActive] = createSignal<string | undefined>("fixture-2")
const [animations, setAnimations] = createSignal(true)

View file

@ -24,7 +24,7 @@ import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location"
import { useTheme, useThemes } from "../context/theme"
import { useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
@ -91,9 +91,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const app = useTuiApp()
const paths = useTuiPaths()
const location = useLocation()
const theme = useTheme()
const themes = useThemes()
const pluginTheme = createPluginTheme(theme, themes)
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
@ -226,7 +224,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
client: client.api,
data,
attention,
theme: pluginTheme,
get theme() {
return themes.currentTokens()
},
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
@ -530,24 +530,6 @@ function isPlugin(value: unknown): value is Plugin.Definition {
)
}
type PluginTheme = ReturnType<typeof useTheme> & {
contextual(context: "elevated" | "overlay"): PluginTheme
syntaxStyle(): ReturnType<ReturnType<typeof useThemes>["currentSyntax"]>
}
export function createPluginTheme(theme: ReturnType<typeof useTheme>, themes: ReturnType<typeof useThemes>): PluginTheme {
return new Proxy(theme as PluginTheme, {
get(target, property, receiver) {
if (property === "contextual") {
return (context: "elevated" | "overlay") => createPluginTheme(themes.contextual(context), themes)
}
if (property === "syntaxStyle") return themes.currentSyntax
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver)
return Reflect.get(themes, property, themes)
},
})
}
export function usePlugin() {
const value = useContext(PluginContext)
if (!value) throw new Error("PluginProvider is missing")

View file

@ -1,7 +1,7 @@
import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js"
import { createStore } from "solid-js/store"
import { TextAttributes } from "@opentui/core"
import { useThemes } from "../../../context/theme"
import { useTheme } from "../../../context/theme"
import { SplitBorder } from "../../../ui/border"
import { Keymap } from "../../../context/keymap"
import { SubagentsTab } from "./subagents-tab"
@ -39,7 +39,7 @@ export type ComposerProps = {
}
export function Composer(props: ComposerProps) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [store, setStore] = createStore({
tabs: {} as Record<string, Tab>,

View file

@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import open from "open"
import { useThemes } from "../../context/theme"
import { useTheme, useThemes } from "../../context/theme"
import type { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client"
@ -45,7 +45,7 @@ function requestOptions(form: FormWithLocation) {
export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient()
const themes = useThemes()
const theme = themes.contextual("elevated")
const theme = useTheme("elevated")
const themeMode = themes.mode
const renderer = useRenderer()
const dimensions = useTerminalDimensions()

View file

@ -1494,7 +1494,7 @@ function SessionGroupView(props: {
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const local = useLocal()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const model = createMemo(
() =>
ctx
@ -1691,7 +1691,7 @@ function RevertMessage(props: {
}>
}) {
const ctx = use()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const route = useRouteData("session")
const client = useClient()
const toast = useToast()
@ -1764,7 +1764,7 @@ function RevertMessage(props: {
}
function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "shell" }> }) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? ""))
return (
@ -1792,7 +1792,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const themes = useThemes()
const theme = themes.contextual("elevated")
const theme = useTheme("elevated")
const mode = themes.mode
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
@ -1869,7 +1869,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
const ctx = use()
const local = useLocal()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const model = createMemo(
() =>
ctx

View file

@ -297,7 +297,7 @@ function RejectPrompt(props: {
onCancel: () => void
}) {
let input: TextareaRenderable
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
Keymap.createLayer(() => ({
@ -429,7 +429,7 @@ function Prompt<const T extends Record<string, string>>(props: {
fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({

View file

@ -1,6 +1,6 @@
import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useThemes } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { PluginSlot } from "../../plugin/context"
@ -8,7 +8,7 @@ import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const data = useData()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const config = useConfig().data
const session = createMemo(() => data.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))

View file

@ -1,7 +1,7 @@
import { createMemo, createSignal, Show } from "solid-js"
import { useRouteData } from "../../context/route"
import { useData } from "../../context/data"
import { useThemes } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
@ -42,7 +42,7 @@ export function SubagentFooter() {
}
})
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)

View file

@ -1,41 +1,48 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui"
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui"
export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
return {
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
const create = (view: Accessor<ResolvedThemeTokens>) => ({
get hue() {
return current().hue
return view().hue
},
get categorical() {
return current().categorical
return view().categorical
},
get text() {
return current().text
return view().text
},
get background() {
return current().background
return view().background
},
get border() {
return current().border
return view().border
},
get scrollbar() {
return current().scrollbar
return view().scrollbar
},
get diff() {
return current().diff
return view().diff
},
get syntax() {
return current().syntax
return view().syntax
},
get markdown() {
return current().markdown
return view().markdown
},
source: (color: RGBA) => current().source(color),
increase: (color: RGBA, amount = 1) => current().increase(color, amount),
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)),
}
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)),
})
return Object.assign(create(current), {
contextual: {
elevated: create(() => current().contextual.elevated),
overlay: create(() => current().contextual.overlay),
},
})
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>

View file

@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
export type DialogAlertProps = {
@ -11,7 +11,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
Keymap.createLayer(() => ({
mode: "modal",

View file

@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
@ -21,7 +21,7 @@ export type DialogConfirmResult = boolean | undefined
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})

View file

@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For, Show } from "solid-js"
@ -17,8 +17,8 @@ type Active = ExportFormat | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const overlayTheme = useThemes().contextual("overlay")
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
thinking: props.defaultThinking,

View file

@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
export function DialogExportResult(props: { path: string; onClose?: () => void }) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const close = () => {
props.onClose?.()

View file

@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog } from "./dialog"
export function DialogHelp() {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts()
Keymap.createLayer(() => ({

View file

@ -1,6 +1,6 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
@ -18,7 +18,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts()
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable

View file

@ -1,6 +1,6 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { useThemes } from "../context/theme"
import { useTheme, useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
@ -96,7 +96,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const themes = useThemes()
const theme = themes.contextual("elevated")
const theme = useTheme("elevated")
const mode = themes.mode
const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@ -773,7 +773,7 @@ function Option(props: {
activeColor?: RGBA
onMouseOver?: () => void
}) {
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
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

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 { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
@ -16,7 +16,7 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const theme = useThemes().contextual("elevated")
const theme = useTheme("elevated")
const renderer = useRenderer()
let dismiss = false

View file

@ -1,6 +1,6 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "./border"
import { TextAttributes } from "@opentui/core"
@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() {
const toast = useToast()
const theme = useThemes().contextual("overlay")
const theme = useTheme("overlay")
const dimensions = useTerminalDimensions()
return (

View file

@ -4,7 +4,7 @@ import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ConfigProvider } from "../../../src/config"
import {
@ -12,7 +12,6 @@ import {
type DiffViewerFileTreeProps,
} from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createPluginTheme } from "../../../src/plugin/context"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@ -130,7 +129,7 @@ describe("DiffViewerFileTree", () => {
})
function ThemedDiffViewerFileTree(props: Omit<DiffViewerFileTreeProps, "context">) {
return <DiffViewerFileTree {...props} context={{ theme: createPluginTheme(useTheme(), useThemes()) } as Plugin.Context} />
return <DiffViewerFileTree {...props} context={{ theme: useThemes().currentTokens() } as Plugin.Context} />
}
async function renderFrame(component: () => JSX.Element) {

View file

@ -11,7 +11,7 @@ import type {
Route,
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
@ -21,7 +21,6 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createPluginTheme } from "../../../src/plugin/context"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@ -158,7 +157,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
})
}, createEventStream())
function Harness() {
let theme: ReturnType<typeof createPluginTheme>
let theme: ReturnType<ReturnType<typeof useThemes>["currentTokens"]>
const context = {
options: {},
client: createApi(transport.fetch),
@ -207,7 +206,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
void diffViewerPlugin.setup(context)
function Content() {
theme = createPluginTheme(useTheme(), useThemes())
theme = useThemes().currentTokens()
const commandView = renderCommands?.({})
if (current.type !== "plugin") commands.get("diff.open")?.run()
return (

View file

@ -6,7 +6,7 @@ import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
import { ThemeContextProvider, ThemeProvider, useTheme, useThemes, type ThemeError } from "../../../src/context/theme"
import { ThemeContextProvider, ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme"
async function wait(fn: () => boolean) {
const started = Date.now()
@ -129,9 +129,11 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
} as const
let themes: ReturnType<typeof useThemes> | undefined
let theme: ReturnType<typeof useTheme> | undefined
let explicit: ReturnType<typeof useTheme> | undefined
function ContextProbe() {
theme = useTheme()
explicit = useTheme("elevated")
return <text>{theme.text.default.toString()}</text>
}
@ -160,9 +162,11 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
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(themes.contextual("elevated").text.default)
expect(themes.contextual("overlay").background.default).toBe(themes.current.background.default)
expect(theme).toBe(explicit)
expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default)
expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default)
} finally {
app.renderer.destroy()
}

View file

@ -1,17 +1,18 @@
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextKey } from "@opencode-ai/theme/tui"
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/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 [context, setContext] = createSignal<ContextKey>()
const theme = createComponentTheme(() => {
const key = context()
return key ? (resolved().contexts[key] ?? resolved()) : resolved()
}, mode)
const theme = createComponentTheme(resolved, mode)
const [context, setContext] = createSignal<ContextName>()
const current = () => {
const name = context()
return name ? theme.contextual[name] : theme
}
expect(theme.text.default).toBe(resolved().text.default)
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
@ -50,20 +51,21 @@ 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("@context:elevated")
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.background.action.primary.focused).toBe(
resolved().contexts["@context:elevated"]!.background.action.primary.focused,
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(theme.background.action.primary.hovered).toBe(resolved().background.surface.overlay)
expect(theme.background.formfield.selected).toBe(
resolved().contexts["@context:elevated"]!.background.formfield.selected,
expect(current().background.action.primary.hovered).toBe(resolved().background.surface.overlay)
expect(current().background.formfield.selected).toBe(
resolved().contextual.elevated.background.formfield.selected,
)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
setMode("dark")
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.decrease(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[600])
expect(current().text.default).toBe(resolved().contextual.elevated.text.default)
expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600])
})

View file

@ -37,7 +37,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.contexts["@context:elevated"]?.categorical).toBe(theme.categorical)
expect(theme.contextual.elevated.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")
})
@ -65,29 +65,29 @@ test("resolves independent definitions and hue aliases", () => {
expect(lightTheme.source(lightTheme.background.surface.offset)).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.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.contextual.elevated.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300])
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400])
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
expect(lightTheme.contextual.elevated.background.action.primary.default).toBe(
lightTheme.hue.interactive[500],
)
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.surface.offset)
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.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.surface.overlay)
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.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
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])
})
test("resolves base hue aliases and rejects circular hue aliases", () => {
@ -228,8 +228,8 @@ test("resolves elevated hover surfaces from direct colors", () => {
"light",
)
expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255])
expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
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])
})
test("resolves transparent colors", () => {
@ -271,7 +271,7 @@ test("context overrides rewire semantic references and apply state precedence",
},
})
const theme = resolveTheme(definition)
const overlay = theme.contexts["@context:elevated"]!
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])

View file

@ -43,11 +43,11 @@ test("migrates resolved V1 modes into V2 tokens", () => {
expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
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])
})
test("references generated hues from matching token colors", () => {