feat(tui): add V2 theme system (#36950)

This commit is contained in:
James Long 2026-07-14 20:28:45 -04:00 committed by GitHub
commit 9a25673c37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1838 additions and 0 deletions

View file

@ -0,0 +1,106 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { ActionState, ActionVariant, ResolvedActionState, ResolvedThemeView } from "./index"
export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
const textAction = actions((variant, state) => current().color.text.action[variant][state])
const backgroundAction = actions((variant, state) => current().color.background.action[variant][state])
const text = Object.assign(() => current().color.text.default, {
subdued: () => current().color.text.subdued,
action: textAction,
feedback: {
error: feedbackText("error"),
warning: feedbackText("warning"),
success: feedbackText("success"),
info: feedbackText("info"),
},
})
const background = Object.assign(() => current().color.background.default, {
action: backgroundAction,
feedback: {
error: () => current().color.background.feedback.error.default,
warning: () => current().color.background.feedback.warning.default,
success: () => current().color.background.feedback.success.default,
info: () => current().color.background.feedback.info.default,
},
})
const markdown = Object.assign(() => current().color.markdown.text, {
heading: () => current().color.markdown.heading,
link: () => current().color.markdown.link,
linkText: () => current().color.markdown.linkText,
code: () => current().color.markdown.code,
blockQuote: () => current().color.markdown.blockQuote,
emphasis: () => current().color.markdown.emphasis,
strong: () => current().color.markdown.strong,
horizontalRule: () => current().color.markdown.horizontalRule,
listItem: () => current().color.markdown.listItem,
listEnumeration: () => current().color.markdown.listEnumeration,
image: () => current().color.markdown.image,
imageText: () => current().color.markdown.imageText,
codeBlock: () => current().color.markdown.codeBlock,
})
function feedbackText(kind: "error" | "warning" | "success" | "info") {
return Object.assign(() => current().color.text.feedback[kind].default, {
subdued: () => current().color.text.feedback[kind].subdued,
})
}
return {
hue: () => current().hue,
color: {
text,
background,
border: () => current().color.border.default,
scrollbar: () => current().color.scrollbar.default,
diff: {
text: {
added: () => current().color.diff.text.added,
removed: () => current().color.diff.text.removed,
context: () => current().color.diff.text.context,
hunkHeader: () => current().color.diff.text.hunkHeader,
},
background: {
added: () => current().color.diff.background.added,
removed: () => current().color.diff.background.removed,
context: () => current().color.diff.background.context,
},
highlight: {
added: () => current().color.diff.highlight.added,
removed: () => current().color.diff.highlight.removed,
},
lineNumber: {
text: () => current().color.diff.lineNumber.text,
background: {
added: () => current().color.diff.lineNumber.background.added,
removed: () => current().color.diff.lineNumber.background.removed,
},
},
},
syntax: {
comment: () => current().color.syntax.comment,
keyword: () => current().color.syntax.keyword,
function: () => current().color.syntax.function,
variable: () => current().color.syntax.variable,
string: () => current().color.syntax.string,
number: () => current().color.syntax.number,
type: () => current().color.syntax.type,
operator: () => current().color.syntax.operator,
punctuation: () => current().color.syntax.punctuation,
},
markdown,
},
}
}
function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) {
const action = (variant: ActionVariant) => (state: ActionState | "default" = "default") => get(variant, state)
const primary = action("primary")
return Object.assign(primary, {
primary,
secondary: action("secondary"),
destructive: action("destructive"),
})
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>

View file

@ -0,0 +1,391 @@
import type { ThemeFile } from "./index"
export const DEFAULT_THEME = {
version: 2,
light: {
hue: {
gray: {
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
red: {
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
orange: {
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
},
yellow: {
100: "#fef9c3",
200: "#fef08a",
300: "#fde047",
400: "#facc15",
500: "#eab308",
600: "#ca8a04",
700: "#a16207",
800: "#854d0e",
900: "#713f12",
},
green: {
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
cyan: {
100: "#cffafe",
200: "#a5f3fc",
300: "#67e8f9",
400: "#22d3ee",
500: "#06b6d4",
600: "#0891b2",
700: "#0e7490",
800: "#155e75",
900: "#164e63",
},
blue: {
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
purple: {
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
accent: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
},
},
background: {
default: "$hue.neutral.100",
action: {
primary: {
default: "$hue.accent.600", $hovered: "$hue.accent.700", $pressed: "$hue.accent.800",
$selected: "$hue.accent.700", $disabled: "$hue.neutral.300",
},
secondary: {
default: "$hue.neutral.200", $hovered: "$hue.neutral.300", $pressed: "$hue.neutral.400",
$selected: "$hue.neutral.300", $disabled: "$hue.neutral.200",
},
destructive: {
default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
$selected: "$hue.red.700", $disabled: "$hue.neutral.300",
},
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
},
},
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
},
syntax: {
comment: "$hue.neutral.600",
keyword: "$hue.purple.600",
function: "$hue.accent.600",
variable: "$hue.neutral.900",
string: "$hue.green.700",
number: "$hue.yellow.800",
type: "$hue.yellow.500",
operator: "$hue.cyan.600",
punctuation: "$hue.neutral.900",
},
markdown: {
text: "$hue.neutral.900",
heading: "$hue.purple.600",
link: "$hue.accent.600",
linkText: "$hue.cyan.600",
code: "$hue.green.700",
blockQuote: "$hue.neutral.600",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.900",
horizontalRule: "$hue.neutral.300",
listItem: "$hue.accent.600",
listEnumeration: "$hue.cyan.600",
image: "$hue.accent.600",
imageText: "$hue.cyan.600",
codeBlock: "$hue.neutral.900",
},
},
"@context:elevated": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.200",
action: { primary: { default: "$hue.accent.500" } },
},
},
},
"@context:overlay": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.300",
action: { primary: { default: "$hue.accent.500" } },
},
},
},
},
dark: {
hue: {
gray: {
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
red: {
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
orange: {
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#f97316",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
},
yellow: {
100: "#fef9c3",
200: "#fef08a",
300: "#fde047",
400: "#facc15",
500: "#eab308",
600: "#ca8a04",
700: "#a16207",
800: "#854d0e",
900: "#713f12",
},
green: {
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
cyan: {
100: "#cffafe",
200: "#a5f3fc",
300: "#67e8f9",
400: "#22d3ee",
500: "#06b6d4",
600: "#0891b2",
700: "#0e7490",
800: "#155e75",
900: "#164e63",
},
blue: {
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
purple: {
100: "#f3e8ff",
200: "#e9d5ff",
300: "#d8b4fe",
400: "#c084fc",
500: "#a855f7",
600: "#9333ea",
700: "#7e22ce",
800: "#6b21a8",
900: "#581c87",
},
accent: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
},
},
background: {
default: "$hue.neutral.900",
action: {
primary: {
default: "$hue.accent.500", $hovered: "$hue.accent.600", $pressed: "$hue.accent.800",
$selected: "$hue.accent.600", $disabled: "$hue.neutral.800",
},
secondary: {
default: "$hue.neutral.800", $hovered: "$hue.neutral.700", $pressed: "$hue.neutral.900",
$selected: "$hue.neutral.700", $disabled: "$hue.neutral.900",
},
destructive: {
default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
$selected: "$hue.red.700", $disabled: "$hue.neutral.800",
},
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
},
},
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
},
syntax: {
comment: "$hue.neutral.400",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
variable: "$hue.neutral.100",
string: "$hue.green.300",
number: "$hue.yellow.200",
type: "$hue.yellow.500",
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
},
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.400",
link: "$hue.accent.400",
linkText: "$hue.cyan.400",
code: "$hue.green.300",
blockQuote: "$hue.neutral.400",
emphasis: "$hue.yellow.500",
strong: "$hue.neutral.100",
horizontalRule: "$hue.neutral.700",
listItem: "$hue.accent.400",
listEnumeration: "$hue.cyan.400",
image: "$hue.accent.400",
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
},
"@context:elevated": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.800",
action: { primary: { default: "$hue.accent.400" } },
},
},
},
"@context:overlay": {
color: {
text: { action: { primary: { default: "$hue.neutral.900" } } },
background: {
default: "$hue.neutral.700",
action: { primary: { default: "$hue.accent.400" } },
},
},
},
},
} satisfies ThemeFile

