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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core" 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 step = mode === "light" ? 800 : 200
const syntax = theme.syntax const syntax = theme.syntax
const markdown = theme.markdown const markdown = theme.markdown

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js" import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { renderUnicodeCompact } from "uqr" import { renderUnicodeCompact } from "uqr"
import { useClient } from "../context/client" import { useClient } from "../context/client"
import { useThemes } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
@ -16,7 +16,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const client = useClient() const client = useClient()
const dialog = useDialog() const dialog = useDialog()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const theme = useThemes().contextual("elevated") const theme = useTheme("elevated")
const [loadError, setLoadError] = createSignal<unknown>() const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false) const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = 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 { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js" import { createSignal, onMount } from "solid-js"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog" import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog() const dialog = useDialog()
const theme = useThemes().contextual("elevated") const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
const [inputTarget, setInputTarget] = createSignal<InputRenderable>() const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable let input: InputRenderable

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useThemes } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { useData } from "../context/data" import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js" import { For, Match, Switch, Show, createMemo } from "solid-js"
@ -8,7 +8,7 @@ export type DialogStatusProps = {}
export function DialogStatus() { export function DialogStatus() {
const data = useData() const data = useData()
const theme = useThemes().contextual("elevated") const theme = useTheme("elevated")
const dialog = useDialog() const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.server.list() ?? []) 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 { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { FilePath } from "../ui/file-path" import { FilePath } from "../ui/file-path"
import { useThemes } from "../context/theme" import { useTheme } from "../context/theme"
import { useConfig } from "../config" import { useConfig } from "../config"
import { useDialog, type DialogContext } from "../ui/dialog" import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
message?: string message?: string
}) { }) {
const dialog = useDialog() const dialog = useDialog()
const theme = useThemes().contextual("elevated") const theme = useTheme("elevated")
const overlayTheme = useThemes().contextual("overlay") const overlayTheme = useTheme("overlay")
const config = useConfig().data const config = useConfig().data
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const scrollAcceleration = createMemo(() => getScrollAcceleration(config))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,7 +24,7 @@ import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime" import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location" import { useLocation } from "../context/location"
import { useTheme, useThemes } from "../context/theme" import { useThemes } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert" import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm" import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt" import { DialogPrompt } from "../ui/dialog-prompt"
@ -91,9 +91,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const app = useTuiApp() const app = useTuiApp()
const paths = useTuiPaths() const paths = useTuiPaths()
const location = useLocation() const location = useLocation()
const theme = useTheme()
const themes = useThemes() const themes = useThemes()
const pluginTheme = createPluginTheme(theme, themes)
const dialog = useDialog() const dialog = useDialog()
const toast = useToast() const toast = useToast()
const attention = useAttention() const attention = useAttention()
@ -226,7 +224,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
client: client.api, client: client.api,
data, data,
attention, attention,
theme: pluginTheme, get theme() {
return themes.currentTokens()
},
keymap: { keymap: {
layer: Keymap.createLayer, layer: Keymap.createLayer,
dispatch: keymap.dispatch, 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() { export function usePlugin() {
const value = useContext(PluginContext) const value = useContext(PluginContext)
if (!value) throw new Error("PluginProvider is missing") 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 { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useThemes } from "../../../context/theme" import { useTheme } from "../../../context/theme"
import { SplitBorder } from "../../../ui/border" import { SplitBorder } from "../../../ui/border"
import { Keymap } from "../../../context/keymap" import { Keymap } from "../../../context/keymap"
import { SubagentsTab } from "./subagents-tab" import { SubagentsTab } from "./subagents-tab"
@ -39,7 +39,7 @@ export type ComposerProps = {
} }
export function Composer(props: ComposerProps) { export function Composer(props: ComposerProps) {
const theme = useThemes().contextual("elevated") const theme = useTheme("elevated")
const [store, setStore] = createStore({ const [store, setStore] = createStore({
tabs: {} as Record<string, Tab>, 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 { useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import open from "open" 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 { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data" import type { FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client" import { useClient } from "../../context/client"
@ -45,7 +45,7 @@ function requestOptions(form: FormWithLocation) {
export function FormPrompt(props: { form: FormWithLocation }) { export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient() const client = useClient()
const themes = useThemes() const themes = useThemes()
const theme = themes.contextual("elevated") const theme = useTheme("elevated")
const themeMode = themes.mode const themeMode = themes.mode
const renderer = useRenderer() const renderer = useRenderer()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useThemes } from "../context/theme" import { useTheme } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core" import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
@ -16,7 +16,7 @@ export function Dialog(
}>, }>,
) { ) {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const theme = useThemes().contextual("elevated") const theme = useTheme("elevated")
const renderer = useRenderer() const renderer = useRenderer()
let dismiss = false let dismiss = false

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme" import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config" 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) { async function wait(fn: () => boolean) {
const started = Date.now() const started = Date.now()
@ -129,9 +129,11 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
} as const } as const
let themes: ReturnType<typeof useThemes> | undefined let themes: ReturnType<typeof useThemes> | undefined
let theme: ReturnType<typeof useTheme> | undefined let theme: ReturnType<typeof useTheme> | undefined
let explicit: ReturnType<typeof useTheme> | undefined
function ContextProbe() { function ContextProbe() {
theme = useTheme() theme = useTheme()
explicit = useTheme("elevated")
return <text>{theme.text.default.toString()}</text> 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) await wait(() => themes?.ready === true)
if (!themes) throw new Error("Theme provider is not mounted") if (!themes) throw new Error("Theme provider is not mounted")
if (!theme) throw new Error("Contextual theme 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.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
expect(theme.text.default).toBe(themes.contextual("elevated").text.default) expect(theme).toBe(explicit)
expect(themes.contextual("overlay").background.default).toBe(themes.current.background.default) expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default)
expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }

View file

@ -1,17 +1,18 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { RGBA } from "@opentui/core" 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" import { createComponentTheme } from "../../../src/theme/component"
test("provides reactive properties, states, contexts, and color operations", () => { test("provides reactive properties, states, contexts, and color operations", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
const [mode, setMode] = createSignal<"light" | "dark">("light") const [mode, setMode] = createSignal<"light" | "dark">("light")
const [context, setContext] = createSignal<ContextKey>() const theme = createComponentTheme(resolved, mode)
const theme = createComponentTheme(() => { const [context, setContext] = createSignal<ContextName>()
const key = context() const current = () => {
return key ? (resolved().contexts[key] ?? resolved()) : resolved() const name = context()
}, mode) return name ? theme.contextual[name] : theme
}
expect(theme.text.default).toBe(resolved().text.default) expect(theme.text.default).toBe(resolved().text.default)
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500]) 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.scrollbar.default).toBe(resolved().scrollbar.default)
expect(theme.diff.text.added).toBe(resolved().diff.text.added) expect(theme.diff.text.added).toBe(resolved().diff.text.added)
setContext("@context:elevated") setContext("elevated")
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500])) expect("contexts" in current()).toBeFalse()
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default) expect(current().categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
expect(theme.background.action.primary.focused).toBe( expect(current().text.default).toBe(resolved().contextual.elevated.text.default)
resolved().contexts["@context:elevated"]!.background.action.primary.focused, 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(current().background.action.primary.hovered).toBe(resolved().background.surface.overlay)
expect(theme.background.formfield.selected).toBe( expect(current().background.formfield.selected).toBe(
resolved().contexts["@context:elevated"]!.background.formfield.selected, resolved().contextual.elevated.background.formfield.selected,
) )
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"))) setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
setMode("dark") setMode("dark")
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default) expect(current().text.default).toBe(resolved().contextual.elevated.text.default)
expect(theme.decrease(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[600]) expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
expect(theme.raise(theme.background.surface.offset)).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[0]).toBe(theme.hue.accent)
expect(theme.categorical[1]).toBe(theme.hue.red) expect(theme.categorical[1]).toBe(theme.hue.red)
expect(theme.categorical[2]).toBe(theme.hue.interactive) 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: [] } }, "light")).toThrow("Invalid theme")
expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "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.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) 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.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(lightTheme.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.background.default).toBeInstanceOf(RGBA) expect(darkTheme.background.default).toBeInstanceOf(RGBA)
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300]) expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300])
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400]) expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400])
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA) expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200]) 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], lightTheme.hue.interactive[500],
) )
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset) expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.surface.offset)
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) expect(lightTheme.contextual.elevated.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe( expect(lightTheme.contextual.overlay.background.action.primary.default).toBe(
lightTheme.hue.interactive[500], lightTheme.hue.interactive[500],
) )
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay) expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.surface.overlay)
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100]) expect(lightTheme.contextual.overlay.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe( expect(darkTheme.contextual.elevated.background.action.primary.default).toBe(
darkTheme.hue.interactive[400], darkTheme.hue.interactive[400],
) )
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200]) expect(darkTheme.contextual.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.contextual.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.overlay.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
}) })
test("resolves base hue aliases and rejects circular hue aliases", () => { test("resolves base hue aliases and rejects circular hue aliases", () => {
@ -228,8 +228,8 @@ test("resolves elevated hover surfaces from direct colors", () => {
"light", "light",
) )
expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255]) expect(theme.contextual.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.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
}) })
test("resolves transparent colors", () => { test("resolves transparent colors", () => {
@ -271,7 +271,7 @@ test("context overrides rewire semantic references and apply state precedence",
}, },
}) })
const theme = resolveTheme(definition) 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.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 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.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts()) expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.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.contextual.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.contextual.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.contextual.elevated.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts()) expect(resolved.contextual.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.overlay.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
}) })
test("references generated hues from matching token colors", () => { test("references generated hues from matching token colors", () => {