feat(theme): extract TUI theme package (#39378)

This commit is contained in:
James Long 2026-07-28 14:14:39 -04:00 committed by GitHub
commit c445d98188
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 419 additions and 235 deletions

View file

@ -0,0 +1,77 @@
type OklchColor = {
l: number
c: number
h: number
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function hue(value: number) {
return ((value % 360) + 360) % 360
}
function linearToSrgb(value: number) {
if (value <= 0.0031308) return value * 12.92
return 1.055 * Math.pow(value, 1 / 2.4) - 0.055
}
function srgbToLinear(value: number) {
if (value <= 0.04045) return value / 12.92
return Math.pow((value + 0.055) / 1.055, 2.4)
}
export function rgbToOklch(red: number, green: number, blue: number): OklchColor {
const linearRed = srgbToLinear(red)
const linearGreen = srgbToLinear(green)
const linearBlue = srgbToLinear(blue)
const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot
const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot
const chroma = Math.sqrt(a * a + b * b)
const angle = Math.atan2(b, a) * (180 / Math.PI)
return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle }
}
function oklchToRgb(color: OklchColor) {
const a = color.c * Math.cos((color.h * Math.PI) / 180)
const b = color.c * Math.sin((color.h * Math.PI) / 180)
const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b
const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b
const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b
const l = lRoot * lRoot * lRoot
const m = mRoot * mRoot * mRoot
const s = sRoot * sRoot * sRoot
return {
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
}
}
function fitOklch(color: OklchColor): OklchColor {
const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) }
const rgb = oklchToRgb(base)
if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base
const fitted = Array.from({ length: 24 }).reduce<OklchColor | undefined>((result, _, index) => {
if (result) return result
const next = { ...base, c: base.c * Math.pow(0.9, index + 1) }
const output = oklchToRgb(next)
if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next
}, undefined)
return fitted ?? { ...base, c: 0 }
}
export function oklchToHex(color: OklchColor) {
const rgb = oklchToRgb(fitOklch(color))
const toHex = (value: number) =>
Math.round(clamp(value, 0, 1) * 255)
.toString(16)
.padStart(2, "0")
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
}

View file