View file

@ -0,0 +1,97 @@
import type {
BackgroundDefinition,
ModeDefinition,
StatefulColorDefinition,
TextDefinition,
ThemeTokensDefinition,
} from "./index"
import { ActionState } from "./schema"
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)]),
),
}
}
export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
if (!definition.color) return { ...definition }
return {
...definition,
color: {
...definition.color,
text: expandText(definition.color.text),
background: expandBackground(definition.color.background),
},
}
}
export function mergeTheme(...values: unknown[]): Record<string, unknown> {
return values.reduce<Record<string, unknown>>((result, value) => {
if (!isRecord(value)) return result
return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
if (item === undefined || key === "mergeMode") return next
return {
...next,
[key]: isRecord(item) ? mergeTheme(next[key], item) : item,
}
}, result)
}, {})
}
function expandText(definition: TextDefinition | undefined): TextDefinition | undefined {
if (!definition) return
return {
...definition,
subdued: definition.subdued ?? (definition.default ? "$color.text.default" : undefined),
action: expandActions(definition.action, "color.text.action"),
feedback: definition.feedback
? Object.fromEntries(
Object.entries(definition.feedback).map(([kind, feedback]) => {
return [
kind,
{
...feedback,
subdued: feedback.subdued ?? (feedback.default ? `$color.text.feedback.${kind}.default` : undefined),
},
]
}),
)
: undefined,
}
}
function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined {
if (!definition) return
return { ...definition, action: expandActions(definition.action, "color.background.action") }
}
function expandActions<Definition extends Partial<Record<string, StatefulColorDefinition>>>(
definition: Definition | undefined,
path: string,
) {
if (!definition) return
return Object.fromEntries(
Object.entries(definition).map(([variant, value]) => {
if (!value?.default) return [variant, value]
return [
variant,
{
...value,
...Object.fromEntries(
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]),
),
},
]
}),
) as Definition
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View file

