mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 14:56:27 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4ade62900 |
@@ -218,20 +218,6 @@ export const DEFAULT_THEME = {
|
||||
imageText: "$hue.cyan.600",
|
||||
codeBlock: "$hue.neutral.900",
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.raised.high",
|
||||
action: { primary: { default: "$hue.interactive.500" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
hue: {
|
||||
@@ -440,19 +426,5 @@ export const DEFAULT_THEME = {
|
||||
imageText: "$hue.cyan.400",
|
||||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.raised.high",
|
||||
action: { primary: { default: "$hue.interactive.400" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ThemeDocument
|
||||
|
||||
@@ -8,15 +8,7 @@ import type {
|
||||
import { ActionState } from "./schema.js"
|
||||
|
||||
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
|
||||
return {
|
||||
...definition,
|
||||
...expandTokens(definition),
|
||||
...Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
|
||||
),
|
||||
}
|
||||
return { ...definition, ...expandTokens(definition) }
|
||||
}
|
||||
|
||||
export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
|
||||
|
||||
@@ -26,14 +26,12 @@ export {
|
||||
type MergeModeDefinition,
|
||||
type Mode,
|
||||
type StatefulColorDefinition,
|
||||
type ContextKey,
|
||||
type TextDefinition,
|
||||
type ThemeTokensDefinition,
|
||||
} from "./schema.js"
|
||||
|
||||
export type {
|
||||
Categorical,
|
||||
ContextName,
|
||||
FormfieldColor,
|
||||
Hue,
|
||||
HueSource,
|
||||
@@ -43,6 +41,7 @@ export type {
|
||||
ResolvedTheme,
|
||||
ResolvedThemeTokens,
|
||||
StatefulColor,
|
||||
SurfaceName,
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
export { expandTheme } from "./expand.js"
|
||||
|
||||
@@ -3,25 +3,14 @@ import { Schema } from "effect"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
|
||||
import { fallback } from "./fallback.js"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
} from "./schema.js"
|
||||
import { ActionState, BaseHue, HueAlias, HueStep, ThemeDefinition, ThemeDocument } from "./schema.js"
|
||||
import type {
|
||||
ActionStateKey,
|
||||
ContextName,
|
||||
HueDefinition,
|
||||
HueScale,
|
||||
ResolvedActionState,
|
||||
Mode,
|
||||
ResolvedTheme,
|
||||
ResolvedThemeTokens,
|
||||
StatefulColorDefinition,
|
||||
SurfaceName,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index.js"
|
||||
import { selectTheme, selectThemeMode } from "./select.js"
|
||||
@@ -42,40 +31,54 @@ export function themeDecodeError(error: unknown, name: string) {
|
||||
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
|
||||
}
|
||||
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback(selected.mode))
|
||||
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
|
||||
} as ThemeDefinition)
|
||||
return resolveExpandedTheme(
|
||||
{
|
||||
...merged,
|
||||
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
|
||||
} as ThemeDefinition,
|
||||
selected.mode,
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
|
||||
export function resolveTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)), mode)
|
||||
}
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
// Surfaces are code-owned: a theme file describes one palette, and each surface is that palette
|
||||
// re-resolved with a raised background so `$background.default` references follow it.
|
||||
const SURFACES: Record<SurfaceName, ThemeTokensDefinition> = {
|
||||
raised: {
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
overlay: { background: { default: "$background.raised.high" } },
|
||||
}
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const raise = (color: RGBA) => (mode === "light" ? hueSteps.increase(color) : hueSteps.decrease(color))
|
||||
const base = tokens(definition)
|
||||
const resolved = resolveView(base, hue, categorical, hueSteps)
|
||||
const context = (name: ContextName) => {
|
||||
const override = definition[`@context:${name}`]
|
||||
if (!override) return resolved
|
||||
return resolveView(contextualize(base, override), hue, categorical, hueSteps)
|
||||
const views = {} as Record<SurfaceName, ResolvedTheme>
|
||||
const view = (tokens: ThemeTokensDefinition): ResolvedTheme => ({
|
||||
...resolveView(tokens, hue, categorical, hueSteps),
|
||||
raise,
|
||||
surface: (name) => views[name],
|
||||
})
|
||||
for (const name of Object.keys(SURFACES) as SurfaceName[]) {
|
||||
views[name] = view(mergeTheme(base, SURFACES[name]) as ThemeTokensDefinition)
|
||||
}
|
||||
const contextual = {
|
||||
elevated: context("elevated"),
|
||||
overlay: context("overlay"),
|
||||
}
|
||||
|
||||
return { ...resolved, contextual } as ResolvedTheme
|
||||
return view(base)
|
||||
}
|
||||
|
||||
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
|
||||
@@ -90,48 +93,6 @@ function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
|
||||
const result = mergeTheme(base, override)
|
||||
const baseText = base.text?.action
|
||||
const contextText = override.text?.action
|
||||
const baseBackground = base.background?.action
|
||||
const contextBackground = override.background?.action
|
||||
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
|
||||
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
|
||||
return {
|
||||
...result,
|
||||
text: { ...text, action: contextualActions(baseText, contextText) },
|
||||
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
|
||||
} as ThemeTokensDefinition
|
||||
}
|
||||
|
||||
function contextualActions(
|
||||
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
ActionVariant.literals.map((variant) => {
|
||||
const baseVariant = base?.[variant]
|
||||
const contextVariant = context?.[variant]
|
||||
return [
|
||||
variant,
|
||||
Object.fromEntries(
|
||||
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
|
||||
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
|
||||
return [
|
||||
key ?? "default",
|
||||
(key ? contextVariant?.[key] : undefined) ??
|
||||
contextVariant?.default ??
|
||||
(key ? baseVariant?.[key] : undefined) ??
|
||||
baseVariant?.default,
|
||||
]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveView(
|
||||
definition: ThemeTokensDefinition,
|
||||
hue: ResolvedThemeTokens["hue"],
|
||||
|
||||
@@ -40,9 +40,6 @@ export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLen
|
||||
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
|
||||
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
|
||||
|
||||
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
|
||||
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
|
||||
|
||||
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
|
||||
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
|
||||
|
||||
@@ -229,8 +226,6 @@ const ThemeDefinitionFields = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export const ThemeDefinition = ThemeDefinitionFields
|
||||
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
|
||||
@@ -239,8 +234,6 @@ const FileThemeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
|
||||
|
||||
@@ -249,8 +242,6 @@ const MergeModeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
|
||||
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
|
||||
|
||||
@@ -69,8 +69,11 @@ export type ResolvedThemeTokens = {
|
||||
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
|
||||
}
|
||||
|
||||
export type ContextName = "elevated" | "overlay"
|
||||
export type SurfaceName = "raised" | "overlay"
|
||||
|
||||
export type ResolvedTheme = ResolvedThemeTokens & {
|
||||
readonly contextual: Readonly<Record<ContextName, ResolvedThemeTokens>>
|
||||
/** Lift a ramp color one step away from the background: lighter in light mode, darker in dark mode. */
|
||||
readonly raise: (color: RGBA) => RGBA
|
||||
/** The same theme re-resolved on a raised surface. Absolute: every view's surfaces are the base theme's. */
|
||||
readonly surface: (name: SurfaceName) => ResolvedTheme
|
||||
}
|
||||
|
||||
@@ -173,13 +173,6 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
imageText: color("markdownImageText"),
|
||||
codeBlock: color("markdownCodeBlock"),
|
||||
},
|
||||
"@context:elevated": {
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": { background: { default: "$background.raised.high" } },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors"
|
||||
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
|
||||
expect(theme.surface("raised").text.status).toEqual(theme.text.status)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ test.each(["light", "dark"] as const)("custom %s themes inherit the unread atten
|
||||
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
|
||||
expect(theme.surface("raised").text.status).toEqual(theme.text.status)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export function DevToolsBar() {
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { current: theme, mode, supports, setMode } = themes
|
||||
const elevatedTheme = useTheme("elevated")
|
||||
const elevatedTheme = useTheme().surface("raised")
|
||||
const [panel, setPanel] = createSignal<Panel>()
|
||||
const [dumping, setDumping] = createSignal(false)
|
||||
const [dumpPath, setDumpPath] = createSignal<string>()
|
||||
@@ -474,7 +474,7 @@ function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||
}
|
||||
|
||||
function PanelBox(props: ParentProps) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const renderer = useRenderer()
|
||||
return (
|
||||
<box
|
||||
@@ -500,7 +500,7 @@ function PanelBox(props: ParentProps) {
|
||||
}
|
||||
|
||||
function PanelTitle(props: ParentProps) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
return (
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
{props.children}
|
||||
@@ -509,7 +509,7 @@ function PanelTitle(props: ParentProps) {
|
||||
}
|
||||
|
||||
function Row(props: { label: string; value: string }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.text.subdued}>{props.label}</text>
|
||||
@@ -520,7 +520,7 @@ function Row(props: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
@@ -545,7 +545,7 @@ function cpuPercent(microseconds: number, milliseconds: number) {
|
||||
}
|
||||
|
||||
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const value = () => {
|
||||
const value = props.values.at(-1)
|
||||
if (value === undefined) return "--"
|
||||
|
||||
@@ -27,7 +27,7 @@ export function DialogErrorDetails(props: {
|
||||
const location = useLocation()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
|
||||
@@ -13,7 +13,7 @@ type ImagePreviewItem = Readonly<{
|
||||
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
const current = createMemo(() => props.images[index()])
|
||||
|
||||
@@ -78,7 +78,7 @@ export function DialogIntegration(
|
||||
const data = useData()
|
||||
const currentLocation = useLocation()
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
const integrations = createMemo(() =>
|
||||
integrationOptions(data.location.integration.list(location) ?? []).filter(
|
||||
@@ -153,7 +153,7 @@ function manageConnections(
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
|
||||
@@ -427,8 +427,8 @@ function CommandPending(props: {
|
||||
|
||||
function CommandView(props: { title: string; output: string; message: string }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const overlayTheme = useTheme("overlay")
|
||||
const theme = useTheme().surface("raised")
|
||||
const overlayTheme = useTheme().surface("overlay")
|
||||
onMount(() => dialog.setSize("large"))
|
||||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
@@ -467,7 +467,7 @@ function KeyMethod(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [error, setError] = createSignal<string>()
|
||||
|
||||
return (
|
||||
@@ -672,7 +672,7 @@ function OAuthCode(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [error, setError] = createSignal<string>()
|
||||
let settled = false
|
||||
|
||||
@@ -724,7 +724,7 @@ function OAuthView(props: {
|
||||
open?: boolean
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
@@ -852,7 +852,7 @@ function textAnswer(
|
||||
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [error, setError] = createSignal<string>()
|
||||
return (
|
||||
<DialogPrompt
|
||||
|
||||
@@ -40,7 +40,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
|
||||
const client = useClient()
|
||||
const location = useLocation()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const current = () => location.ref ?? data.location.default()
|
||||
const servers = createMemo(() =>
|
||||
pipe(
|
||||
|
||||
@@ -44,7 +44,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
const sessionTabs = useSessionTabs()
|
||||
const toast = useToast()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const mode = themes.mode
|
||||
const paths = useTuiPaths()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
const [showPassword, setShowPassword] = createSignal(false)
|
||||
const [passwordHover, setPasswordHover] = createSignal(false)
|
||||
|
||||
@@ -30,7 +30,7 @@ export function DialogSessionList() {
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const mode = themes.mode
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
|
||||
@@ -13,7 +13,7 @@ const PAGE_BYTES = 64 * 1024
|
||||
export function DialogShellOutput(props: { shell: ShellInfo; location: LocationRef }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [info, setInfo] = createSignal(props.shell)
|
||||
const [output, setOutput] = createSignal<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 = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export function DialogStatus() {
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
||||
|
||||
@@ -15,7 +15,7 @@ export function DialogUpdate(props: {
|
||||
restart: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [error, setError] = createSignal<string>()
|
||||
const [active, setActive] = createSignal(0)
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -31,8 +31,8 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
message?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const overlayTheme = useTheme("overlay")
|
||||
const theme = useTheme().surface("raised")
|
||||
const overlayTheme = useTheme().surface("overlay")
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
|
||||
@@ -40,7 +40,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const sessionData = useData()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useConfig } from "../config"
|
||||
|
||||
export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const config = useConfig().data
|
||||
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
||||
|
||||
@@ -10,7 +10,7 @@ type Progress = { label: string; numerator?: number; denominator?: number }
|
||||
export function MigrationOverlay() {
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("overlay")
|
||||
const theme = useTheme().surface("overlay")
|
||||
const [progress, setProgress] = createSignal<Progress>()
|
||||
const abort = new AbortController()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BoxRenderable } from "@opentui/core"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { usePanel, type PanelTarget } from "../context/panel"
|
||||
import { InteractivityProvider } from "../context/interactivity"
|
||||
import { ThemeContextProvider, useTheme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Slot } from "../plugin/render"
|
||||
|
||||
export function PanelHost(props: {
|
||||
@@ -19,6 +19,9 @@ export function PanelHost(props: {
|
||||
|
||||
const Content = () => {
|
||||
const theme = useTheme()
|
||||
// Side panels sit on a raised surface; fullscreen takes over the base background.
|
||||
const background = () =>
|
||||
panels.presentation() === "panel" ? theme.surface("raised").background.default : theme.background.default
|
||||
return (
|
||||
<box
|
||||
id="session-panel"
|
||||
@@ -27,7 +30,7 @@ export function PanelHost(props: {
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
focusable
|
||||
backgroundColor={theme.background.default}
|
||||
backgroundColor={background()}
|
||||
onMouseDown={props.onFocus}
|
||||
>
|
||||
<Slot
|
||||
@@ -55,9 +58,7 @@ export function PanelHost(props: {
|
||||
|
||||
return (
|
||||
<InteractivityProvider enabled={props.focused}>
|
||||
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
|
||||
<Content />
|
||||
</ThemeContextProvider>
|
||||
<Content />
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function Autocomplete(props: {
|
||||
const data = useData()
|
||||
const keymap = Keymap.use()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const theme = useTheme("overlay")
|
||||
const theme = useTheme().surface("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const config = useConfig().data
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function Reconnecting(props: { managed?: boolean }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
|
||||
return (
|
||||
<box
|
||||
|
||||
@@ -10,7 +10,7 @@ export function SessionTabsRailControls(props: {
|
||||
tabs: SessionTabsController
|
||||
belowHighlighted: boolean
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const keymap = Keymap.use()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const hoverColor = createMemo(() =>
|
||||
|
||||
@@ -366,7 +366,7 @@ export function createTabMarquee(animations: () => boolean) {
|
||||
|
||||
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const dialog = useDialog()
|
||||
onCleanup(Keymap.use().mode.push("menu"))
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -515,7 +515,7 @@ function VerticalSessionTabs(props: {
|
||||
const data = props.controller ? undefined : useData()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const base = useTheme()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function StartupLoading(props: { ready: () => boolean }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [show, setShow] = createSignal(false)
|
||||
const text = createMemo(() => (props.ready() ? "Finishing startup…" : "Loading plugins…"))
|
||||
let wait: NodeJS.Timeout | undefined
|
||||
|
||||
@@ -32,7 +32,7 @@ export function TerminalPane(props: {
|
||||
const client = useClient()
|
||||
const keymap = Keymap.use()
|
||||
const leader = Keymap.useLeaderActive()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const themes = useThemes()
|
||||
const renderer = useRenderer()
|
||||
const [failure, setFailure] = createSignal<string>()
|
||||
@@ -155,7 +155,7 @@ export function TerminalPane(props: {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const tokens = themes.currentTokens().contextual.elevated
|
||||
const tokens = themes.currentTokens().surface("raised")
|
||||
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default)
|
||||
applyTerminalTheme()
|
||||
})
|
||||
@@ -284,7 +284,7 @@ export function TerminalPane(props: {
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
overflow="hidden"
|
||||
backgroundColor={themes.currentTokens().contextual.elevated.background.default}
|
||||
backgroundColor={themes.currentTokens().surface("raised").background.default}
|
||||
onSizeChange={function () {
|
||||
size = { cols: Math.max(1, this.width - 2), rows: this.height }
|
||||
if (controller && restored) interact()
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
resolveThemeDocument,
|
||||
themeModes,
|
||||
type ResolvedTheme,
|
||||
type ContextName,
|
||||
} from "@opencode/theme/tui"
|
||||
import {
|
||||
DEFAULT_THEMES,
|
||||
@@ -23,8 +22,8 @@ import {
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
@@ -122,7 +121,6 @@ type Themes = {
|
||||
}
|
||||
|
||||
type ThemeContextValue = {
|
||||
current: ComponentTheme["contextual"][ContextName]
|
||||
themes: Themes
|
||||
readonly ready: boolean
|
||||
}
|
||||
@@ -322,7 +320,7 @@ const themeContext = createSimpleContext({
|
||||
const tokens = () => selected().theme
|
||||
tokens()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const current = createComponentTheme(tokens, mode)
|
||||
const current = createComponentTheme(tokens)
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
|
||||
|
||||
@@ -365,7 +363,6 @@ const themeContext = createSimpleContext({
|
||||
},
|
||||
}
|
||||
return {
|
||||
current,
|
||||
themes: service,
|
||||
get ready() {
|
||||
return service.ready
|
||||
@@ -377,11 +374,8 @@ const themeContext = createSimpleContext({
|
||||
export function useThemes() {
|
||||
return themeContext.use().themes
|
||||
}
|
||||
export function useTheme(): ComponentTheme
|
||||
export function useTheme(context: ContextName): ComponentTheme["contextual"][ContextName]
|
||||
export function useTheme(context?: ContextName) {
|
||||
const value = themeContext.use()
|
||||
return context ? value.themes.current.contextual[context] : value.current
|
||||
export function useTheme(): ComponentTheme {
|
||||
return themeContext.use().themes.current
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
@@ -391,20 +385,6 @@ function usablePalette(colors: TerminalColors | undefined): colors is TerminalCo
|
||||
)
|
||||
}
|
||||
|
||||
/** Switches context without remounting children; undefined inherits the enclosing view. */
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
|
||||
const value = themeContext.use()
|
||||
const current = createComponentThemeView(() => {
|
||||
const name = props.context
|
||||
return name ? value.themes.currentTokens().contextual[name] : value.current
|
||||
}, value.themes.mode)
|
||||
return (
|
||||
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
|
||||
{props.children}
|
||||
</themeContext.context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") {
|
||||
const document = parseTheme(source, name)
|
||||
const modes = themeModes(document)
|
||||
|
||||
@@ -86,8 +86,8 @@ function Answer(props: { question: string; answer: string }) {
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
const plugins = usePlugin()
|
||||
const theme = useTheme("elevated")
|
||||
const overlay = useTheme("overlay")
|
||||
const theme = useTheme().surface("raised")
|
||||
const overlay = useTheme().surface("overlay")
|
||||
const syntax = useThemes().currentSyntax
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
@@ -12,7 +12,7 @@ export function DiffFileMenu(props: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.overlay
|
||||
const theme = props.context.theme.surface("overlay")
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
|
||||
const width = () => Math.min(19, dimensions().width)
|
||||
|
||||
@@ -27,7 +27,7 @@ export type DiffViewerFileTreeProps = {
|
||||
}
|
||||
|
||||
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [sourceHovered, setSourceHovered] = createSignal(false)
|
||||
const list = () => props.layout === "list"
|
||||
const tree = createMemo(() => buildFileTree(props.files))
|
||||
|
||||
@@ -214,7 +214,7 @@ function DiffBaseDialog(props: {
|
||||
current?: string
|
||||
onSelect: (ref: string) => void
|
||||
}) {
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const theme = props.context.theme.surface("raised")
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [branches] = createResource(search, (search) =>
|
||||
props.context.client.vcs.branch.list({ location: props.location, search, limit: 100 }),
|
||||
@@ -1079,7 +1079,7 @@ export function DiffViewerContent(props: {
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const theme = props.context.theme.surface("raised")
|
||||
const shortcut =
|
||||
(...ids: string[]) =>
|
||||
() =>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function StoryFooter(props: {
|
||||
message?: string
|
||||
controls: readonly StoryFooterControl[]
|
||||
}) {
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const theme = props.context.theme.surface("raised")
|
||||
|
||||
return (
|
||||
<box flexShrink={0} flexDirection="column" backgroundColor={theme.background.default}>
|
||||
|
||||
@@ -11,7 +11,7 @@ const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
|
||||
|
||||
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const theme = props.context.theme.surface("raised")
|
||||
const [message, setMessage] = createSignal("Choose another directory to continue")
|
||||
const open = () =>
|
||||
props.context.ui.dialog.show(() => (
|
||||
|
||||
@@ -115,7 +115,7 @@ function map(
|
||||
syntax?: SyntaxStyle,
|
||||
system = false,
|
||||
): RunTheme {
|
||||
const elevated = theme.contextual.elevated
|
||||
const elevated = theme.surface("raised")
|
||||
// V1 system migration serializes colors; restore terminal defaults before quantizing scrollback.
|
||||
const exact = (color: RGBA) => {
|
||||
if (system && color.equals(theme.text.default)) return RGBA.defaultForeground(color)
|
||||
@@ -159,7 +159,7 @@ function map(
|
||||
text: exact(theme.text.default),
|
||||
shade: exact(elevated.background.default),
|
||||
surface: exact(elevated.background.default),
|
||||
pane: exact(theme.contextual.overlay.background.default),
|
||||
pane: exact(theme.surface("overlay").background.default),
|
||||
border: exact(theme.border.default),
|
||||
line: exact(theme.background.raised.high),
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ComposerProps = {
|
||||
}
|
||||
|
||||
export function Composer(props: ComposerProps) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const config = useConfig().data
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal<"code" | "output">()
|
||||
@@ -224,7 +224,7 @@ function GutteredCode(props: {
|
||||
digits: number
|
||||
blocks: Set<CodeRenderable>
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const syntax = useThemes().currentSyntax
|
||||
const gutter = createMemo(() =>
|
||||
props.content
|
||||
|
||||
@@ -59,7 +59,7 @@ const drafts = new Map<string, FormDraft>()
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const data = useData()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const themeMode = themes.mode
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
@@ -1936,7 +1936,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
const config = useConfig()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
ctx
|
||||
@@ -2165,7 +2165,7 @@ function RevertMessage(props: {
|
||||
}>
|
||||
}) {
|
||||
const ctx = use()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const route = useRouteData("session")
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
@@ -2268,7 +2268,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
),
|
||||
)
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const mode = themes.mode
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||
@@ -2384,7 +2384,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
}
|
||||
|
||||
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
|
||||
|
||||
@@ -2744,8 +2744,9 @@ function InlineTool(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge(props: { children: string }) {
|
||||
const theme = useTheme()
|
||||
function StatusBadge(props: { children: string; raised?: boolean }) {
|
||||
const base = useTheme()
|
||||
const theme = props.raised ? base.surface("raised") : base
|
||||
return (
|
||||
<text flexShrink={0} bg={theme.raise(theme.background.default)} fg={theme.text.subdued}>
|
||||
{" "}
|
||||
@@ -2767,16 +2768,8 @@ type BlockToolProps = {
|
||||
}
|
||||
|
||||
function BlockTool(props: BlockToolProps) {
|
||||
const parentTheme = useTheme()
|
||||
return (
|
||||
<ThemeContextProvider context="elevated">
|
||||
<BlockToolContent {...props} borderColor={parentTheme.background.default} />
|
||||
</ThemeContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const base = useTheme()
|
||||
const theme = base.surface("raised")
|
||||
const ctx = use()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
@@ -2794,7 +2787,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
|
||||
gap={1}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={props.borderColor}
|
||||
borderColor={base.background.default}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
@@ -3027,7 +3020,7 @@ function ShellDisplay(props: {
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.background}>
|
||||
<StatusBadge>Background</StatusBadge>
|
||||
<StatusBadge raised>Background</StatusBadge>
|
||||
</Show>
|
||||
</box>
|
||||
</BlockTool>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SessionLocationMissing(props: { directory: string; projectID: st
|
||||
|
||||
export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
|
||||
const paths = useTuiPaths()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72))
|
||||
|
||||
return (
|
||||
|
||||
@@ -277,7 +277,7 @@ function RejectPrompt(props: {
|
||||
}) {
|
||||
let input: TextareaRenderable
|
||||
const enabled = useInteractivity()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
@@ -418,7 +418,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
fullscreen?: boolean
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const config = useConfig().data
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode/theme/tui"
|
||||
import type { ResolvedTheme, SurfaceName } from "@opencode/theme/tui"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
|
||||
return Object.assign(createComponentThemeView(current, mode), {
|
||||
contextual: {
|
||||
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
|
||||
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
|
||||
export function createComponentTheme(
|
||||
view: Accessor<ResolvedTheme>,
|
||||
// Shared across a theme's surface views so `surface()` stays absolute at the wrapper level too.
|
||||
surfaces = new Map<SurfaceName, ComponentTheme>(),
|
||||
): ComponentTheme {
|
||||
return {
|
||||
get hue() {
|
||||
return view().hue
|
||||
@@ -43,8 +38,15 @@ export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mo
|
||||
source: (color: RGBA) => view().source(color),
|
||||
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
|
||||
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
|
||||
raise: (color: RGBA) => view().raise(color),
|
||||
surface(name: SurfaceName) {
|
||||
const cached = surfaces.get(name)
|
||||
if (cached) return cached
|
||||
const created = createComponentTheme(() => view().surface(name), surfaces)
|
||||
surfaces.set(name, created)
|
||||
return created
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ComponentTheme = ReturnType<typeof createComponentTheme>
|
||||
export type ComponentTheme = ResolvedTheme
|
||||
|
||||
@@ -11,7 +11,7 @@ export type DialogAlertProps = {
|
||||
|
||||
export function DialogAlert(props: DialogAlertProps) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
|
||||
@@ -19,7 +19,7 @@ export type DialogConfirmProps = {
|
||||
|
||||
export function DialogConfirm(props: DialogConfirmProps) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const [store, setStore] = createStore({
|
||||
active: "confirm" as "confirm" | "cancel",
|
||||
})
|
||||
|
||||
@@ -23,8 +23,8 @@ type Active = ExportFormat | "thinking" | "tools" | "sanitize" | "copy" | "expor
|
||||
|
||||
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const overlayTheme = useTheme("overlay")
|
||||
const theme = useTheme().surface("raised")
|
||||
const overlayTheme = useTheme().surface("overlay")
|
||||
const [store, setStore] = createStore({
|
||||
format: "markdown" as ExportFormat,
|
||||
thinking: props.defaultThinking,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDialog, type DialogContext } from "./dialog"
|
||||
|
||||
export function DialogExportResult(props: { path: string; onClose?: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
|
||||
const close = () => {
|
||||
props.onClose?.()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDialog } from "./dialog"
|
||||
|
||||
export function DialogHelp() {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
|
||||
@@ -22,7 +22,7 @@ export type DialogPromptProps = {
|
||||
export function DialogPrompt(props: DialogPromptProps) {
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const config = useConfig().data
|
||||
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
|
||||
|
||||
@@ -107,7 +107,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
|
||||
const dialog = useDialog()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const mode = themes.mode
|
||||
const config = useConfig().data
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -843,7 +843,7 @@ function Option(props: {
|
||||
activeColor?: RGBA
|
||||
onMouseOver?: () => void
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
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
|
||||
|
||||
@@ -25,7 +25,7 @@ export function Dialog(
|
||||
}>,
|
||||
) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
const renderer = useRenderer()
|
||||
|
||||
let dismiss = false
|
||||
|
||||
@@ -6,7 +6,7 @@ export function PaneResizeHandle(props: {
|
||||
left: number
|
||||
highlight?: "left" | "right"
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("raised")
|
||||
|
||||
return (
|
||||
<box
|
||||
|
||||
@@ -22,7 +22,7 @@ function ToastSurface(props: {
|
||||
onHover?: (hovered: boolean) => void
|
||||
onActivate: () => void
|
||||
}) {
|
||||
const theme = useTheme("overlay")
|
||||
const theme = useTheme().surface("overlay")
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createSignal } from "solid-js"
|
||||
import { DEFAULT_THEME, selectTheme } from "@opencode/theme/tui"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ThemeContextProvider, ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme"
|
||||
import { ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme"
|
||||
|
||||
async function wait(fn: () => boolean) {
|
||||
const started = Date.now()
|
||||
@@ -121,38 +119,20 @@ test.each([
|
||||
}
|
||||
})
|
||||
|
||||
test("contextual hooks resolve overrides and fall back to a standalone theme's base view", async () => {
|
||||
const standalone = {
|
||||
version: 2,
|
||||
standalone: true,
|
||||
dark: {
|
||||
hue: selectTheme(DEFAULT_THEME, "dark").hue,
|
||||
"@context:elevated": { text: { default: "#abcdef" } },
|
||||
},
|
||||
} as const
|
||||
test("surfaces are code-owned, absolute views of the base theme", async () => {
|
||||
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>
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
themes = useThemes()
|
||||
return (
|
||||
<ThemeContextProvider context="elevated">
|
||||
<ContextProbe />
|
||||
</ThemeContextProvider>
|
||||
)
|
||||
theme = useTheme()
|
||||
return <text>{theme.text.default.toString()}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "standalone", mode: "dark" } })}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ standalone }) }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode: "dark" } })}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
@@ -163,62 +143,15 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
|
||||
|
||||
try {
|
||||
await wait(() => themes?.ready === true)
|
||||
if (!themes) throw new Error("Theme provider is not mounted")
|
||||
if (!theme) throw new Error("Contextual theme is not mounted")
|
||||
if (!explicit) throw new Error("Explicit contextual theme is not mounted")
|
||||
expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
|
||||
expect(theme.text.default).toBe(explicit.text.default)
|
||||
expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default)
|
||||
expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default)
|
||||
if (!themes || !theme) throw new Error("Theme provider is not mounted")
|
||||
const raised = theme.surface("raised")
|
||||
expect(theme.surface("raised")).toBe(raised)
|
||||
expect(raised.surface("raised")).toBe(raised)
|
||||
expect(raised.background.default).toBe(themes.currentTokens().background.raised.base)
|
||||
expect(theme.surface("overlay").background.default).toBe(themes.currentTokens().background.raised.high)
|
||||
expect(raised.text.default).toBe(theme.text.default)
|
||||
expect(raised.raise(raised.background.raised.base)).toBe(themes.currentTokens().hue.neutral[600])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["dark", "light"] as const)(
|
||||
"reactive %s theme contexts change without remounting their contents",
|
||||
async (mode) => {
|
||||
const [context, setContext] = createSignal<"elevated" | undefined>("elevated")
|
||||
const [parent, setParent] = createSignal<"overlay" | undefined>()
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
let mounts = 0
|
||||
function Probe() {
|
||||
mounts++
|
||||
theme = useTheme()
|
||||
themes = useThemes()
|
||||
return <text fg={theme.text.default}>probe</text>
|
||||
}
|
||||
const app = await testRender(() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
|
||||
<ThemeProvider mode={mode} source={{ discover: async () => ({}) }}>
|
||||
<ThemeContextProvider context={parent()}>
|
||||
<ThemeContextProvider context={context()}>
|
||||
<Probe />
|
||||
</ThemeContextProvider>
|
||||
</ThemeContextProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
))
|
||||
app.renderer.start()
|
||||
try {
|
||||
await wait(() => themes?.ready === true)
|
||||
if (!theme || !themes) throw new Error("Theme provider is not mounted")
|
||||
const view = theme
|
||||
expect(view.background.default).toBe(themes.current.contextual.elevated.background.default)
|
||||
setContext(undefined)
|
||||
await app.flush()
|
||||
expect(view.background.default).toBe(themes.current.background.default)
|
||||
setParent("overlay")
|
||||
await app.flush()
|
||||
expect(view.background.default).toBe(themes.current.contextual.overlay.background.default)
|
||||
setContext("elevated")
|
||||
await app.flush()
|
||||
expect(view.text.default).toBe(themes.current.contextual.elevated.text.default)
|
||||
expect(theme).toBe(view)
|
||||
expect(mounts).toBe(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
let theme!: ReturnType<typeof useTheme>
|
||||
function Colors() {
|
||||
config = useConfig()
|
||||
theme = orientation === "vertical" ? useTheme("elevated") : useTheme()
|
||||
theme = orientation === "vertical" ? useTheme().surface("raised") : useTheme()
|
||||
return null
|
||||
}
|
||||
const controller = {
|
||||
|
||||
@@ -83,21 +83,21 @@ function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
|
||||
muted: theme.text.subdued,
|
||||
warning: theme.text.feedback.warning.default,
|
||||
error: theme.text.feedback.error.default,
|
||||
actionSecondaryText: theme.contextual.elevated.text.action.secondary.default,
|
||||
actionFocusedBg: theme.contextual.elevated.background.action.primary.focused,
|
||||
actionFocusedText: theme.contextual.elevated.text.action.primary.focused,
|
||||
formfieldText: theme.contextual.elevated.text.formfield.default,
|
||||
formfieldFocusedBg: theme.contextual.elevated.background.formfield.focused,
|
||||
formfieldFocusedText: theme.contextual.elevated.text.formfield.focused,
|
||||
selection: theme.contextual.elevated.text.formfield.selected,
|
||||
actionSecondaryText: theme.surface("raised").text.action.secondary.default,
|
||||
actionFocusedBg: theme.surface("raised").background.action.primary.focused,
|
||||
actionFocusedText: theme.surface("raised").text.action.primary.focused,
|
||||
formfieldText: theme.surface("raised").text.formfield.default,
|
||||
formfieldFocusedBg: theme.surface("raised").background.formfield.focused,
|
||||
formfieldFocusedText: theme.surface("raised").text.formfield.focused,
|
||||
selection: theme.surface("raised").text.formfield.selected,
|
||||
running: theme.text.status.running,
|
||||
question: theme.text.status.question,
|
||||
permission: theme.text.status.permission,
|
||||
success: theme.text.feedback.success.default,
|
||||
link: theme.markdown.link,
|
||||
shade: theme.contextual.elevated.background.default,
|
||||
surface: theme.contextual.elevated.background.default,
|
||||
pane: theme.contextual.overlay.background.default,
|
||||
shade: theme.surface("raised").background.default,
|
||||
surface: theme.surface("raised").background.default,
|
||||
pane: theme.surface("overlay").background.default,
|
||||
border: theme.border.default,
|
||||
line: theme.background.raised.high,
|
||||
}
|
||||
@@ -195,13 +195,11 @@ test.each(["light", "dark"] as const)(
|
||||
hue: DEFAULT_THEME[mode].hue,
|
||||
text: {
|
||||
default: "#123456",
|
||||
formfield: { default: "#234567", $selected: "#345678" },
|
||||
action: { primary: { $focused: "#56789a" } },
|
||||
formfield: { default: "#234567", $selected: "#345678", $focused: "#6789ab" },
|
||||
feedback: { warning: { default: "#456789" } },
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { $focused: "#56789a" } }, formfield: { $focused: "#6789ab" } },
|
||||
background: { action: { primary: { $focused: "#789abc" } }, formfield: { $focused: "#89abcd" } },
|
||||
},
|
||||
background: { action: { primary: { $focused: "#789abc" } }, formfield: { $focused: "#89abcd" } },
|
||||
},
|
||||
}
|
||||
await Bun.write(path.join(tmp.path, "themes", "mini-custom.json"), JSON.stringify(source))
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode/theme/tui"
|
||||
import { createComponentTheme, createComponentThemeView } from "../../../src/theme/component"
|
||||
import { DEFAULT_THEME, resolveTheme, selectTheme } from "@opencode/theme/tui"
|
||||
import { createComponentTheme } from "../../../src/theme/component"
|
||||
|
||||
test("provides reactive properties, states, contexts, and color operations", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
const [mode, setMode] = createSignal<"light" | "dark">("light")
|
||||
const theme = createComponentTheme(resolved, mode)
|
||||
const [context, setContext] = createSignal<ContextName>()
|
||||
const current = () => {
|
||||
const name = context()
|
||||
return name ? theme.contextual[name] : theme
|
||||
}
|
||||
test("provides reactive properties, states, surfaces, and color operations", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"), "light"))
|
||||
const theme = createComponentTheme(resolved)
|
||||
const current = theme.surface("raised")
|
||||
|
||||
expect(theme.text.default).toBe(resolved().text.default)
|
||||
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
|
||||
@@ -51,35 +46,19 @@ test("provides reactive properties, states, contexts, and color operations", ()
|
||||
expect(theme.scrollbar.default).toBe(resolved().scrollbar.default)
|
||||
expect(theme.diff.text.added).toBe(resolved().diff.text.added)
|
||||
|
||||
setContext("elevated")
|
||||
expect("contexts" in current()).toBeFalse()
|
||||
expect(current().categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(current().text.default).toBe(resolved().contextual.elevated.text.default)
|
||||
expect(current().background.action.primary.focused).toBe(
|
||||
resolved().contextual.elevated.background.action.primary.focused,
|
||||
)
|
||||
expect(current().background.action.primary.hovered).toBe(resolved().background.raised.high)
|
||||
expect(current().background.formfield.selected).toBe(resolved().contextual.elevated.background.formfield.selected)
|
||||
expect(theme.surface("raised")).toBe(current)
|
||||
expect(current.surface("raised")).toBe(current)
|
||||
expect(current.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(current.text.default).toBe(resolved().surface("raised").text.default)
|
||||
expect(current.background.default).toBe(resolved().background.raised.base)
|
||||
expect(current.background.action.primary.focused).toBe(resolved().surface("raised").background.action.primary.focused)
|
||||
expect(current.background.action.primary.hovered).toBe(resolved().background.raised.high)
|
||||
expect(current.background.formfield.selected).toBe(resolved().surface("raised").background.formfield.selected)
|
||||
expect(theme.surface("overlay").background.default).toBe(resolved().background.raised.high)
|
||||
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
setMode("dark")
|
||||
expect(current().text.default).toBe(resolved().contextual.elevated.text.default)
|
||||
expect(current().decrease(current().background.raised.base, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(current().raise(current().background.raised.base)).toBe(resolved().hue.neutral[600])
|
||||
})
|
||||
|
||||
test("a stable component theme view follows presentation context changes", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
const [context, setContext] = createSignal<ContextName>()
|
||||
const theme = createComponentThemeView(
|
||||
() => (context() ? resolved().contextual[context()!] : resolved()),
|
||||
() => "dark",
|
||||
)
|
||||
expect(theme.background.default).toBe(resolved().background.default)
|
||||
setContext("elevated")
|
||||
expect(theme.background.default).toBe(resolved().contextual.elevated.background.default)
|
||||
setContext(undefined)
|
||||
expect(theme.background.default).toBe(resolved().background.default)
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
expect(theme.text.default).toBe(resolved().text.default)
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"), "dark"))
|
||||
expect(current.text.default).toBe(resolved().surface("raised").text.default)
|
||||
expect(current.background.default).toBe(resolved().background.raised.base)
|
||||
expect(current.decrease(current.background.raised.base, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(current.raise(current.background.raised.base)).toBe(resolved().hue.neutral[600])
|
||||
})
|
||||
|
||||
@@ -22,8 +22,8 @@ test("resolves one-mode documents with defaults for the available mode", () => {
|
||||
const resolvedLight = resolveSource({ version: 2, light: {} }, "dark")
|
||||
const resolvedDark = resolveSource({ version: 2, dark: {} }, "light")
|
||||
|
||||
expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
|
||||
expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
|
||||
expect(resolvedLight.background.default.equals(resolveTheme(light, "light").background.default)).toBeTrue()
|
||||
expect(resolvedDark.background.default.equals(resolveTheme(dark, "dark").background.default)).toBeTrue()
|
||||
expect(resolvedLight.categorical.length).toBeGreaterThan(0)
|
||||
expect(resolvedDark.categorical.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -38,7 +38,7 @@ test("validates and resolves categorical hues in configured order", () => {
|
||||
expect(theme.categorical[0]).toBe(theme.hue.accent)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.red)
|
||||
expect(theme.categorical[2]).toBe(theme.hue.interactive)
|
||||
expect(theme.contextual.elevated.categorical).toBe(theme.categorical)
|
||||
expect(theme.surface("raised").categorical).toBe(theme.categorical)
|
||||
expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
|
||||
expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme")
|
||||
})
|
||||
@@ -52,15 +52,15 @@ test("generates syntax with one categorical hue", () => {
|
||||
})
|
||||
|
||||
test("uses the default categorical order for direct definitions", () => {
|
||||
const theme = resolveTheme({ ...light, categorical: undefined })
|
||||
const theme = resolveTheme({ ...light, categorical: undefined }, "light")
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.blue)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.purple)
|
||||
})
|
||||
|
||||
test("resolves independent definitions and hue aliases", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
const lightTheme = resolveTheme(light, "light")
|
||||
const darkTheme = resolveTheme(dark, "dark")
|
||||
|
||||
expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue()
|
||||
@@ -74,30 +74,42 @@ test("resolves independent definitions and hue aliases", () => {
|
||||
expect(lightTheme.source(lightTheme.background.raised.base)).toEqual({ hue: "neutral", step: 300 })
|
||||
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
|
||||
expect(lightTheme.contextual.elevated.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.surface("raised").increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.raise(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(darkTheme.raise(darkTheme.hue.red[200])).toBe(darkTheme.hue.red[100])
|
||||
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
|
||||
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[300])
|
||||
expect(lightTheme.background.raised.high).toBe(lightTheme.hue.neutral[400])
|
||||
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
|
||||
expect(lightTheme.contextual.elevated.background.action.primary.default).toBe(lightTheme.hue.interactive[500])
|
||||
expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.raised.base)
|
||||
expect(lightTheme.contextual.elevated.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(lightTheme.contextual.overlay.background.action.primary.default).toBe(lightTheme.hue.interactive[500])
|
||||
expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.raised.high)
|
||||
expect(lightTheme.contextual.overlay.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(darkTheme.contextual.elevated.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
|
||||
expect(darkTheme.contextual.elevated.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
|
||||
expect(darkTheme.contextual.overlay.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
|
||||
expect(darkTheme.contextual.overlay.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
|
||||
// Surfaces re-resolve the same palette on a raised background, so `$background.default`
|
||||
// references follow it while everything else stays the base value.
|
||||
const raised = lightTheme.surface("raised")
|
||||
expect(raised.background.default).toBe(lightTheme.background.raised.base)
|
||||
expect(raised.background.formfield.default).toBe(lightTheme.background.raised.base)
|
||||
expect(raised.background.feedback.error.default).toBe(lightTheme.background.raised.base)
|
||||
expect(raised.background.action.primary.hovered).toBe(lightTheme.background.raised.high)
|
||||
expect(raised.background.action.primary.default).toBe(lightTheme.background.action.primary.default)
|
||||
expect(raised.background.action.primary.focused).toBe(lightTheme.background.action.primary.focused)
|
||||
expect(raised.text.action.primary.default).toBe(lightTheme.text.action.primary.default)
|
||||
expect(raised.surface("raised")).toBe(raised)
|
||||
expect(raised.surface("overlay")).toBe(lightTheme.surface("overlay"))
|
||||
const overlay = lightTheme.surface("overlay")
|
||||
expect(overlay.background.default).toBe(lightTheme.background.raised.high)
|
||||
expect(overlay.background.action.primary.hovered).toBe(lightTheme.background.action.primary.hovered)
|
||||
expect(darkTheme.surface("raised").background.default).toBe(darkTheme.background.raised.base)
|
||||
expect(darkTheme.surface("overlay").background.default).toBe(darkTheme.background.raised.high)
|
||||
})
|
||||
|
||||
test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||
const aliased = resolveTheme({
|
||||
...light,
|
||||
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
||||
})
|
||||
const aliased = resolveTheme(
|
||||
{
|
||||
...light,
|
||||
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
||||
},
|
||||
"light",
|
||||
)
|
||||
const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
|
||||
|
||||
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
|
||||
@@ -110,23 +122,29 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||
expect(aliased.source(aliased.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(aliased.source(aliased.hue.purple[500])).toEqual({ hue: "purple", step: 500 })
|
||||
expect(() =>
|
||||
resolveTheme({
|
||||
...light,
|
||||
hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" },
|
||||
}),
|
||||
resolveTheme(
|
||||
{
|
||||
...light,
|
||||
hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" },
|
||||
},
|
||||
"light",
|
||||
),
|
||||
).toThrow("Circular hue reference: red -> blue -> red")
|
||||
})
|
||||
|
||||
test("steps by hue source when adjacent colors have equal values", () => {
|
||||
if (typeof light.hue.gray !== "object") throw new Error("Expected a concrete gray scale")
|
||||
const theme = resolveTheme({
|
||||
...light,
|
||||
hue: {
|
||||
...light.hue,
|
||||
gray: { ...light.hue.gray, 200: "#eee8d5", 300: "#eee8d5", 400: "#d3d7c6" },
|
||||
neutral: "$hue.gray",
|
||||
const theme = resolveTheme(
|
||||
{
|
||||
...light,
|
||||
hue: {
|
||||
...light.hue,
|
||||
gray: { ...light.hue.gray, 200: "#eee8d5", 300: "#eee8d5", 400: "#d3d7c6" },
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
},
|
||||
})
|
||||
"light",
|
||||
)
|
||||
|
||||
expect(theme.hue.neutral[200]).not.toBe(theme.hue.neutral[300])
|
||||
expect(theme.hue.neutral[200].equals(theme.hue.neutral[300])).toBeTrue()
|
||||
@@ -198,7 +216,7 @@ test("expands user structural fallbacks before merging defaults", () => {
|
||||
expect(expanded.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(isolatedState.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
|
||||
expect(isolatedState.background.action.primary.focused.toInts()).toEqual(
|
||||
resolveTheme(light).background.action.primary.focused.toInts(),
|
||||
resolveTheme(light, "light").background.action.primary.focused.toInts(),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -226,7 +244,7 @@ test("uses defaults for the selected mode when it merges the other mode", () =>
|
||||
})
|
||||
|
||||
test("resolves matched action variants and states", () => {
|
||||
const theme = resolveTheme(light)
|
||||
const theme = resolveTheme(light, "light")
|
||||
|
||||
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
|
||||
expect(theme.text.action.primary.hovered).toBeInstanceOf(RGBA)
|
||||
@@ -250,8 +268,9 @@ test("resolves elevated hover surfaces from direct colors", () => {
|
||||
"light",
|
||||
)
|
||||
|
||||
expect(theme.contextual.elevated.background.default.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(theme.contextual.elevated.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
|
||||
expect(theme.surface("raised").background.default.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(theme.surface("raised").background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
|
||||
expect(theme.surface("overlay").background.default.toInts()).toEqual([35, 69, 103, 255])
|
||||
})
|
||||
|
||||
test("resolves transparent colors", () => {
|
||||
@@ -277,31 +296,20 @@ test("reports theme decoding failures as native errors", () => {
|
||||
).toThrow('Invalid theme: custom "opaque" is an invalid value')
|
||||
})
|
||||
|
||||
test("context overrides rewire semantic references and apply state precedence", () => {
|
||||
const definition = override(light, {
|
||||
text: {
|
||||
default: "#111111",
|
||||
action: {
|
||||
primary: { default: "$text.default", $pressed: "#222222" },
|
||||
},
|
||||
test("theme files cannot define surfaces; unknown context keys are ignored", () => {
|
||||
const theme = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: { text: { default: "#111111" }, "@context:elevated": { text: { default: "#333333" } } },
|
||||
dark: {},
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: {
|
||||
default: "#333333",
|
||||
action: { primary: { default: "#444444", $focused: "#555555" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
const theme = resolveTheme(definition)
|
||||
const overlay = theme.contextual.elevated
|
||||
|
||||
expect(overlay.text.default.toInts()).toEqual([51, 51, 51, 255])
|
||||
expect(overlay.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
|
||||
expect(overlay.text.action.primary.focused.toInts()).toEqual([85, 85, 85, 255])
|
||||
"light",
|
||||
)
|
||||
expect(theme.surface("raised").text.default.toInts()).toEqual([17, 17, 17, 255])
|
||||
})
|
||||
|
||||
test("rejects missing, base, and contextual reference cycles", () => {
|
||||
expect(() => resolveTheme(override(light, { text: { default: "$missing" } }))).toThrow(
|
||||
test("rejects missing and circular references", () => {
|
||||
expect(() => resolveTheme(override(light, { text: { default: "$missing" } }), "light")).toThrow(
|
||||
'Theme reference "$missing" was not found',
|
||||
)
|
||||
expect(() =>
|
||||
@@ -309,29 +317,29 @@ test("rejects missing, base, and contextual reference cycles", () => {
|
||||
override(light, {
|
||||
text: { default: "$text.subdued", subdued: "$text.default" },
|
||||
}),
|
||||
),
|
||||
).toThrow("Circular theme reference")
|
||||
expect(() =>
|
||||
resolveTheme(
|
||||
override(light, {
|
||||
"@context:elevated": { text: { default: "$text.default" } },
|
||||
}),
|
||||
"light",
|
||||
),
|
||||
).toThrow("Circular theme reference")
|
||||
})
|
||||
|
||||
test("validates complete hues, resolved groups, and hue-only syntax", () => {
|
||||
expect(() =>
|
||||
resolveTheme({
|
||||
...light,
|
||||
hue: { ...light.hue, accent: "$hue.missing" },
|
||||
} as unknown as ThemeDefinition),
|
||||
resolveTheme(
|
||||
{
|
||||
...light,
|
||||
hue: { ...light.hue, accent: "$hue.missing" },
|
||||
} as unknown as ThemeDefinition,
|
||||
"light",
|
||||
),
|
||||
).toThrow("$hue.missing")
|
||||
expect(() =>
|
||||
resolveTheme({
|
||||
...light,
|
||||
syntax: { ...light.syntax, keyword: "$text.default" },
|
||||
} as unknown as ThemeDefinition),
|
||||
resolveTheme(
|
||||
{
|
||||
...light,
|
||||
syntax: { ...light.syntax, keyword: "$text.default" },
|
||||
} as unknown as ThemeDefinition,
|
||||
"light",
|
||||
),
|
||||
).toThrow("$text.default")
|
||||
})
|
||||
|
||||
|
||||
@@ -43,11 +43,6 @@ const definition = {
|
||||
text,
|
||||
background,
|
||||
border: { default: "$hue.neutral.300" },
|
||||
"@context:elevated": {
|
||||
text: { default: "$hue.neutral.800" },
|
||||
background: { default: "$hue.neutral.200" },
|
||||
},
|
||||
"@context:overlay": { background: { default: "$hue.neutral.300" } },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
export const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument
|
||||
|
||||
@@ -47,11 +47,11 @@ test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
expect(resolved.text.action.secondary.default.toInts()).toEqual(legacy.textMuted.toInts())
|
||||
expect(resolved.text.action.secondary.hovered.toInts()).toEqual(legacy.text.toInts())
|
||||
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
|
||||
expect(resolved.contextual.elevated.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
||||
expect(resolved.contextual.elevated.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
expect(resolved.contextual.elevated.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
|
||||
expect(resolved.contextual.overlay.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
|
||||
expect(resolved.contextual.overlay.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
expect(resolved.surface("raised").background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
||||
expect(resolved.surface("raised").background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
expect(resolved.surface("raised").text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
|
||||
expect(resolved.surface("overlay").background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
|
||||
expect(resolved.surface("overlay").background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
})
|
||||
|
||||
test("references generated hues from matching token colors", () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ for (const mode of ["dark", "light"] as const) {
|
||||
let theme!: ReturnType<typeof useTheme>
|
||||
let parent!: BoxRenderable
|
||||
function Pane() {
|
||||
theme = useTheme("elevated")
|
||||
theme = useTheme().surface("raised")
|
||||
resize = createPaneResize({
|
||||
value,
|
||||
defaultValue: () => 16,
|
||||
|
||||
@@ -246,24 +246,25 @@ Syntax keys are `comment`, `keyword`, `function`, `variable`, `string`, `number`
|
||||
`punctuation`. Markdown keys are `text`, `heading`, `link`, `linkText`, `code`, `blockQuote`, `emphasis`, `strong`,
|
||||
`horizontalRule`, `listItem`, `listEnumeration`, `image`, `imageText`, and `codeBlock`.
|
||||
|
||||
## Contexts
|
||||
## Raised surfaces
|
||||
|
||||
Use context overrides for elevated panels and overlays while keeping the base theme elsewhere:
|
||||
Dialogs, panels, menus, and toasts sit on raised surfaces. Set their backgrounds with `background.raised`; the TUI
|
||||
re-resolves the rest of the palette on top of them, so tokens that reference `$background.default` follow the surface:
|
||||
|
||||
```json title="themes/ocean.json"
|
||||
{
|
||||
"version": 2,
|
||||
"dark": {
|
||||
"background": {
|
||||
"default": "#071521"
|
||||
},
|
||||
"@context:overlay": {
|
||||
"background": {
|
||||
"default": "#102a3c"
|
||||
"default": "#071521",
|
||||
"raised": {
|
||||
"base": "#0c1f2e",
|
||||
"high": "#102a3c",
|
||||
"max": "#16364b"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The available keys are `@context:elevated` and `@context:overlay`. Each accepts the same token groups as the base mode.
|
||||
`base` is used for panels and dialogs, `high` for menus and toasts that float above them. `max` is reserved.
|
||||
|
||||
Reference in New Issue
Block a user