@ -0,0 +1,440 @@
import type { HueName, ThemeDocument } from "./schema.js"
export const DEFAULT_CATEGORICAL = [
"blue",
"purple",
"green",
"orange",
"red",
"cyan",
] as const satisfies readonly HueName[]
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",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
categorical: DEFAULT_CATEGORICAL,
text: {
default: "$hue.neutral.800",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.800",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.700",
},
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.200",
surface: {
offset: "$hue.neutral.300",
overlay: "$hue.neutral.400",
},
action: {
primary: {
default: "$hue.interactive.600",
$hovered: "$hue.interactive.700",
$focused: "$hue.interactive.700",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
destructive: {
default: "$hue.red.600",
$hovered: "$hue.red.700",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.300",
},
},
formfield: {
default: "$background.default",
$hovered: "$background.surface.offset",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$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": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.surface.overlay" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.interactive.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",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
categorical: DEFAULT_CATEGORICAL,
text: {
default: "$hue.neutral.200",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.200",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.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.800",
surface: {
offset: "$hue.neutral.700",
overlay: "$hue.neutral.600",
},
action: {
primary: {
default: "$hue.interactive.500",
$hovered: "$hue.interactive.600",
$focused: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
destructive: {
default: "$hue.red.600",
$hovered: "$hue.red.700",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.800",
},
},
formfield: {
default: "$background.default",
$hovered: "$background.surface.offset",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$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": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.surface.overlay" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.interactive.400" } },
},
},
},
} satisfies ThemeDocument

View file

@ -0,0 +1,108 @@
import type {
BackgroundDefinition,
ModeDefinition,
StatefulColorDefinition,
TextDefinition,
ThemeTokensDefinition,
} from "./index.js"
import { ActionState } from "./schema.js"
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
return {
...definition,
...expandTokens(definition),
...Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
),
}
}
export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
return {
...definition,
text: expandText(definition.text),
background: expandBackground(definition.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 ? "$text.default" : undefined),
action: expandActions(definition.action, "text.action"),
formfield: expandFormfield(definition.formfield, "text.formfield"),
feedback: definition.feedback
? Object.fromEntries(
Object.entries(definition.feedback).map(([kind, feedback]) => {
return [
kind,
{
...feedback,
subdued: feedback.subdued ?? (feedback.default ? `$text.feedback.${kind}.default` : undefined),
},
]
}),
)
: undefined,
}
}
function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined {
if (!definition) return
return {
...definition,
action: expandActions(definition.action, "background.action"),
formfield: expandFormfield(definition.formfield, "background.formfield"),
}
}
function expandFormfield(definition: StatefulColorDefinition | undefined, path: string) {
if (!definition?.default) return definition
return {
...definition,
...Object.fromEntries(
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]),
),
}
}
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,57 @@
import type { ThemeTokensDefinition } from "./index.js"
import { ActionVariant, FeedbackKind } from "./schema.js"
export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
default: red,
surface: { offset: red, overlay: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { 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,50 @@
export {
ActionState,
type ActionStateKey,
ActionVariant,
BaseHue,
CategoricalDefinition,
FeedbackKind,
FormfieldState,
type FormfieldStateKey,
HueAlias,
HueName,
HueStep,
MarkdownDefinition,
MarkdownToken,
ModeDefinition,
SyntaxDefinition,
SyntaxToken,
ThemeDefinition,
ThemeDocument,
type BackgroundDefinition,
type DiffDefinition,
type FileThemeDefinition,
type FormfieldColorDefinition,
type HueDefinition,
type HueOverrideDefinition,
type MergeModeDefinition,
type Mode,
type StatefulColorDefinition,
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema.js"
export type {
Categorical,
FormfieldColor,
Hue,
HueSource,
HueScale,
ResolvedActionState,
ResolvedFormfieldState,
ResolvedTheme,
ResolvedThemeView,
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
export { generateSyntax } from "./syntax.js"

View file

@ -0,0 +1,255 @@
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
import { fallback } from "./fallback.js"
import {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
ThemeDefinition,
ThemeDocument,
} from "./schema.js"
import type {
ActionStateKey,
HueDefinition,
HueScale,
ResolvedActionState,
ResolvedTheme,
ResolvedThemeView,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
import { selectTheme, selectThemeMode } from "./select.js"
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
function decodeThemeDefinition(input: unknown) {
try {
return decodeThemeDefinitionSchema(input)
} catch (error) {
throw themeDecodeError(error, "theme")
}
}
export function themeDecodeError(error: unknown, name: string) {
const message = Schema.isSchemaError(error) ? error.message : String(error)
const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value"
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
}
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
const selected = selectThemeMode(document, mode)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
const core = expandTokens(fallback())
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
return resolveExpandedTheme({
...merged,
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
} as ThemeDefinition)
}
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
}
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
const hue = resolveHue(definition.hue)
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
const hueSteps = compileHueSteps(hue)
const base = tokens(definition)
const resolved = resolveView(base, hue, categorical, hueSteps)
const contexts = Object.fromEntries(
Object.entries(definition)
.filter(([key]) => key.startsWith("@context:"))
.map(([key, override]) => {
const contextual = contextualize(base, override as ThemeTokensDefinition)
return [key, resolveView(contextual, hue, categorical, hueSteps)]
}),
)
return { ...resolved, contexts } as ResolvedTheme
}
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
return {
text: definition.text,
background: definition.background,
border: definition.border,
scrollbar: definition.scrollbar,
diff: definition.diff,
syntax: definition.syntax,
markdown: definition.markdown,
}
}
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
const result = mergeTheme(base, override)
const baseText = base.text?.action
const contextText = override.text?.action
const baseBackground = base.background?.action
const contextBackground = override.background?.action
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
return {
...result,
text: { ...text, action: contextualActions(baseText, contextText) },
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
} as ThemeTokensDefinition
}
function contextualActions(
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
) {
return Object.fromEntries(
ActionVariant.literals.map((variant) => {
const baseVariant = base?.[variant]
const contextVariant = context?.[variant]
return [
variant,
Object.fromEntries(
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
return [
key ?? "default",
(key ? contextVariant?.[key] : undefined) ??
contextVariant?.default ??
(key ? baseVariant?.[key] : undefined) ??
baseVariant?.default,
]
}),
),
]
}),
)
}
function resolveView(
definition: ThemeTokensDefinition,
hue: ResolvedThemeView["hue"],
categorical: ResolvedThemeView["categorical"],
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
): ResolvedThemeView {
const source: Record<string, unknown> = { hue, ...definition }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps }
}
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
}
const shift = (color: RGBA, amount: number) => {
const match = index.get(color)
if (!match) return color
const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0
const position = Math.max(0, Math.min(HueStep.literals.length - 1, match.position + offset))
return hue[match.hue][HueStep.literals[position]]
}
return {
source: (color) => {
const match = index.get(color)
return match ? { hue: match.hue, step: match.step } : undefined
},
increase: (color, amount = 1) => shift(color, amount),
decrease: (color, amount = 1) => shift(color, -amount),
}
}
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") {
const match = /^\$hue\.([^.]+)$/.exec(value)
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
const target = resolve(match[1], [...stack, name])
const result = Object.fromEntries(HueStep.literals.map((step) => [step, RGBA.clone(target[step])])) as HueScale
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 (value === "transparent") return RGBA.fromInts(0, 0, 0, 0)
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,258 @@
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", "interactive", "neutral"])
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "selected", "hovered"])
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const FormfieldState = ActionState
export type FormfieldState = ActionState
export type FormfieldStateKey = `$${FormfieldState}`
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.Literal("transparent"),
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
])
export const HueName = Schema.Union([BaseHue, HueAlias])
export type HueName = Schema.Schema.Type<typeof HueName>
export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1))
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueDefinition = Schema.Struct({
gray: HueValueDefinition,
red: HueValueDefinition,
orange: HueValueDefinition,
yellow: HueValueDefinition,
green: HueValueDefinition,
cyan: HueValueDefinition,
blue: HueValueDefinition,
purple: HueValueDefinition,
accent: HueValueDefinition,
interactive: HueValueDefinition,
neutral: HueValueDefinition,
})
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueOverrideDefinition = Schema.Struct({
gray: Schema.optional(HueValueDefinition),
red: Schema.optional(HueValueDefinition),
orange: Schema.optional(HueValueDefinition),
yellow: Schema.optional(HueValueDefinition),
green: Schema.optional(HueValueDefinition),
cyan: Schema.optional(HueValueDefinition),
blue: Schema.optional(HueValueDefinition),
purple: Schema.optional(HueValueDefinition),
accent: Schema.optional(HueValueDefinition),
interactive: Schema.optional(HueValueDefinition),
neutral: Schema.optional(HueValueDefinition),
})
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$hovered: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
})
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
export type FormfieldColorDefinition = StatefulColorDefinition
const ActionColorDefinition = Schema.Struct({
primary: 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),
formfield: Schema.optional(StatefulColorDefinition),
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),
surface: Schema.optional(
Schema.Struct({
offset: Schema.optional(ColorValue),
overlay: Schema.optional(ColorValue),
}),
),
action: Schema.optional(ActionColorDefinition),
formfield: Schema.optional(StatefulColorDefinition),
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({
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,
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export const ThemeDefinition = ThemeDefinitionFields
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
const FileThemeDefinition = Schema.Struct({
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
const MergeModeDefinition = Schema.Struct({
mergeMode: Schema.Literal(true),
hue: Schema.optional(HueOverrideDefinition),
categorical: Schema.optional(CategoricalDefinition),
...ThemeTokensDefinition.fields,
"@context:elevated": Schema.optional(ThemeTokensDefinition),
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
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 ThemeDocument = Schema.Union([
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
])
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>

View file

@ -0,0 +1,51 @@
import { expandTheme, mergeTheme } from "./expand.js"
import type {
FileThemeDefinition,
MergeModeDefinition,
Mode,
ModeDefinition,
ThemeDefinition,
ThemeDocument,
} from "./index.js"
export function selectTheme(
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
mode?: Mode,
): ThemeDefinition
export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition
export function selectTheme(document: ThemeDocument, mode?: Mode) {
return selectThemeMode(document, mode).theme
}
export function selectThemeMode(
document: ThemeDocument,
mode: Mode = "light",
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
const modes = themeModes(document)
const selectedMode = modes.includes(mode) ? mode : modes[0]
const selected = document[selectedMode]
if (!selected) throw new Error("Theme must provide at least one mode")
if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes")
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
const otherMode = selectedMode === "light" ? "dark" : "light"
const other = document[otherMode]
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
}
export function themeModes(document: ThemeDocument): readonly Mode[] {
if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme")
if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme")
return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined)
}
export function supportsThemeMode(document: ThemeDocument, mode: Mode) {
return themeModes(document).includes(mode)
}
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
}

View file

@ -0,0 +1,93 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeView } from "./index.js"
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
const step = mode === "light" ? 800 : 200
const syntax = theme.syntax
const markdown = theme.markdown
const feedback = theme.text.feedback
return SyntaxStyle.fromTheme([
rule(["default"], theme.text.default),
rule(["prompt"], theme.hue.accent[step]),
rule(["extmark.file"], feedback.warning.default, { bold: true }),
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
// V1 migration preserves its selected/inverse foreground in this action state.
rule(["extmark.paste"], theme.text.action.primary.focused, {
background: feedback.warning.default,
bold: true,
}),
rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
rule(["string", "symbol", "character.special", "character"], syntax.string),
rule(["number", "boolean", "constant", "float"], syntax.number),
rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, {
italic: true,
}),
rule(["keyword.type"], syntax.type, { bold: true, italic: true }),
rule(["keyword.function", "function.method"], syntax.function),
rule(["keyword"], syntax.keyword, { italic: true }),
rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword),
rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator),
rule(
["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"],
syntax.variable,
),
rule(["variable.member", "function", "constructor"], syntax.function),
rule(["type", "module", "class", "namespace"], syntax.type),
rule(["type.definition"], syntax.type, { bold: true }),
rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
rule(
["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
feedback.error.default,
),
rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
rule(["punctuation.special", "tag.delimiter"], syntax.operator),
rule(
[
"markup.heading",
"markup.heading.2",
"markup.heading.3",
"markup.heading.4",
"markup.heading.5",
"markup.heading.6",
],
markdown.heading,
{ bold: true },
),
rule(["markup.heading.1"], markdown.heading, { bold: true, underline: true }),
rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }),
rule(["markup.italic"], markdown.emphasis, { italic: true }),
rule(["markup.list"], markdown.listItem),
rule(["markup.quote"], markdown.blockQuote, { italic: true }),
rule(["markup.raw", "markup.raw.block"], markdown.code),
rule(["markup.raw.inline"], markdown.code, { background: theme.background.default }),
rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
underline: true,
}),
rule(["markup.link.label"], markdown.linkText, { underline: true }),
rule(["label"], markdown.linkText),
rule(["spell", "nospell"], theme.text.default),
rule(["markup.underline"], theme.text.default, { underline: true }),
rule(["comment.error"], feedback.error.default, { italic: true, bold: true }),
rule(["comment.warning"], feedback.warning.default, { italic: true, bold: true }),
rule(["comment.todo", "comment.note"], feedback.info.default, { italic: true, bold: true }),
rule(["attribute", "annotation"], feedback.warning.default),
rule(["tag"], feedback.error.default),
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.subdued),
rule(["markup.list.checked"], feedback.success.default),
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
rule(["error"], feedback.error.default, { bold: true }),
rule(["warning"], feedback.warning.default, { bold: true }),
rule(["info"], feedback.info.default),
])
}
function rule(
scope: string[],
foreground: RGBA,
style: Omit<ThemeTokenStyle["style"], "foreground"> = {},
): ThemeTokenStyle {
return { scope, style: { foreground, ...style } }
}