@ -0,0 +1,56 @@
import type { ThemeTokensDefinition } from "./index"
import { ActionVariant, FeedbackKind } from "./schema"
export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
color: {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
comment: red,
keyword: red,
function: red,
variable: red,
string: red,
number: red,
type: red,
operator: red,
punctuation: red,
},
markdown: {
text: red,
heading: red,
link: red,
linkText: red,
code: red,
blockQuote: red,
emphasis: red,
strong: red,
horizontalRule: red,
listItem: red,
listEnumeration: red,
image: red,
imageText: red,
codeBlock: red,
},
},
}
}

View file

@ -0,0 +1,30 @@
export {
ActionState,
type ActionStateKey,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
MarkdownDefinition,
MarkdownToken,
ModeDefinition,
SyntaxDefinition,
SyntaxToken,
ThemeDefinition,
ThemeFile,
type BackgroundDefinition,
type DiffDefinition,
type FileThemeDefinition,
type HueDefinition,
type HueOverrideDefinition,
type MergeModeDefinition,
type Mode,
type StatefulColorDefinition,
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema"
export type { Hue, HueScale, ResolvedActionState, ResolvedTheme, ResolvedThemeView, StatefulColor } from "./types"
export { migrateV1 } from "./v1-migrate"

View file

@ -0,0 +1,207 @@
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_THEME } from "./defaults"
import { expandTheme, expandTokens, mergeTheme } from "./expand"
import { fallback } from "./fallback"
import {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
ThemeDefinition,
ThemeFile,
} from "./schema"
import type {
ActionStateKey,
HueDefinition,
HueScale,
ResolvedActionState,
ResolvedTheme,
ResolvedThemeView,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index"
import { selectTheme, selectThemeMode } from "./select"
const decodeThemeDefinition = Schema.decodeUnknownSync(ThemeDefinition)
const decodeThemeFile = Schema.decodeUnknownSync(ThemeFile)
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark") {
const decoded = decodeThemeFile(file)
const selected = selectThemeMode(decoded, mode)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
const core = expandTokens(fallback())
const merged = decoded.standalone
? mergeTheme(core, definition)
: mergeTheme(core, defaults, definition)
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
return resolveExpandedTheme(merged as ThemeDefinition)
}
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
}
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
const hue = resolveHue(definition.hue)
const base = tokens(definition)
const resolved = resolveView(base, hue)
const contexts = Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, override]) => {
const contextual = contextualize(base, override as ThemeTokensDefinition)
return [key, resolveView(contextual, hue)]
}),
)
return { ...resolved, contexts } as ResolvedTheme
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
return {
color: definition.color,
}
}
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
const result = mergeTheme(base, override)
const baseText = base.color?.text?.action
const contextText = override.color?.text?.action
const baseBackground = base.color?.background?.action
const contextBackground = override.color?.background?.action
const color = result["color"] as NonNullable<ThemeTokensDefinition["color"]>
return {
...result,
color: {
...color,
text: { ...color.text, action: contextualActions(baseText, contextText) },
background: { ...color.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: ResolvedThemeView["hue"]): ResolvedThemeView {
const source: Record<string, unknown> = { hue, ...definition }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue }
}
function resolveHue(definition: HueDefinition) {
const source = definition as Record<string, unknown>
const cache = new Map<string, HueScale>()
const expected = new Set<string>([...BaseHue.literals, ...HueAlias.literals])
for (const name of Object.keys(source)) {
if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`)
}
function resolve(name: string, stack: string[]): HueScale {
const hit = cache.get(name)
if (hit) return hit
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
const value = source[name]
if (typeof value === "string") {
if ((BaseHue.literals as readonly string[]).includes(name)) throw new Error(`Base hue "${name}" must be a scale`)
const match = /^\$hue\.([^.]+)$/.exec(value)
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
const result = resolve(match[1], [...stack, name])
cache.set(name, result)
return result
}
if (!isRecord(value)) throw new Error(`Hue "${name}" was not found`)
const result = Object.fromEntries(
HueStep.literals.map((step) => {
const color = value[step]
if (typeof color !== "string" || !isHex(color)) throw new Error(`Invalid hue color at "hue.${name}.${step}"`)
return [step, RGBA.fromHex(color)]
}),
) as HueScale
for (const step of Object.keys(value)) {
if (!HueStep.literals.includes(Number(step) as HueStep)) throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
}
cache.set(name, result)
return result
}
return Object.fromEntries(
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
) as ResolvedThemeView["hue"]
}
function createResolver(source: Record<string, unknown>) {
const cache = new Map<string, RGBA>()
function resolve(value: unknown, path: string, stack: string[] = []): unknown {
if (value instanceof RGBA) return value
if (typeof value === "string") return resolveColor(value, path, stack)
if (typeof value === "number") return value
if (!isRecord(value)) throw new Error(`Invalid theme value at "${path}"`)
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [resolvedKey(key), resolve(item, `${path}.${key}`, stack)]),
)
}
function resolveColor(value: string, path: string, stack: string[]) {
if (isHex(value)) return RGBA.fromHex(value)
if (!value.startsWith("$")) throw new Error(`Invalid color "${value}" at "${path}"`)
const target = value.slice(1)
const hit = cache.get(target)
if (hit) return hit
if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`)
const result = resolve(read(source, target), target, [...stack, target])
if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`)
cache.set(target, result)
return result
}
return (value: unknown, path: string) => resolve(value, path)
}
function resolvedKey(key: string) {
if (!key.startsWith("$")) return key
const state = key.slice(1)
return (ActionState.literals as readonly string[]).includes(state) ? state : key
}
function read(source: Record<string, unknown>, path: string) {
const result = path.split(".").reduce<unknown>((value, key) => (isRecord(value) ? value[key] : undefined), source)
if (result === undefined) throw new Error(`Theme reference "$${path}" was not found`)
return result
}
function isHex(value: string) {
return /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof RGBA)
}

View file

@ -0,0 +1,214 @@
import { Schema } from "effect"
export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900])
export type HueStep = Schema.Schema.Type<typeof HueStep>
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
export const HueAlias = Schema.Literals(["accent", "neutral"])
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
export const ActionState = Schema.Literals(["hovered", "pressed", "selected", "focused", "disabled"])
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"])
export type FeedbackKind = Schema.Schema.Type<typeof FeedbackKind>
const Mode = Schema.Literals(["light", "dark"])
export type Mode = Schema.Schema.Type<typeof Mode>
const HexColor = Schema.String.check(Schema.isPattern(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i))
const ColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$", Schema.NonEmptyString])])
const HueName = Schema.Union([BaseHue, HueAlias])
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 HueAliasDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueDefinition = Schema.Struct({
gray: HueScaleDefinition,
red: HueScaleDefinition,
orange: HueScaleDefinition,
yellow: HueScaleDefinition,
green: HueScaleDefinition,
cyan: HueScaleDefinition,
blue: HueScaleDefinition,
purple: HueScaleDefinition,
accent: HueAliasDefinition,
neutral: HueAliasDefinition,
})
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueOverrideDefinition = Schema.Struct({
gray: Schema.optional(HueScaleDefinition),
red: Schema.optional(HueScaleDefinition),
orange: Schema.optional(HueScaleDefinition),
yellow: Schema.optional(HueScaleDefinition),
green: Schema.optional(HueScaleDefinition),
cyan: Schema.optional(HueScaleDefinition),
blue: Schema.optional(HueScaleDefinition),
purple: Schema.optional(HueScaleDefinition),
accent: Schema.optional(HueAliasDefinition),
neutral: Schema.optional(HueAliasDefinition),
})
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$hovered: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
})
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
const ActionColorDefinition = Schema.Struct({
primary: Schema.optional(StatefulColorDefinition),
secondary: Schema.optional(StatefulColorDefinition),
destructive: Schema.optional(StatefulColorDefinition),
})
const TextFeedbackDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
subdued: Schema.optional(ColorValue),
})
const BackgroundFeedbackDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
})
const TextDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
subdued: Schema.optional(ColorValue),
action: Schema.optional(ActionColorDefinition),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(TextFeedbackDefinition),
warning: Schema.optional(TextFeedbackDefinition),
success: Schema.optional(TextFeedbackDefinition),
info: Schema.optional(TextFeedbackDefinition),
}),
),
})
export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
const BackgroundDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
action: Schema.optional(ActionColorDefinition),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(BackgroundFeedbackDefinition),
warning: Schema.optional(BackgroundFeedbackDefinition),
success: Schema.optional(BackgroundFeedbackDefinition),
info: Schema.optional(BackgroundFeedbackDefinition),
}),
),
})
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
export const SyntaxToken = Schema.Literals([
"comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation",
])
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
export const MarkdownToken = Schema.Literals([
"text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem",
"listEnumeration", "image", "imageText", "codeBlock",
])
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
export type MarkdownDefinition = Schema.Schema.Type<typeof MarkdownDefinition>
const DiffDefinition = Schema.Struct({
text: Schema.optional(
Schema.Struct({
added: Schema.optional(ColorValue),
removed: Schema.optional(ColorValue),
context: Schema.optional(ColorValue),
hunkHeader: Schema.optional(ColorValue),
}),
),
background: Schema.optional(
Schema.Struct({
added: Schema.optional(ColorValue),
removed: Schema.optional(ColorValue),
context: Schema.optional(ColorValue),
}),
),
highlight: Schema.optional(
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
),
lineNumber: Schema.optional(
Schema.Struct({
text: Schema.optional(ColorValue),
background: Schema.optional(
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
),
}),
),
})
export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
const ThemeTokensDefinition = Schema.Struct({
color: Schema.optional(
Schema.Struct({
text: Schema.optional(TextDefinition),
background: Schema.optional(BackgroundDefinition),
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
diff: Schema.optional(DiffDefinition),
syntax: Schema.optional(SyntaxDefinition),
markdown: Schema.optional(MarkdownDefinition),
}),
),
})
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
const ThemeDefinitionFields = Schema.Struct({
hue: HueDefinition,
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export const ThemeDefinition = ThemeDefinitionFields
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
const FileThemeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
const MergeModeDefinition = Schema.Struct({
mergeMode: Schema.Literal(true),
hue: Schema.optional(HueOverrideDefinition),
...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([FileThemeDefinition, MergeModeDefinition])
export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
const FileMetadata = {
$schema: Schema.optional(Schema.String),
version: Schema.Literal(2),
standalone: Schema.optional(Schema.Boolean),
}
export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition })
export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>

View file

@ -0,0 +1,37 @@
import { expandTheme, mergeTheme } from "./expand"
import type {
FileThemeDefinition,
MergeModeDefinition,
Mode,
ModeDefinition,
ThemeDefinition,
ThemeFile,
} from "./index"
export function selectTheme(
file: Omit<ThemeFile, "light" | "dark"> & { light: ThemeDefinition; dark: ThemeDefinition },
mode?: Mode,
): ThemeDefinition
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
export function selectTheme(file: ThemeFile, mode?: Mode) {
return selectThemeMode(file, mode).theme
}
export function selectThemeMode(
file: ThemeFile,
mode: Mode = "light",
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
const selected = file[mode]
if (!merges(selected)) return { theme: selected, mode, expanded: false }
const otherMode = mode === "light" ? "dark" : "light"
const other = file[otherMode]
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`)
return { theme: merged as FileThemeDefinition, mode, expanded: true }
}
function merges(definition: ModeDefinition): definition is MergeModeDefinition {
return "mergeMode" in definition && definition.mergeMode === true
}

