From 9a25673c376a368b90f87e5ebc191f04831c0e5f Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 14 Jul 2026 20:28:45 -0400 Subject: [PATCH] feat(tui): add V2 theme system (#36950) --- packages/tui/src/theme/v2/component.ts | 106 +++++ packages/tui/src/theme/v2/defaults.ts | 391 ++++++++++++++++++ packages/tui/src/theme/v2/expand.ts | 97 +++++ packages/tui/src/theme/v2/fallback.ts | 56 +++ packages/tui/src/theme/v2/index.ts | 30 ++ packages/tui/src/theme/v2/resolve.ts | 207 ++++++++++ packages/tui/src/theme/v2/schema.ts | 214 ++++++++++ packages/tui/src/theme/v2/select.ts | 37 ++ packages/tui/src/theme/v2/solid.ts | 50 +++ packages/tui/src/theme/v2/types.ts | 56 +++ packages/tui/src/theme/v2/v1-migrate.ts | 213 ++++++++++ packages/tui/test/theme/v2/component.test.ts | 35 ++ packages/tui/test/theme/v2/resolve.test.ts | 200 +++++++++ packages/tui/test/theme/v2/select.test.ts | 37 ++ packages/tui/test/theme/v2/types.test.ts | 47 +++ packages/tui/test/theme/v2/v1-migrate.test.ts | 62 +++ 16 files changed, 1838 insertions(+) create mode 100644 packages/tui/src/theme/v2/component.ts create mode 100644 packages/tui/src/theme/v2/defaults.ts create mode 100644 packages/tui/src/theme/v2/expand.ts create mode 100644 packages/tui/src/theme/v2/fallback.ts create mode 100644 packages/tui/src/theme/v2/index.ts create mode 100644 packages/tui/src/theme/v2/resolve.ts create mode 100644 packages/tui/src/theme/v2/schema.ts create mode 100644 packages/tui/src/theme/v2/select.ts create mode 100644 packages/tui/src/theme/v2/solid.ts create mode 100644 packages/tui/src/theme/v2/types.ts create mode 100644 packages/tui/src/theme/v2/v1-migrate.ts create mode 100644 packages/tui/test/theme/v2/component.test.ts create mode 100644 packages/tui/test/theme/v2/resolve.test.ts create mode 100644 packages/tui/test/theme/v2/select.test.ts create mode 100644 packages/tui/test/theme/v2/types.test.ts create mode 100644 packages/tui/test/theme/v2/v1-migrate.test.ts diff --git a/packages/tui/src/theme/v2/component.ts b/packages/tui/src/theme/v2/component.ts new file mode 100644 index 0000000000..25a46c48ce --- /dev/null +++ b/packages/tui/src/theme/v2/component.ts @@ -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) { + 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 diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts new file mode 100644 index 0000000000..3396c8fe7a --- /dev/null +++ b/packages/tui/src/theme/v2/defaults.ts @@ -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 diff --git a/packages/tui/src/theme/v2/expand.ts b/packages/tui/src/theme/v2/expand.ts new file mode 100644 index 0000000000..34971c9581 --- /dev/null +++ b/packages/tui/src/theme/v2/expand.ts @@ -0,0 +1,97 @@ +import type { + BackgroundDefinition, + ModeDefinition, + StatefulColorDefinition, + TextDefinition, + ThemeTokensDefinition, +} from "./index" +import { ActionState } from "./schema" + +export function expandTheme(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 { + return values.reduce>((result, value) => { + if (!isRecord(value)) return result + return Object.entries(value).reduce>((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: 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 { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/tui/src/theme/v2/fallback.ts b/packages/tui/src/theme/v2/fallback.ts new file mode 100644 index 0000000000..d37af9a968 --- /dev/null +++ b/packages/tui/src/theme/v2/fallback.ts @@ -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, + }, + }, + } +} diff --git a/packages/tui/src/theme/v2/index.ts b/packages/tui/src/theme/v2/index.ts new file mode 100644 index 0000000000..f2901c6d60 --- /dev/null +++ b/packages/tui/src/theme/v2/index.ts @@ -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" diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts new file mode 100644 index 0000000000..d11e7c5ae3 --- /dev/null +++ b/packages/tui/src/theme/v2/resolve.ts @@ -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 + return { + ...result, + color: { + ...color, + text: { ...color.text, action: contextualActions(baseText, contextText) }, + background: { ...color.background, action: contextualActions(baseBackground, contextBackground) }, + }, + } as ThemeTokensDefinition +} + +function contextualActions( + base: Partial> | undefined, + context: Partial> | 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 = { hue, ...definition } + return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue } +} + +function resolveHue(definition: HueDefinition) { + const source = definition as Record + const cache = new Map() + const expected = new Set([...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) { + const cache = new Map() + + 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, path: string) { + const result = path.split(".").reduce((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 { + return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof RGBA) +} diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts new file mode 100644 index 0000000000..f098767be0 --- /dev/null +++ b/packages/tui/src/theme/v2/schema.ts @@ -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 + +export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"]) +export type BaseHue = Schema.Schema.Type + +export const HueAlias = Schema.Literals(["accent", "neutral"]) +export type HueAlias = Schema.Schema.Type + +export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"]) +export type ActionVariant = Schema.Schema.Type + +export const ActionState = Schema.Literals(["hovered", "pressed", "selected", "focused", "disabled"]) +export type ActionState = Schema.Schema.Type +export type ActionStateKey = `$${ActionState}` + +export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"]) +export type FeedbackKind = Schema.Schema.Type + +const Mode = Schema.Literals(["light", "dark"]) +export type Mode = Schema.Schema.Type + +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 + +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 + +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 + +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 + +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 + +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 + +export const SyntaxToken = Schema.Literals([ + "comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation", +]) +export type SyntaxToken = Schema.Schema.Type +export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue)) +export type SyntaxDefinition = Schema.Schema.Type + +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 +export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue)) +export type MarkdownDefinition = Schema.Schema.Type + +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 + +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 + +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 + +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 + +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 +export const ModeDefinition = Schema.Union([FileThemeDefinition, MergeModeDefinition]) +export type ModeDefinition = Schema.Schema.Type + +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 diff --git a/packages/tui/src/theme/v2/select.ts b/packages/tui/src/theme/v2/select.ts new file mode 100644 index 0000000000..15017a7473 --- /dev/null +++ b/packages/tui/src/theme/v2/select.ts @@ -0,0 +1,37 @@ +import { expandTheme, mergeTheme } from "./expand" +import type { + FileThemeDefinition, + MergeModeDefinition, + Mode, + ModeDefinition, + ThemeDefinition, + ThemeFile, +} from "./index" + +export function selectTheme( + file: Omit & { 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 +} diff --git a/packages/tui/src/theme/v2/solid.ts b/packages/tui/src/theme/v2/solid.ts new file mode 100644 index 0000000000..2e6364c4e2 --- /dev/null +++ b/packages/tui/src/theme/v2/solid.ts @@ -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 + readonly component: ComponentTheme +} + +const ThemeContext = createContext() + +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 +} diff --git a/packages/tui/src/theme/v2/types.ts b/packages/tui/src/theme/v2/types.ts new file mode 100644 index 0000000000..19c8729d8a --- /dev/null +++ b/packages/tui/src/theme/v2/types.ts @@ -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> +export type Hue = Readonly> +export type StatefulColor = Readonly> + +export type ResolvedThemeView = { + readonly hue: Hue + readonly color: { + readonly text: { + readonly default: RGBA + readonly subdued: RGBA + readonly action: Readonly> + readonly feedback: Readonly> + } + readonly background: { + readonly default: RGBA + readonly action: Readonly> + readonly feedback: Readonly> + } + 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> + readonly markdown: Readonly> + } +} + +export type ResolvedTheme = ResolvedThemeView & { + readonly contexts: Readonly>> +} diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts new file mode 100644 index 0000000000..3b1bf67b81 --- /dev/null +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -0,0 +1,213 @@ +import { RGBA } from "@opentui/core" +import type { Theme, ThemeJson } from "../index" +import type { ThemeFile } from "./index" + +type ThemeColor = Exclude + +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> + 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) +} diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts new file mode 100644 index 0000000000..840bc41013 --- /dev/null +++ b/packages/tui/test/theme/v2/component.test.ts @@ -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() + 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) +}) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts new file mode 100644 index 0000000000..5718ce3565 --- /dev/null +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -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) { + return merge(base, value) as ThemeDefinition +} + +function merge(...values: unknown[]): Record { + return values.reduce>((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 + }, {}) +} diff --git a/packages/tui/test/theme/v2/select.test.ts b/packages/tui/test/theme/v2/select.test.ts new file mode 100644 index 0000000000..eae10f9769 --- /dev/null +++ b/packages/tui/test/theme/v2/select.test.ts @@ -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") +}) diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts new file mode 100644 index 0000000000..e95716278e --- /dev/null +++ b/packages/tui/test/theme/v2/types.test.ts @@ -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) +}) diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts new file mode 100644 index 0000000000..1157077b50 --- /dev/null +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -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)}` +}