View file

@ -0,0 +1,68 @@
import type { RGBA } from "@opentui/core"
import type {
ActionState,
ActionVariant,
BaseHue,
FeedbackKind,
HueAlias,
HueStep,
MarkdownToken,
ContextKey,
SyntaxToken,
} from "./schema.js"
export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = ResolvedActionState
export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
export type Categorical = readonly HueScale[]
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = StatefulColor
export type ResolvedThemeView = {
readonly hue: Hue
readonly categorical: Categorical
readonly source: (color: RGBA) => HueSource | undefined
readonly increase: (color: RGBA, amount?: number) => RGBA
readonly decrease: (color: RGBA, amount?: number) => RGBA
readonly text: {
readonly default: RGBA
readonly subdued: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly formfield: FormfieldColor
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
}
readonly background: {
readonly default: RGBA
readonly surface: {
readonly offset: RGBA
readonly overlay: RGBA
}
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly formfield: FormfieldColor
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,462 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "./color.js"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
import { HueStep } from "./schema.js"
import type { Theme, ThemeV1Json } from "./v1.js"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
const minimumChroma = 0.03
const lightThreshold = 0.6
export function migrateV1(theme: ThemeV1Json): ThemeDocument {
const light = resolveV1(theme, "light")
const dark = resolveV1(theme, "dark")
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
const lightMode = detectMode(light)
const darkMode = detectMode(dark)
if (lightMode === darkMode) {
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
}
}
return {
version: 2,
standalone: true,
light: migrateMode(light, "light"),
dark: migrateMode(dark, "dark"),
}
}
function detectMode(theme: Theme): Mode {
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
}
function luminance(color: RGBA) {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
const hues = inferHues(theme, mode)
const categorical = categoricalTokens.flatMap((token) => {
const hue = hues.byToken[token]
return hue ? [hue] : []
})
const uniqueCategorical = categorical.filter((hue, index) => categorical.indexOf(hue) === index)
const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
const background = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
return referenceHues({
hue: {
gray: neutralScale(theme, mode),
...Object.fromEntries(
chromaticHues.map((name) => {
const match = hues.byHue[name]
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
}),
),
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
neutral: "$hue.gray",
},
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
text: {
default: text,
subdued: textMuted,
action: {
primary: {
default: "$text.default",
$disabled: textMuted,
$focused: selected,
$selected: primary,
},
destructive: { default: destructive, $disabled: textMuted },
},
formfield: {
default: text,
$hovered: primary,
$focused: primary,
$pressed: primary,
$disabled: textMuted,
$selected: primary,
},
feedback: {
error: { default: color("error") },
warning: { default: color("warning") },
success: { default: color("success") },
info: { default: color("info") },
},
},
background: {
default: background,
surface: {
offset: backgroundPanel,
overlay: backgroundMenu,
},
action: {
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
destructive: { default: color("error") },
},
formfield: {
default: "$background.default",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
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": {
background: {
default: "$background.surface.offset",
action: { primary: { $hovered: "$background.surface.overlay" } },
},
},
"@context:overlay": { background: { default: "$background.surface.overlay" } },
})
}
function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
const definitions = theme.hue as Record<string, string | Partial<Record<HueStep, string>>> | undefined
if (!definitions) return theme
const scales = new Map<string, Partial<Record<HueStep, string>>>()
function resolve(name: string, chain: string[] = []): Partial<Record<HueStep, string>> | undefined {
const cached = scales.get(name)
if (cached) return cached
if (chain.includes(name)) return
const value = definitions?.[name]
if (!value) return
if (typeof value !== "string") {
scales.set(name, value)
return value
}
const target = /^\$hue\.([^.]+)$/.exec(value)?.[1]
if (!target) return
const scale = resolve(target, [...chain, name])
if (scale) scales.set(name, scale)
return scale
}
const references = new Map<string, string>()
const index = (name: string, overwrite: boolean) => {
const scale = resolve(name)
if (!scale) return
HueStep.literals.forEach((step) => {
const color = scale[step]
if (!color || (!overwrite && references.has(color.toLowerCase()))) return
references.set(color.toLowerCase(), `$hue.${name}.${step}`)
})
}
chromaticHues.forEach((name) => index(name, false))
index("gray", false)
index("accent", true)
index("interactive", true)
index("neutral", true)
function replace(value: unknown): unknown {
if (typeof value === "string") return references.get(value.toLowerCase()) ?? value
if (!value || typeof value !== "object" || Array.isArray(value)) return value
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item)]))
}
return Object.fromEntries(
Object.entries(theme).map(([key, value]) => [key, key === "hue" || key === "categorical" ? value : replace(value)]),
) as FileThemeDefinition
}
function inferHues(theme: Theme, mode: "light" | "dark") {
const colors: readonly [V1HueToken, RGBA][] = [
["accent", theme.accent],
["success", theme.success],
["warning", theme.warning],
["primary", theme.primary],
["error", theme.error],
["info", theme.info],
["secondary", theme.secondary],
]
const inferred = colors.reduce<{
byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
byToken: Partial<Record<V1HueToken, ChromaticHue>>
}>(
(result, [token, color]) => {
const nearest = inferHue(color, mode)
if (!nearest) return result
const current = result.byHue[nearest.name]
return {
byHue:
current && current.distance <= nearest.distance
? result.byHue
: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
byToken: { ...result.byToken, [token]: nearest.name },
}
},
{ byHue: {}, byToken: {} },
)
return (
[
["accent", theme.accent],
["primary", theme.primary],
] as const
).reduce((result, [token, color]) => {
const nearest = inferHue(color, mode)
if (!nearest) return result
return {
byHue: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
byToken: { ...result.byToken, [token]: nearest.name },
}
}, inferred)
}
function inferHue(color: RGBA, mode: Mode) {
const value = toOklch(color)
if (ambiguous(color, value.c)) return
const anchor = inferenceAnchor(value.l)
return chromaticHues
.map((name) => ({
name,
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
}))
.sort((first, second) => first.distance - second.distance)[0]
}
function inferenceAnchor(lightness: number): HueStep {
return lightness >= lightThreshold ? 300 : 700
}
function hueDistance(first: number, second: number) {
const difference = Math.abs(first - second)
return Math.min(difference, 360 - difference)
}
function ambiguous(color: RGBA, chroma = toOklch(color).c) {
return color.toInts()[3] === 0 || chroma < minimumChroma
}
function resolveV1(theme: ThemeV1Json, 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 hueScale(color: RGBA, mode: "light" | "dark") {
const value = toOklch(color)
const anchor = mode === "light" ? 800 : 200
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
const alpha = color.toInts()[3]
return Object.fromEntries(
HueStep.literals.map((step) => {
if (step === anchor) return [step, hex(color)]
const progress = mode === "light" ? (anchor - step) / (anchor - 100) : (step - anchor) / (900 - anchor)
const generated = oklchToHex({
l: value.l + (endpoint - value.l) * progress,
c: value.c * (1 - progress * 0.5),
h: value.h,
})
return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
}),
) as Record<HueStep, string>
}
function neutralScale(theme: Theme, mode: "light" | "dark") {
const anchors = neutralAnchors(theme, mode)
return Object.fromEntries(
HueStep.literals.map((step) => {
const exact = anchors.find((anchor) => anchor.step === step)
if (exact) return [step, hex(exact.color)]
const first = anchors[0]!
const last = anchors.at(-1)!
const [lower, upper] =
step < first.step
? [first, anchors[1]!]
: step > last.step
? [anchors.at(-2)!, last]
: [anchors.filter((anchor) => anchor.step < step).at(-1)!, anchors.find((anchor) => anchor.step > step)!]
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
}),
) as Record<HueStep, string>
}
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 200, color: theme.background },
{ step: 300, color: theme.backgroundPanel },
{ step: 400, color: theme.backgroundElement || theme.backgroundMenu },
{ step: 600, color: theme.textMuted },
{ step: 800, color: theme.text },
]
if (mode === "light") return light
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
}
function interpolate(first: RGBA, second: RGBA, amount: number) {
const start = toOklch(first)
const end = toOklch(second)
const startHue = Number.isFinite(start.h) ? start.h : Number.isFinite(end.h) ? end.h : 0
const endHue = Number.isFinite(end.h) ? end.h : startHue
const hue = ((((endHue - startHue) % 360) + 540) % 360) - 180
const generated = oklchToHex({
l: start.l + (end.l - start.l) * amount,
c: start.c + (end.c - start.c) * amount,
h: startHue + hue * amount,
})
const alpha = Math.max(
0,
Math.min(255, Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)),
)
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
}
function toOklch(color: RGBA) {
const [red, green, blue] = color.toInts()
return rgbToOklch(red / 255, green / 255, blue / 255)
}
function hex(color: RGBA) {
return hexInts(...color.toInts())
}
function hexInts(r: number, g: number, b: number, a: number) {
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
}
function byte(value: number) {
return value.toString(16).padStart(2, "0")
}
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,76 @@
import type { RGBA } from "@opentui/core"
export type Theme = {
readonly primary: RGBA
readonly secondary: RGBA
readonly accent: RGBA
readonly error: RGBA
readonly warning: RGBA
readonly success: RGBA
readonly info: RGBA
readonly text: RGBA
readonly textMuted: RGBA
readonly selectedListItemText: RGBA
readonly background: RGBA
readonly backgroundPanel: RGBA
readonly backgroundElement: RGBA
readonly backgroundMenu: RGBA
readonly border: RGBA
readonly borderActive: RGBA
readonly borderSubtle: RGBA
readonly diffAdded: RGBA
readonly diffRemoved: RGBA
readonly diffContext: RGBA
readonly diffHunkHeader: RGBA
readonly diffHighlightAdded: RGBA
readonly diffHighlightRemoved: RGBA
readonly diffAddedBg: RGBA
readonly diffRemovedBg: RGBA
readonly diffContextBg: RGBA
readonly diffLineNumber: RGBA
readonly diffAddedLineNumberBg: RGBA
readonly diffRemovedLineNumberBg: RGBA
readonly markdownText: RGBA
readonly markdownHeading: RGBA
readonly markdownLink: RGBA
readonly markdownLinkText: RGBA
readonly markdownCode: RGBA
readonly markdownBlockQuote: RGBA
readonly markdownEmph: RGBA
readonly markdownStrong: RGBA
readonly markdownHorizontalRule: RGBA
readonly markdownListItem: RGBA
readonly markdownListEnumeration: RGBA
readonly markdownImage: RGBA
readonly markdownImageText: RGBA
readonly markdownCodeBlock: RGBA
readonly syntaxComment: RGBA
readonly syntaxKeyword: RGBA
readonly syntaxFunction: RGBA
readonly syntaxVariable: RGBA
readonly syntaxString: RGBA
readonly syntaxNumber: RGBA
readonly syntaxType: RGBA
readonly syntaxOperator: RGBA
readonly syntaxPunctuation: RGBA
readonly thinkingOpacity: number
_hasSelectedListItemText: boolean
}
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeV1Json = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}