View file

@ -0,0 +1,50 @@
import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js"
import { createComponentTheme, type ComponentTheme } from "./component"
import type { ContextKey, ResolvedTheme } from "./index"
type ThemeRuntime = {
readonly resolved: Accessor<ResolvedTheme>
readonly component: ComponentTheme
}
const ThemeContext = createContext<ThemeRuntime>()
export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme }>) {
const resolved = () => props.theme
return createComponent(ThemeContext.Provider, {
value: { resolved, component: createComponentTheme(resolved) },
get children() {
return props.children
},
})
}
export function ContextProvider(props: ParentProps<{ context: ContextKey }>) {
const parent = runtime()
const context = () => {
const value = parent.resolved().contexts[props.context]
if (!value) throw new Error(`Theme context is not defined: ${props.context}`)
return value
}
context()
return createComponent(ThemeContext.Provider, {
value: { resolved: parent.resolved, component: createComponentTheme(context) },
get children() {
return props.children
},
})
}
export function useTheme() {
return runtime().component
}
export function useResolvedTheme() {
return runtime().resolved
}
function runtime() {
const context = useContext(ThemeContext)
if (!context) throw new Error("Theme context must be used within a ThemeProvider")
return context
}

View file

@ -0,0 +1,56 @@
import type { RGBA } from "@opentui/core"
import type {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
MarkdownToken,
ContextKey,
SyntaxToken,
} from "./schema"
export type ResolvedActionState = "default" | ActionState
export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type ResolvedThemeView = {
readonly hue: Hue
readonly color: {
readonly text: {
readonly default: RGBA
readonly subdued: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
}
readonly background: {
readonly default: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
}
readonly border: { readonly default: RGBA }
readonly scrollbar: { readonly default: RGBA }
readonly diff: {
readonly text: {
readonly added: RGBA
readonly removed: RGBA
readonly context: RGBA
readonly hunkHeader: RGBA
}
readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
readonly lineNumber: {
readonly text: RGBA
readonly background: { readonly added: RGBA; readonly removed: RGBA }
}
}
readonly syntax: Readonly<Record<SyntaxToken, RGBA>>
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
}
export type ResolvedTheme = ResolvedThemeView & {
readonly contexts: Readonly<Partial<Record<ContextKey, ResolvedThemeView>>>
}

View file

@ -0,0 +1,213 @@
import { RGBA } from "@opentui/core"
import type { Theme, ThemeJson } from "../index"
import type { ThemeFile } from "./index"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export function migrateV1(theme: ThemeJson): ThemeFile {
return {
version: 2,
light: migrateMode(resolveV1(theme, "light")),
dark: migrateMode(resolveV1(theme, "dark")),
}
}
function migrateMode(theme: Theme): ThemeFile["light"] {
const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
return {
hue: { accent: accentScale(theme.accent) },
color: {
text: {
default: color("text"),
subdued: color("textMuted"),
action: {
primary: { default: selected },
secondary: {
default: color("text"),
$hovered: color("text"),
$pressed: color("text"),
$selected: color("text"),
$focused: color("text"),
$disabled: color("textMuted"),
},
destructive: { default: destructive },
},
feedback: {
error: { default: color("error") },
warning: { default: color("warning") },
success: { default: color("success") },
info: { default: color("info") },
},
},
background: {
default: color("background"),
action: {
primary: { default: color("primary") },
secondary: {
default: color("backgroundMenu"),
$hovered: color("backgroundElement"),
$pressed: color("backgroundElement"),
$selected: color("backgroundMenu"),
$focused: color("backgroundElement"),
$disabled: color("backgroundMenu"),
},
destructive: { default: color("error") },
},
},
border: { default: color("border") },
scrollbar: { default: color("borderActive") },
diff: {
text: {
added: color("diffAdded"),
removed: color("diffRemoved"),
context: color("diffContext"),
hunkHeader: color("diffHunkHeader"),
},
background: {
added: color("diffAddedBg"),
removed: color("diffRemovedBg"),
context: color("diffContextBg"),
},
highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") },
lineNumber: {
text: color("diffLineNumber"),
background: {
added: color("diffAddedLineNumberBg"),
removed: color("diffRemovedLineNumberBg"),
},
},
},
syntax: {
comment: color("syntaxComment"),
keyword: color("syntaxKeyword"),
function: color("syntaxFunction"),
variable: color("syntaxVariable"),
string: color("syntaxString"),
number: color("syntaxNumber"),
type: color("syntaxType"),
operator: color("syntaxOperator"),
punctuation: color("syntaxPunctuation"),
},
markdown: {
text: color("markdownText"),
heading: color("markdownHeading"),
link: color("markdownLink"),
linkText: color("markdownLinkText"),
code: color("markdownCode"),
blockQuote: color("markdownBlockQuote"),
emphasis: color("markdownEmph"),
strong: color("markdownStrong"),
horizontalRule: color("markdownHorizontalRule"),
listItem: color("markdownListItem"),
listEnumeration: color("markdownListEnumeration"),
image: color("markdownImage"),
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
},
},
"@context:elevated": { color: { background: { default: color("backgroundPanel") } } },
"@context:overlay": { color: { background: { default: color("backgroundMenu") } } },
}
}
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
const defs = theme.defs ?? {}
function resolveColor(value: unknown, chain: string[] = []): RGBA {
if (value instanceof RGBA) return value
if (typeof value === "string") {
if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0)
if (value.startsWith("#")) return RGBA.fromHex(value)
if (chain.includes(value)) throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
const next = defs[value] ?? theme.theme[value as ThemeColor]
if (next === undefined) throw new Error(`Color reference "${value}" not found in defs or theme`)
return resolveColor(next, [...chain, value])
}
if (typeof value === "number") return ansi(value)
if (!value || typeof value !== "object" || !(mode in value)) throw new Error("Invalid V1 theme color")
return resolveColor((value as Record<"dark" | "light", unknown>)[mode], chain)
}
const resolved = Object.fromEntries(
Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
.map(([key, value]) => [key, resolveColor(value)]),
) as Partial<Record<ThemeColor, RGBA>>
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
resolved.selectedListItemText = hasSelectedListItemText
? resolveColor(theme.theme.selectedListItemText)
: resolved.background
resolved.backgroundMenu = theme.theme.backgroundMenu
? resolveColor(theme.theme.backgroundMenu)
: resolved.backgroundElement
return {
...resolved,
_hasSelectedListItemText: hasSelectedListItemText,
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
} as Theme
}
function selectedForeground(theme: Theme, background: RGBA) {
if (theme._hasSelectedListItemText) return theme.selectedListItemText
if (theme.background.a !== 0) return theme.background
return 0.299 * background.r + 0.587 * background.g + 0.114 * background.b > 0.5
? RGBA.fromInts(0, 0, 0)
: RGBA.fromInts(255, 255, 255)
}
function accentScale(accent: RGBA) {
return {
100: mix(accent, 255, 0.66),
200: mix(accent, 255, 0.33),
300: hex(accent),
400: mix(accent, 0, 0.1),
500: mix(accent, 0, 0.2),
600: mix(accent, 0, 0.3),
700: mix(accent, 0, 0.4),
800: mix(accent, 0, 0.5),
900: mix(accent, 0, 0.6),
}
}
function mix(color: RGBA, target: number, amount: number) {
const [r, g, b, a] = color.toInts()
return hexInts(
Math.round(r + (target - r) * amount),
Math.round(g + (target - g) * amount),
Math.round(b + (target - b) * amount),
a,
)
}
function hex(color: RGBA) {
return hexInts(...color.toInts())
}
function hexInts(r: number, g: number, b: number, a: number) {
const byte = (value: number) => value.toString(16).padStart(2, "0")
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
}
function ansi(code: number) {
if (code < 16) {
const colors = [
"#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
"#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
]
return RGBA.fromHex(colors[code] ?? "#000000")
}
if (code < 232) {
const index = code - 16
const value = (part: number) => (part === 0 ? 0 : part * 40 + 55)
return RGBA.fromInts(value(Math.floor(index / 36)), value(Math.floor(index / 6) % 6), value(index % 6))
}
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
return RGBA.fromInts(0, 0, 0)
}

