feat(tui): migrate core surfaces to V2 themes (#37145)

This commit is contained in:
James Long 2026-07-16 10:22:29 -04:00 committed by GitHub
commit 5fcef6773c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1389 additions and 815 deletions

View file

@ -1,13 +1,36 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { ActionState, ActionVariant, ResolvedActionState, ResolvedThemeView } from "./index"
import type {
ActionState,
ActionVariant,
FormfieldState,
ResolvedActionState,
ResolvedFormfieldState,
ResolvedThemeView,
HueStep,
} from "./index"
export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
const textAction = actions((variant, state) => current().color.text.action[variant][state])
const backgroundAction = actions((variant, state) => current().color.background.action[variant][state])
const text = Object.assign(() => current().color.text.default, {
subdued: () => current().color.text.subdued,
const textAction = actions((variant, state) => current().text.action[variant][state])
const backgroundAction = actions((variant, state) => current().background.action[variant][state])
const textFormfield = formfield((state) => current().text.formfield[state])
const backgroundFormfield = formfield((state) => current().background.formfield[state])
const hue = {
gray: (step: HueStep) => current().hue.gray[step],
red: (step: HueStep) => current().hue.red[step],
orange: (step: HueStep) => current().hue.orange[step],
yellow: (step: HueStep) => current().hue.yellow[step],
green: (step: HueStep) => current().hue.green[step],
cyan: (step: HueStep) => current().hue.cyan[step],
blue: (step: HueStep) => current().hue.blue[step],
purple: (step: HueStep) => current().hue.purple[step],
accent: (step: HueStep) => current().hue.accent[step],
neutral: (step: HueStep) => current().hue.neutral[step],
}
const text = Object.assign(() => current().text.default, {
subdued: () => current().text.subdued,
action: textAction,
formfield: textFormfield,
feedback: {
error: feedbackText("error"),
warning: feedbackText("warning"),
@ -15,81 +38,84 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
info: feedbackText("info"),
},
})
const background = Object.assign(() => current().color.background.default, {
const background = Object.assign(() => current().background.default, {
surface: {
offset: () => current().background.surface.offset,
overlay: () => current().background.surface.overlay,
},
action: backgroundAction,
formfield: backgroundFormfield,
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,
error: () => current().background.feedback.error.default,
warning: () => current().background.feedback.warning.default,
success: () => current().background.feedback.success.default,
info: () => current().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,
const markdown = Object.assign(() => current().markdown.text, {
heading: () => current().markdown.heading,
link: () => current().markdown.link,
linkText: () => current().markdown.linkText,
code: () => current().markdown.code,
blockQuote: () => current().markdown.blockQuote,
emphasis: () => current().markdown.emphasis,
strong: () => current().markdown.strong,
horizontalRule: () => current().markdown.horizontalRule,
listItem: () => current().markdown.listItem,
listEnumeration: () => current().markdown.listEnumeration,
image: () => current().markdown.image,
imageText: () => current().markdown.imageText,
codeBlock: () => current().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 Object.assign(() => current().text.feedback[kind].default, {
subdued: () => current().text.feedback[kind].subdued,
})
}
return {
hue: () => current().hue,
color: {
text,
background,
border: () => current().color.border.default,
scrollbar: () => current().color.scrollbar.default,
diff: {
hue,
text,
background,
border: () => current().border.default,
scrollbar: () => current().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,
added: () => current().diff.text.added,
removed: () => current().diff.text.removed,
context: () => current().diff.text.context,
hunkHeader: () => current().diff.text.hunkHeader,
},
background: {
added: () => current().color.diff.background.added,
removed: () => current().color.diff.background.removed,
context: () => current().color.diff.background.context,
added: () => current().diff.background.added,
removed: () => current().diff.background.removed,
context: () => current().diff.background.context,
},
highlight: {
added: () => current().color.diff.highlight.added,
removed: () => current().color.diff.highlight.removed,
added: () => current().diff.highlight.added,
removed: () => current().diff.highlight.removed,
},
lineNumber: {
text: () => current().color.diff.lineNumber.text,
text: () => current().diff.lineNumber.text,
background: {
added: () => current().color.diff.lineNumber.background.added,
removed: () => current().color.diff.lineNumber.background.removed,
added: () => current().diff.lineNumber.background.added,
removed: () => current().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,
syntax: {
comment: () => current().syntax.comment,
keyword: () => current().syntax.keyword,
function: () => current().syntax.function,
variable: () => current().syntax.variable,
string: () => current().syntax.string,
number: () => current().syntax.number,
type: () => current().syntax.type,
operator: () => current().syntax.operator,
punctuation: () => current().syntax.punctuation,
},
markdown,
}
}
@ -103,4 +129,8 @@ function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RG
})
}
function formfield(get: (state: ResolvedFormfieldState) => RGBA) {
return (state: FormfieldState | "default" = "default") => get(state)
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>

View file

@ -95,8 +95,7 @@ export const DEFAULT_THEME = {
accent: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
text: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
@ -104,6 +103,13 @@ export const DEFAULT_THEME = {
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.900",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.600",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
@ -113,25 +119,36 @@ export const DEFAULT_THEME = {
},
background: {
default: "$hue.neutral.100",
surface: {
offset: "$hue.neutral.200",
overlay: "$hue.neutral.300",
},
action: {
primary: {
default: "$hue.accent.600", $hovered: "$hue.accent.700", $pressed: "$hue.accent.800",
$selected: "$hue.accent.700", $disabled: "$hue.neutral.300",
default: "$hue.accent.600", $focused: "$hue.accent.700", $pressed: "$hue.accent.800",
$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",
default: "$hue.neutral.200", $focused: "$hue.neutral.300", $pressed: "$hue.neutral.400",
$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",
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
$disabled: "$hue.neutral.300",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: "$hue.neutral.300" },
@ -174,24 +191,19 @@ export const DEFAULT_THEME = {
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" } },
},
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
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" } },
},
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.500" } },
},
},
},
@ -288,8 +300,7 @@ export const DEFAULT_THEME = {
accent: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
text: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
@ -297,6 +308,13 @@ export const DEFAULT_THEME = {
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.100",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.500",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
@ -306,25 +324,36 @@ export const DEFAULT_THEME = {
},
background: {
default: "$hue.neutral.900",
surface: {
offset: "$hue.neutral.800",
overlay: "$hue.neutral.700",
},
action: {
primary: {
default: "$hue.accent.500", $hovered: "$hue.accent.600", $pressed: "$hue.accent.800",
$selected: "$hue.accent.600", $disabled: "$hue.neutral.800",
default: "$hue.accent.500", $focused: "$hue.accent.600", $pressed: "$hue.accent.800",
$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",
default: "$hue.neutral.800", $focused: "$hue.neutral.700", $pressed: "$hue.neutral.900",
$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",
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
$disabled: "$hue.neutral.800",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: "$hue.neutral.700" },
@ -367,24 +396,19 @@ export const DEFAULT_THEME = {
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" } },
},
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
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" } },
},
text: { action: { primary: { default: "$hue.neutral.900" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.400" } },
},
},
},

View file

@ -1,11 +1,12 @@
import type {
BackgroundDefinition,
FormfieldColorDefinition,
ModeDefinition,
StatefulColorDefinition,
TextDefinition,
ThemeTokensDefinition,
} from "./index"
import { ActionState } from "./schema"
import { ActionState, FormfieldState } from "./schema"
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
return {
@ -20,14 +21,10 @@ export function expandTheme<Definition extends ModeDefinition>(definition: Defin
}
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),
},
text: expandText(definition.text),
background: expandBackground(definition.background),
}
}
@ -48,8 +45,9 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
if (!definition) return
return {
...definition,
subdued: definition.subdued ?? (definition.default ? "$color.text.default" : undefined),
action: expandActions(definition.action, "color.text.action"),
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]) => {
@ -57,7 +55,7 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
kind,
{
...feedback,
subdued: feedback.subdued ?? (feedback.default ? `$color.text.feedback.${kind}.default` : undefined),
subdued: feedback.subdued ?? (feedback.default ? `$text.feedback.${kind}.default` : undefined),
},
]
}),
@ -68,7 +66,21 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined {
if (!definition) return
return { ...definition, action: expandActions(definition.action, "color.background.action") }
return {
...definition,
action: expandActions(definition.action, "background.action"),
formfield: expandFormfield(definition.formfield, "background.formfield"),
}
}
function expandFormfield(definition: FormfieldColorDefinition | undefined, path: string) {
if (!definition?.default) return definition
return {
...definition,
...Object.fromEntries(
FormfieldState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]),
),
}
}
function expandActions<Definition extends Partial<Record<string, StatefulColorDefinition>>>(

View file

@ -5,26 +5,28 @@ export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
color: {
text: {
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: {
},
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: {
},
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: {
},
syntax: {
comment: red,
keyword: red,
function: red,
@ -34,8 +36,8 @@ export function fallback(): ThemeTokensDefinition {
type: red,
operator: red,
punctuation: red,
},
markdown: {
},
markdown: {
text: red,
heading: red,
link: red,
@ -50,7 +52,6 @@ export function fallback(): ThemeTokensDefinition {
image: red,
imageText: red,
codeBlock: red,
},
},
}
}

View file

@ -4,6 +4,8 @@ export {
ActionVariant,
BaseHue,
FeedbackKind,
FormfieldState,
type FormfieldStateKey,
HueAlias,
HueStep,
MarkdownDefinition,
@ -16,6 +18,7 @@ export {
type BackgroundDefinition,
type DiffDefinition,
type FileThemeDefinition,
type FormfieldColorDefinition,
type HueDefinition,
type HueOverrideDefinition,
type MergeModeDefinition,
@ -26,5 +29,14 @@ export {
type ThemeTokensDefinition,
} from "./schema"
export type { Hue, HueScale, ResolvedActionState, ResolvedTheme, ResolvedThemeView, StatefulColor } from "./types"
export type {
FormfieldColor,
Hue,
HueScale,
ResolvedActionState,
ResolvedFormfieldState,
ResolvedTheme,
ResolvedThemeView,
StatefulColor,
} from "./types"
export { migrateV1 } from "./v1-migrate"

View file

@ -8,6 +8,7 @@ import {
ActionVariant,
BaseHue,
FeedbackKind,
FormfieldState,
HueAlias,
HueStep,
ThemeDefinition,
@ -25,11 +26,33 @@ import type {
} from "./index"
import { selectTheme, selectThemeMode } from "./select"
const decodeThemeDefinition = Schema.decodeUnknownSync(ThemeDefinition)
const decodeThemeFile = Schema.decodeUnknownSync(ThemeFile)
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
const decodeThemeFileSchema = Schema.decodeUnknownSync(ThemeFile)
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark") {
const decoded = decodeThemeFile(file)
function decodeThemeDefinition(input: unknown) {
try {
return decodeThemeDefinitionSchema(input)
} catch (error) {
throw themeDecodeError(error, "theme")
}
}
function decodeThemeFile(input: unknown, name: string) {
try {
return decodeThemeFileSchema(input)
} catch (error) {
throw themeDecodeError(error, name)
}
}
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 resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") {
const decoded = decodeThemeFile(file, name)
const selected = selectThemeMode(decoded, mode)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
@ -63,24 +86,28 @@ function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
return {
color: definition.color,
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.color?.text?.action
const contextText = override.color?.text?.action
const baseBackground = base.color?.background?.action
const contextBackground = override.color?.background?.action
const color = result["color"] as NonNullable<ThemeTokensDefinition["color"]>
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,
color: {
...color,
text: { ...color.text, action: contextualActions(baseText, contextText) },
background: { ...color.background, action: contextualActions(baseBackground, contextBackground) },
},
text: { ...text, action: contextualActions(baseText, contextText) },
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
} as ThemeTokensDefinition
}
@ -171,6 +198,7 @@ function createResolver(source: Record<string, unknown>) {
}
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)
@ -189,7 +217,7 @@ function createResolver(source: Record<string, unknown>) {
function resolvedKey(key: string) {
if (!key.startsWith("$")) return key
const state = key.slice(1)
return (ActionState.literals as readonly string[]).includes(state) ? state : key
return ([...ActionState.literals, ...FormfieldState.literals] as readonly string[]).includes(state) ? state : key
}
function read(source: Record<string, unknown>, path: string) {

View file

@ -12,10 +12,14 @@ export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
export const ActionState = Schema.Literals(["hovered", "pressed", "selected", "focused", "disabled"])
export const ActionState = Schema.Literals(["focused", "pressed", "disabled"])
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const FormfieldState = Schema.Literals(["focused", "pressed", "disabled", "selected"])
export type FormfieldState = Schema.Schema.Type<typeof FormfieldState>
export type FormfieldStateKey = `$${FormfieldState}`
export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"])
export type FeedbackKind = Schema.Schema.Type<typeof FeedbackKind>
@ -24,7 +28,11 @@ export type Mode = Schema.Schema.Type<typeof Mode>
const HexColor = Schema.String.check(Schema.isPattern(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i))
const ColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$", Schema.NonEmptyString])])
const ColorValue = Schema.Union([
HexColor,
Schema.Literal("transparent"),
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
])
const HueName = Schema.Union([BaseHue, HueAlias])
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
@ -65,14 +73,21 @@ export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinit
const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$hovered: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
})
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
const FormfieldColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
})
export type FormfieldColorDefinition = Schema.Schema.Type<typeof FormfieldColorDefinition>
const ActionColorDefinition = Schema.Struct({
primary: Schema.optional(StatefulColorDefinition),
secondary: Schema.optional(StatefulColorDefinition),
@ -92,6 +107,7 @@ const TextDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
subdued: Schema.optional(ColorValue),
action: Schema.optional(ActionColorDefinition),
formfield: Schema.optional(FormfieldColorDefinition),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(TextFeedbackDefinition),
@ -105,7 +121,14 @@ 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(FormfieldColorDefinition),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(BackgroundFeedbackDefinition),
@ -163,17 +186,13 @@ const DiffDefinition = Schema.Struct({
export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
const ThemeTokensDefinition = Schema.Struct({
color: Schema.optional(
Schema.Struct({
text: Schema.optional(TextDefinition),
background: Schema.optional(BackgroundDefinition),
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
diff: Schema.optional(DiffDefinition),
syntax: Schema.optional(SyntaxDefinition),
markdown: Schema.optional(MarkdownDefinition),
}),
),
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>

View file

@ -4,6 +4,7 @@ import type {
ActionVariant,
BaseHue,
FeedbackKind,
FormfieldState,
HueAlias,
HueStep,
MarkdownToken,
@ -12,43 +13,49 @@ import type {
} from "./schema"
export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = "default" | FormfieldState
export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = Readonly<Record<ResolvedFormfieldState, RGBA>>
export type ResolvedThemeView = {
readonly hue: Hue
readonly color: {
readonly text: {
readonly default: RGBA
readonly subdued: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
}
readonly background: {
readonly default: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
}
readonly border: { readonly default: RGBA }
readonly scrollbar: { readonly default: RGBA }
readonly diff: {
readonly text: {
readonly added: RGBA
readonly removed: RGBA
readonly context: RGBA
readonly hunkHeader: RGBA
}
readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
readonly lineNumber: {
readonly text: RGBA
readonly background: { readonly added: RGBA; readonly removed: RGBA }
}
}
readonly syntax: Readonly<Record<SyntaxToken, RGBA>>
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
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 & {

View file

@ -1,5 +1,6 @@
import { RGBA } from "@opentui/core"
import type { Theme, ThemeJson } from "../index"
import { DEFAULT_THEME } from "./defaults"
import type { ThemeFile } from "./index"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
@ -7,109 +8,141 @@ type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItem
export function migrateV1(theme: ThemeJson): ThemeFile {
return {
version: 2,
light: migrateMode(resolveV1(theme, "light")),
dark: migrateMode(resolveV1(theme, "dark")),
standalone: true,
light: migrateMode(resolveV1(theme, "light"), "light"),
dark: migrateMode(resolveV1(theme, "dark"), "dark"),
}
}
function migrateMode(theme: Theme): ThemeFile["light"] {
function migrateMode(theme: Theme, mode: "light" | "dark"): 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: {
hue: {
...DEFAULT_THEME[mode].hue,
accent: hueScale(theme.secondary),
},
text: {
default: color("text"),
subdued: color("textMuted"),
action: {
primary: {
default: selected,
$disabled: color("textMuted"),
$focused: selected,
},
secondary: {
default: "$text.default",
$disabled: color("textMuted"),
},
destructive: { default: destructive, $disabled: color("textMuted") },
},
formfield: {
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") },
},
$focused: color("primary"),
$pressed: color("primary"),
$disabled: color("textMuted"),
$selected: color("primary"),
},
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"),
feedback: {
error: { default: color("error") },
warning: { default: color("warning") },
success: { default: color("success") },
info: { default: color("info") },
},
},
"@context:elevated": { color: { background: { default: color("backgroundPanel") } } },
"@context:overlay": { color: { background: { default: color("backgroundMenu") } } },
background: {
default: color("background"),
surface: {
offset: color("backgroundPanel"),
overlay: color("backgroundMenu"),
},
action: {
primary: { default: color("primary"), $focused: color("primary") },
secondary: {
default: "$background.default",
$focused: color("backgroundElement"),
$pressed: color("backgroundElement"),
},
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: {
default: color("primary"),
$focused: color("primary"),
},
secondary: {
default: "$background.surface.offset",
},
},
},
},
"@context:overlay": { background: { default: "$background.surface.overlay" } },
}
}
@ -159,17 +192,17 @@ function selectedForeground(theme: Theme, background: RGBA) {
: RGBA.fromInts(255, 255, 255)
}
function accentScale(accent: RGBA) {
function hueScale(color: 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),
100: mix(color, 255, 0.8),
200: mix(color, 255, 0.6),
300: mix(color, 255, 0.4),
400: mix(color, 255, 0.2),
500: hex(color),
600: mix(color, 0, 0.15),
700: mix(color, 0, 0.3),
800: mix(color, 0, 0.45),
900: mix(color, 0, 0.6),
}
}
@ -195,8 +228,22 @@ function hexInts(r: number, g: number, b: number, a: number) {
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",
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
]
return RGBA.fromHex(colors[code] ?? "#000000")
}