View file

@ -0,0 +1,35 @@
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { createComponentTheme } from "../../../src/theme/v2/component"
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
import { resolveTheme } from "../../../src/theme/v2/resolve"
import { selectTheme } from "../../../src/theme/v2/select"
import type { ContextKey } from "../../../src/theme/v2"
test("provides reactive property, variant, state, and context accessors", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
const [context, setContext] = createSignal<ContextKey>()
const theme = createComponentTheme(() => {
const key = context()
return key ? resolved().contexts[key] ?? resolved() : resolved()
})
expect(theme.color.text()).toBe(resolved().color.text.default)
expect(theme.color.text.subdued()).toBe(resolved().color.text.subdued)
expect(theme.color.text.action()).toBe(resolved().color.text.action.primary.default)
expect(theme.color.text.action.primary("pressed")).toBe(resolved().color.text.action.primary.pressed)
expect(theme.color.background.action.secondary("disabled")).toBe(
resolved().color.background.action.secondary.disabled,
)
expect(theme.color.scrollbar()).toBe(resolved().color.scrollbar.default)
expect(theme.color.diff.text.added()).toBe(resolved().color.diff.text.added)
setContext("@context:elevated")
expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
expect(theme.color.background.action.primary("selected")).toBe(
resolved().contexts["@context:elevated"]!.color.background.action.primary.selected,
)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
})

View file

@ -0,0 +1,200 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
import type { ThemeDefinition } from "../../../src/theme/v2"
import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve"
import { selectTheme } from "../../../src/theme/v2/select"
const light = selectTheme(DEFAULT_THEME, "light")
const dark = selectTheme(DEFAULT_THEME, "dark")
test("resolves independent definitions and hue aliases", () => {
const lightTheme = resolveTheme(light)
const darkTheme = resolveTheme(dark)
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
expect(lightTheme.color.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.color.background.default).toBeInstanceOf(RGBA)
expect(lightTheme.color.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.color.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
)
expect(lightTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(lightTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
)
expect(lightTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
)
expect(darkTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
darkTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
)
expect(darkTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
darkTheme.hue.neutral[900],
)
})
test("merges partial files with the selected OpenCode defaults", () => {
const theme = resolveThemeFile(
{
version: 2,
light: {
hue: light.hue,
color: { text: { default: "#123456" } },
},
dark: { hue: dark.hue },
},
"light",
)
expect(theme.color.text.default.toInts()).toEqual([18, 52, 86, 255])
expect(theme.color.text.subdued.toInts()).toEqual([18, 52, 86, 255])
expect(theme.color.background.action.destructive.pressed).toBeInstanceOf(RGBA)
})
test("expands user structural fallbacks before merging defaults", () => {
const expanded = resolveThemeFile(
{
version: 2,
light: {
hue: light.hue,
color: { background: { action: { primary: { default: "#123456" } } } },
},
dark: { hue: dark.hue },
},
"light",
)
const isolatedState = resolveThemeFile(
{
version: 2,
light: {
hue: light.hue,
color: { background: { action: { primary: { $pressed: "#654321" } } } },
},
dark: { hue: dark.hue },
},
"light",
)
expect(expanded.color.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
expect(isolatedState.color.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
expect(isolatedState.color.background.action.primary.hovered.toInts()).toEqual(
resolveTheme(light).color.background.action.primary.hovered.toInts(),
)
})
test("standalone themes skip OpenCode defaults and use the red core fallback", () => {
const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const
const lightTheme = resolveThemeFile(file, "light")
const darkTheme = resolveThemeFile(file, "dark")
expect(lightTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
expect(lightTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
expect(darkTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
expect(darkTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
})
test("uses defaults for the selected mode when it merges the other mode", () => {
const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark")
expect(theme.color.background.default.toInts()).toEqual(resolveTheme(dark).color.background.default.toInts())
})
test("resolves matched action variants and states", () => {
const theme = resolveTheme(light)
expect(theme.color.text.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.color.background.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.color.text.action.secondary.default).toBeInstanceOf(RGBA)
expect(theme.color.background.action.destructive.disabled).toBeInstanceOf(RGBA)
})
test("context overrides rewire semantic references and apply state precedence", () => {
const definition = override(light, {
color: {
text: {
default: "#111111",
action: {
primary: { default: "$color.text.default", $pressed: "#222222" },
secondary: { default: "$color.text.default" },
},
},
},
"@context:elevated": {
color: {
text: {
default: "#333333",
action: { primary: { default: "#444444", $selected: "#555555" } },
},
},
},
})
const theme = resolveTheme(definition)
const overlay = theme.contexts["@context:elevated"]!
expect(overlay.color.text.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.color.text.action.secondary.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.color.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
expect(overlay.color.text.action.primary.selected.toInts()).toEqual([85, 85, 85, 255])
})
test("rejects missing, base, and contextual reference cycles", () => {
expect(() => resolveTheme(override(light, { color: { text: { default: "$missing.color" } } }))).toThrow(
'Theme reference "$missing.color" was not found',
)
expect(() =>
resolveTheme(
override(light, {
color: { text: { default: "$color.text.subdued", subdued: "$color.text.default" } },
}),
),
).toThrow("Circular theme reference")
expect(() =>
resolveTheme(
override(light, {
"@context:elevated": { color: { text: { default: "$color.text.default" } } },
}),
),
).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,
),
).toThrow("$hue.missing")
expect(() =>
resolveTheme({
...light,
color: { ...light.color, syntax: { ...light.color?.syntax, keyword: "$color.text.default" } },
} as unknown as ThemeDefinition),
).toThrow("$color.text.default")
})
function override(base: ThemeDefinition, value: Partial<ThemeDefinition>) {
return merge(base, value) as ThemeDefinition
}
function merge(...values: unknown[]): Record<string, unknown> {
return values.reduce<Record<string, unknown>>((result, value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return result
for (const [key, item] of Object.entries(value)) {
if (item === undefined) continue
result[key] = item && typeof item === "object" && !Array.isArray(item) ? merge(result[key], item) : item
}
return result
}, {})
}

View file

@ -0,0 +1,37 @@
import { expect, test } from "bun:test"
import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select"
const hue = {} as HueDefinition
const light = { hue, color: { text: { default: "#111111", subdued: "#222222" } } } satisfies ThemeDefinition
const dark = { hue, color: { text: { default: "#eeeeee", subdued: "#dddddd" } } } satisfies ThemeDefinition
test("requires and selects independent light and dark themes", () => {
const file = { version: 2, light, dark } satisfies ThemeFile
expect(selectTheme(file)).toBe(light)
expect(selectTheme(file, "light")).toBe(light)
expect(selectTheme(file, "dark")).toBe(dark)
expect(selectThemeMode(file, "dark").mode).toBe("dark")
})
test("merges an expanded mode override over the other mode", () => {
const file = {
version: 2,
light,
dark: { mergeMode: true, color: { text: { default: "#ffffff" } } },
} satisfies ThemeFile
const selected = selectTheme(file, "dark")
expect(selected.hue).toBeDefined()
expect(selected.color?.text?.default).toBe("#ffffff")
expect(selected.color?.text?.subdued).toBe("$color.text.default")
})
test("rejects mutual mode merging", () => {
const file = {
version: 2,
light: { mergeMode: true },
dark: { mergeMode: true },
} satisfies ThemeFile
expect(() => selectTheme(file)).toThrow("cannot both merge")
})

View file

@ -0,0 +1,47 @@
import { expect, test } from "bun:test"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
const text = {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $pressed: "$hue.neutral.200" },
secondary: { default: "$hue.neutral.900" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
},
} satisfies TextDefinition
const background = {
default: "$hue.neutral.100",
action: {
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
secondary: { default: "$hue.neutral.200" },
destructive: { default: "$hue.red.600" },
},
feedback: { error: { default: "$hue.red.100" } },
} satisfies BackgroundDefinition
const definition = {
hue: {} as ThemeDefinition["hue"],
color: { text, background, border: { default: "$hue.neutral.300" } },
"@context:elevated": {
color: {
text: { default: "$hue.neutral.800" },
background: { default: "$hue.neutral.200" },
},
},
"@context:overlay": { color: { background: { default: "$hue.neutral.300" } } },
} satisfies ThemeDefinition
const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
test("supports property-first definitions, variants, states, and contexts", () => {
expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
expect(background.action.destructive.default).toBe("$hue.red.600")
expect(definition["@context:elevated"].color?.text?.default).toBe("$hue.neutral.800")
expect(definition["@context:overlay"].color?.background?.default).toBe("$hue.neutral.300")
expect(file.light).toBe(definition)
})

View file

@ -0,0 +1,62 @@
import { expect, test } from "bun:test"
import { DEFAULT_THEMES, resolveTheme as resolveV1, selectedForeground } from "../../../src/theme"
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
test("migrates resolved V1 modes into literal V2 tokens", () => {
const migrated = migrateV1(DEFAULT_THEMES.opencode)
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
const resolved = resolveThemeFile(migrated, "light")
expect(migrated.standalone).toBeUndefined()
expect(migrated.light.hue?.accent).toBeObject()
if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale")
expect(migrated.light.hue.accent[300]).toBe(hex(legacy.accent))
expect(migrated.light.color?.background?.default).toBe(hex(legacy.background))
expect(migrated.light.color?.background?.action?.primary?.default).toBe(hex(legacy.primary))
expect(migrated.light.color?.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
expect(migrated.light.color?.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.color?.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.color?.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
expect(resolved.color.background.action.secondary.hovered.toInts()).toEqual(legacy.backgroundElement.toInts())
expect(resolved.color.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.color.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.color.background.default.toInts()).toEqual(
legacy.backgroundMenu.toInts(),
)
})
test("preserves V1 selected foreground behavior on transparent backgrounds", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.background = "transparent"
source.theme.primary = { light: "#ffffff", dark: "#000000" }
delete source.theme.selectedListItemText
const migrated = migrateV1(source)
expect(migrated.light.color?.text?.action?.primary?.default).toBe("#000000")
expect(migrated.dark.color?.text?.action?.primary?.default).toBe("#ffffff")
})
test("retains V1 circular reference errors", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.defs = { ...source.defs, one: "two", two: "one" }
source.theme.primary = "one"
expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
})
test("migrates every built-in V1 theme in both modes", () => {
for (const source of Object.values(DEFAULT_THEMES)) {
const migrated = migrateV1(source)
expect(resolveThemeFile(migrated, "light").color.text.default).toBeDefined()
expect(resolveThemeFile(migrated, "dark").color.text.default).toBeDefined()
}
})
function hex(color: { toInts(): [number, number, number, number] }) {
const [r, g, b, a] = color.toInts()
const byte = (value: number) => value.toString(16).padStart(2, "0")
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
}