feat(tui): add V2 theme system (#36950)
This commit is contained in:
parent
563f6a65de
commit
9a25673c37
16 changed files with 1838 additions and 0 deletions
106
packages/tui/src/theme/v2/component.ts
Normal file
106
packages/tui/src/theme/v2/component.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ActionState, ActionVariant, ResolvedActionState, ResolvedThemeView } from "./index"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
|
||||
const textAction = actions((variant, state) => current().color.text.action[variant][state])
|
||||
const backgroundAction = actions((variant, state) => current().color.background.action[variant][state])
|
||||
const text = Object.assign(() => current().color.text.default, {
|
||||
subdued: () => current().color.text.subdued,
|
||||
action: textAction,
|
||||
feedback: {
|
||||
error: feedbackText("error"),
|
||||
warning: feedbackText("warning"),
|
||||
success: feedbackText("success"),
|
||||
info: feedbackText("info"),
|
||||
},
|
||||
})
|
||||
const background = Object.assign(() => current().color.background.default, {
|
||||
action: backgroundAction,
|
||||
feedback: {
|
||||
error: () => current().color.background.feedback.error.default,
|
||||
warning: () => current().color.background.feedback.warning.default,
|
||||
success: () => current().color.background.feedback.success.default,
|
||||
info: () => current().color.background.feedback.info.default,
|
||||
},
|
||||
})
|
||||
const markdown = Object.assign(() => current().color.markdown.text, {
|
||||
heading: () => current().color.markdown.heading,
|
||||
link: () => current().color.markdown.link,
|
||||
linkText: () => current().color.markdown.linkText,
|
||||
code: () => current().color.markdown.code,
|
||||
blockQuote: () => current().color.markdown.blockQuote,
|
||||
emphasis: () => current().color.markdown.emphasis,
|
||||
strong: () => current().color.markdown.strong,
|
||||
horizontalRule: () => current().color.markdown.horizontalRule,
|
||||
listItem: () => current().color.markdown.listItem,
|
||||
listEnumeration: () => current().color.markdown.listEnumeration,
|
||||
image: () => current().color.markdown.image,
|
||||
imageText: () => current().color.markdown.imageText,
|
||||
codeBlock: () => current().color.markdown.codeBlock,
|
||||
})
|
||||
|
||||
function feedbackText(kind: "error" | "warning" | "success" | "info") {
|
||||
return Object.assign(() => current().color.text.feedback[kind].default, {
|
||||
subdued: () => current().color.text.feedback[kind].subdued,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
hue: () => current().hue,
|
||||
color: {
|
||||
text,
|
||||
background,
|
||||
border: () => current().color.border.default,
|
||||
scrollbar: () => current().color.scrollbar.default,
|
||||
diff: {
|
||||
text: {
|
||||
added: () => current().color.diff.text.added,
|
||||
removed: () => current().color.diff.text.removed,
|
||||
context: () => current().color.diff.text.context,
|
||||
hunkHeader: () => current().color.diff.text.hunkHeader,
|
||||
},
|
||||
background: {
|
||||
added: () => current().color.diff.background.added,
|
||||
removed: () => current().color.diff.background.removed,
|
||||
context: () => current().color.diff.background.context,
|
||||
},
|
||||
highlight: {
|
||||
added: () => current().color.diff.highlight.added,
|
||||
removed: () => current().color.diff.highlight.removed,
|
||||
},
|
||||
lineNumber: {
|
||||
text: () => current().color.diff.lineNumber.text,
|
||||
background: {
|
||||
added: () => current().color.diff.lineNumber.background.added,
|
||||
removed: () => current().color.diff.lineNumber.background.removed,
|
||||
},
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: () => current().color.syntax.comment,
|
||||
keyword: () => current().color.syntax.keyword,
|
||||
function: () => current().color.syntax.function,
|
||||
variable: () => current().color.syntax.variable,
|
||||
string: () => current().color.syntax.string,
|
||||
number: () => current().color.syntax.number,
|
||||
type: () => current().color.syntax.type,
|
||||
operator: () => current().color.syntax.operator,
|
||||
punctuation: () => current().color.syntax.punctuation,
|
||||
},
|
||||
markdown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) {
|
||||
const action = (variant: ActionVariant) => (state: ActionState | "default" = "default") => get(variant, state)
|
||||
const primary = action("primary")
|
||||
return Object.assign(primary, {
|
||||
primary,
|
||||
secondary: action("secondary"),
|
||||
destructive: action("destructive"),
|
||||
})
|
||||
}
|
||||
|
||||
export type ComponentTheme = ReturnType<typeof createComponentTheme>
|
||||
391
packages/tui/src/theme/v2/defaults.ts
Normal file
391
packages/tui/src/theme/v2/defaults.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import type { ThemeFile } from "./index"
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
light: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
color: {
|
||||
text: {
|
||||
default: "$hue.neutral.900",
|
||||
subdued: "$hue.neutral.600",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
|
||||
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.100",
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.accent.600", $hovered: "$hue.accent.700", $pressed: "$hue.accent.800",
|
||||
$selected: "$hue.accent.700", $disabled: "$hue.neutral.300",
|
||||
},
|
||||
secondary: {
|
||||
default: "$hue.neutral.200", $hovered: "$hue.neutral.300", $pressed: "$hue.neutral.400",
|
||||
$selected: "$hue.neutral.300", $disabled: "$hue.neutral.200",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700", $disabled: "$hue.neutral.300",
|
||||
},
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$color.background.default" },
|
||||
warning: { default: "$color.background.default" },
|
||||
success: { default: "$color.background.default" },
|
||||
info: { default: "$color.background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.300" },
|
||||
scrollbar: { default: "$hue.neutral.400" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900",
|
||||
hunkHeader: "$hue.purple.600",
|
||||
},
|
||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.600",
|
||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.600",
|
||||
keyword: "$hue.purple.600",
|
||||
function: "$hue.accent.600",
|
||||
variable: "$hue.neutral.900",
|
||||
string: "$hue.green.700",
|
||||
number: "$hue.yellow.800",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.600",
|
||||
punctuation: "$hue.neutral.900",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.900",
|
||||
heading: "$hue.purple.600",
|
||||
link: "$hue.accent.600",
|
||||
linkText: "$hue.cyan.600",
|
||||
code: "$hue.green.700",
|
||||
blockQuote: "$hue.neutral.600",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.900",
|
||||
horizontalRule: "$hue.neutral.300",
|
||||
listItem: "$hue.accent.600",
|
||||
listEnumeration: "$hue.cyan.600",
|
||||
image: "$hue.accent.600",
|
||||
imageText: "$hue.cyan.600",
|
||||
codeBlock: "$hue.neutral.900",
|
||||
},
|
||||
},
|
||||
"@context:elevated": {
|
||||
color: {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$hue.neutral.200",
|
||||
action: { primary: { default: "$hue.accent.500" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
color: {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$hue.neutral.300",
|
||||
action: { primary: { default: "$hue.accent.500" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
color: {
|
||||
text: {
|
||||
default: "$hue.neutral.100",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
|
||||
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.900",
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.accent.500", $hovered: "$hue.accent.600", $pressed: "$hue.accent.800",
|
||||
$selected: "$hue.accent.600", $disabled: "$hue.neutral.800",
|
||||
},
|
||||
secondary: {
|
||||
default: "$hue.neutral.800", $hovered: "$hue.neutral.700", $pressed: "$hue.neutral.900",
|
||||
$selected: "$hue.neutral.700", $disabled: "$hue.neutral.900",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700", $disabled: "$hue.neutral.800",
|
||||
},
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$color.background.default" },
|
||||
warning: { default: "$color.background.default" },
|
||||
success: { default: "$color.background.default" },
|
||||
info: { default: "$color.background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.700" },
|
||||
scrollbar: { default: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.400",
|
||||
keyword: "$hue.purple.400",
|
||||
function: "$hue.accent.400",
|
||||
variable: "$hue.neutral.100",
|
||||
string: "$hue.green.300",
|
||||
number: "$hue.yellow.200",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.100",
|
||||
heading: "$hue.purple.400",
|
||||
link: "$hue.accent.400",
|
||||
linkText: "$hue.cyan.400",
|
||||
code: "$hue.green.300",
|
||||
blockQuote: "$hue.neutral.400",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.100",
|
||||
horizontalRule: "$hue.neutral.700",
|
||||
listItem: "$hue.accent.400",
|
||||
listEnumeration: "$hue.cyan.400",
|
||||
image: "$hue.accent.400",
|
||||
imageText: "$hue.cyan.400",
|
||||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
},
|
||||
"@context:elevated": {
|
||||
color: {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$hue.neutral.800",
|
||||
action: { primary: { default: "$hue.accent.400" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
color: {
|
||||
text: { action: { primary: { default: "$hue.neutral.900" } } },
|
||||
background: {
|
||||
default: "$hue.neutral.700",
|
||||
action: { primary: { default: "$hue.accent.400" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ThemeFile
|
||||
97
packages/tui/src/theme/v2/expand.ts
Normal file
97
packages/tui/src/theme/v2/expand.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type {
|
||||
BackgroundDefinition,
|
||||
ModeDefinition,
|
||||
StatefulColorDefinition,
|
||||
TextDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index"
|
||||
import { ActionState } from "./schema"
|
||||
|
||||
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
|
||||
return {
|
||||
...definition,
|
||||
...expandTokens(definition),
|
||||
...Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
|
||||
if (!definition.color) return { ...definition }
|
||||
return {
|
||||
...definition,
|
||||
color: {
|
||||
...definition.color,
|
||||
text: expandText(definition.color.text),
|
||||
background: expandBackground(definition.color.background),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTheme(...values: unknown[]): Record<string, unknown> {
|
||||
return values.reduce<Record<string, unknown>>((result, value) => {
|
||||
if (!isRecord(value)) return result
|
||||
return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
|
||||
if (item === undefined || key === "mergeMode") return next
|
||||
return {
|
||||
...next,
|
||||
[key]: isRecord(item) ? mergeTheme(next[key], item) : item,
|
||||
}
|
||||
}, result)
|
||||
}, {})
|
||||
}
|
||||
|
||||
function expandText(definition: TextDefinition | undefined): TextDefinition | undefined {
|
||||
if (!definition) return
|
||||
return {
|
||||
...definition,
|
||||
subdued: definition.subdued ?? (definition.default ? "$color.text.default" : undefined),
|
||||
action: expandActions(definition.action, "color.text.action"),
|
||||
feedback: definition.feedback
|
||||
? Object.fromEntries(
|
||||
Object.entries(definition.feedback).map(([kind, feedback]) => {
|
||||
return [
|
||||
kind,
|
||||
{
|
||||
...feedback,
|
||||
subdued: feedback.subdued ?? (feedback.default ? `$color.text.feedback.${kind}.default` : undefined),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined {
|
||||
if (!definition) return
|
||||
return { ...definition, action: expandActions(definition.action, "color.background.action") }
|
||||
}
|
||||
|
||||
function expandActions<Definition extends Partial<Record<string, StatefulColorDefinition>>>(
|
||||
definition: Definition | undefined,
|
||||
path: string,
|
||||
) {
|
||||
if (!definition) return
|
||||
return Object.fromEntries(
|
||||
Object.entries(definition).map(([variant, value]) => {
|
||||
if (!value?.default) return [variant, value]
|
||||
return [
|
||||
variant,
|
||||
{
|
||||
...value,
|
||||
...Object.fromEntries(
|
||||
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]),
|
||||
),
|
||||
},
|
||||
]
|
||||
}),
|
||||
) as Definition
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
56
packages/tui/src/theme/v2/fallback.ts
Normal file
56
packages/tui/src/theme/v2/fallback.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { ThemeTokensDefinition } from "./index"
|
||||
import { ActionVariant, FeedbackKind } from "./schema"
|
||||
|
||||
export function fallback(): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
color: {
|
||||
text: {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
border: { default: red },
|
||||
scrollbar: { default: red },
|
||||
diff: {
|
||||
text: { added: red, removed: red, context: red, hunkHeader: red },
|
||||
background: { added: red, removed: red, context: red },
|
||||
highlight: { added: red, removed: red },
|
||||
lineNumber: { text: red, background: { added: red, removed: red } },
|
||||
},
|
||||
syntax: {
|
||||
comment: red,
|
||||
keyword: red,
|
||||
function: red,
|
||||
variable: red,
|
||||
string: red,
|
||||
number: red,
|
||||
type: red,
|
||||
operator: red,
|
||||
punctuation: red,
|
||||
},
|
||||
markdown: {
|
||||
text: red,
|
||||
heading: red,
|
||||
link: red,
|
||||
linkText: red,
|
||||
code: red,
|
||||
blockQuote: red,
|
||||
emphasis: red,
|
||||
strong: red,
|
||||
horizontalRule: red,
|
||||
listItem: red,
|
||||
listEnumeration: red,
|
||||
image: red,
|
||||
imageText: red,
|
||||
codeBlock: red,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
30
packages/tui/src/theme/v2/index.ts
Normal file
30
packages/tui/src/theme/v2/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export {
|
||||
ActionState,
|
||||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
MarkdownDefinition,
|
||||
MarkdownToken,
|
||||
ModeDefinition,
|
||||
SyntaxDefinition,
|
||||
SyntaxToken,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
type BackgroundDefinition,
|
||||
type DiffDefinition,
|
||||
type FileThemeDefinition,
|
||||
type HueDefinition,
|
||||
type HueOverrideDefinition,
|
||||
type MergeModeDefinition,
|
||||
type Mode,
|
||||
type StatefulColorDefinition,
|
||||
type ContextKey,
|
||||
type TextDefinition,
|
||||
type ThemeTokensDefinition,
|
||||
} from "./schema"
|
||||
|
||||
export type { Hue, HueScale, ResolvedActionState, ResolvedTheme, ResolvedThemeView, StatefulColor } from "./types"
|
||||
export { migrateV1 } from "./v1-migrate"
|
||||
207
packages/tui/src/theme/v2/resolve.ts
Normal file
207
packages/tui/src/theme/v2/resolve.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_THEME } from "./defaults"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand"
|
||||
import { fallback } from "./fallback"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
} from "./schema"
|
||||
import type {
|
||||
ActionStateKey,
|
||||
HueDefinition,
|
||||
HueScale,
|
||||
ResolvedActionState,
|
||||
ResolvedTheme,
|
||||
ResolvedThemeView,
|
||||
StatefulColorDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index"
|
||||
import { selectTheme, selectThemeMode } from "./select"
|
||||
|
||||
const decodeThemeDefinition = Schema.decodeUnknownSync(ThemeDefinition)
|
||||
const decodeThemeFile = Schema.decodeUnknownSync(ThemeFile)
|
||||
|
||||
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark") {
|
||||
const decoded = decodeThemeFile(file)
|
||||
const selected = selectThemeMode(decoded, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const merged = decoded.standalone
|
||||
? mergeTheme(core, definition)
|
||||
: mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme(merged as ThemeDefinition)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
|
||||
}
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const base = tokens(definition)
|
||||
const resolved = resolveView(base, hue)
|
||||
const contexts = Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, override]) => {
|
||||
const contextual = contextualize(base, override as ThemeTokensDefinition)
|
||||
return [key, resolveView(contextual, hue)]
|
||||
}),
|
||||
)
|
||||
|
||||
return { ...resolved, contexts } as ResolvedTheme
|
||||
}
|
||||
|
||||
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
|
||||
return {
|
||||
color: definition.color,
|
||||
}
|
||||
}
|
||||
|
||||
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
|
||||
const result = mergeTheme(base, override)
|
||||
const baseText = base.color?.text?.action
|
||||
const contextText = override.color?.text?.action
|
||||
const baseBackground = base.color?.background?.action
|
||||
const contextBackground = override.color?.background?.action
|
||||
const color = result["color"] as NonNullable<ThemeTokensDefinition["color"]>
|
||||
return {
|
||||
...result,
|
||||
color: {
|
||||
...color,
|
||||
text: { ...color.text, action: contextualActions(baseText, contextText) },
|
||||
background: { ...color.background, action: contextualActions(baseBackground, contextBackground) },
|
||||
},
|
||||
} as ThemeTokensDefinition
|
||||
}
|
||||
|
||||
function contextualActions(
|
||||
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
ActionVariant.literals.map((variant) => {
|
||||
const baseVariant = base?.[variant]
|
||||
const contextVariant = context?.[variant]
|
||||
return [
|
||||
variant,
|
||||
Object.fromEntries(
|
||||
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
|
||||
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
|
||||
return [
|
||||
key ?? "default",
|
||||
(key ? contextVariant?.[key] : undefined) ??
|
||||
contextVariant?.default ??
|
||||
(key ? baseVariant?.[key] : undefined) ??
|
||||
baseVariant?.default,
|
||||
]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveView(definition: ThemeTokensDefinition, hue: ResolvedThemeView["hue"]): ResolvedThemeView {
|
||||
const source: Record<string, unknown> = { hue, ...definition }
|
||||
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue }
|
||||
}
|
||||
|
||||
function resolveHue(definition: HueDefinition) {
|
||||
const source = definition as Record<string, unknown>
|
||||
const cache = new Map<string, HueScale>()
|
||||
const expected = new Set<string>([...BaseHue.literals, ...HueAlias.literals])
|
||||
for (const name of Object.keys(source)) {
|
||||
if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`)
|
||||
}
|
||||
|
||||
function resolve(name: string, stack: string[]): HueScale {
|
||||
const hit = cache.get(name)
|
||||
if (hit) return hit
|
||||
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
|
||||
const value = source[name]
|
||||
if (typeof value === "string") {
|
||||
if ((BaseHue.literals as readonly string[]).includes(name)) throw new Error(`Base hue "${name}" must be a scale`)
|
||||
const match = /^\$hue\.([^.]+)$/.exec(value)
|
||||
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
|
||||
const result = resolve(match[1], [...stack, name])
|
||||
cache.set(name, result)
|
||||
return result
|
||||
}
|
||||
if (!isRecord(value)) throw new Error(`Hue "${name}" was not found`)
|
||||
const result = Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
const color = value[step]
|
||||
if (typeof color !== "string" || !isHex(color)) throw new Error(`Invalid hue color at "hue.${name}.${step}"`)
|
||||
return [step, RGBA.fromHex(color)]
|
||||
}),
|
||||
) as HueScale
|
||||
for (const step of Object.keys(value)) {
|
||||
if (!HueStep.literals.includes(Number(step) as HueStep)) throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
|
||||
}
|
||||
cache.set(name, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
|
||||
) as ResolvedThemeView["hue"]
|
||||
}
|
||||
|
||||
function createResolver(source: Record<string, unknown>) {
|
||||
const cache = new Map<string, RGBA>()
|
||||
|
||||
function resolve(value: unknown, path: string, stack: string[] = []): unknown {
|
||||
if (value instanceof RGBA) return value
|
||||
if (typeof value === "string") return resolveColor(value, path, stack)
|
||||
if (typeof value === "number") return value
|
||||
if (!isRecord(value)) throw new Error(`Invalid theme value at "${path}"`)
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [resolvedKey(key), resolve(item, `${path}.${key}`, stack)]),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveColor(value: string, path: string, stack: string[]) {
|
||||
if (isHex(value)) return RGBA.fromHex(value)
|
||||
if (!value.startsWith("$")) throw new Error(`Invalid color "${value}" at "${path}"`)
|
||||
const target = value.slice(1)
|
||||
const hit = cache.get(target)
|
||||
if (hit) return hit
|
||||
if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`)
|
||||
const result = resolve(read(source, target), target, [...stack, target])
|
||||
if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`)
|
||||
cache.set(target, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return (value: unknown, path: string) => resolve(value, path)
|
||||
}
|
||||
|
||||
function resolvedKey(key: string) {
|
||||
if (!key.startsWith("$")) return key
|
||||
const state = key.slice(1)
|
||||
return (ActionState.literals as readonly string[]).includes(state) ? state : key
|
||||
}
|
||||
|
||||
function read(source: Record<string, unknown>, path: string) {
|
||||
const result = path.split(".").reduce<unknown>((value, key) => (isRecord(value) ? value[key] : undefined), source)
|
||||
if (result === undefined) throw new Error(`Theme reference "$${path}" was not found`)
|
||||
return result
|
||||
}
|
||||
|
||||
function isHex(value: string) {
|
||||
return /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof RGBA)
|
||||
}
|
||||
214
packages/tui/src/theme/v2/schema.ts
Normal file
214
packages/tui/src/theme/v2/schema.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { Schema } from "effect"
|
||||
|
||||
export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900])
|
||||
export type HueStep = Schema.Schema.Type<typeof HueStep>
|
||||
|
||||
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
|
||||
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
|
||||
|
||||
export const HueAlias = Schema.Literals(["accent", "neutral"])
|
||||
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
|
||||
|
||||
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
|
||||
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
||||
|
||||
export const ActionState = Schema.Literals(["hovered", "pressed", "selected", "focused", "disabled"])
|
||||
export type ActionState = Schema.Schema.Type<typeof ActionState>
|
||||
export type ActionStateKey = `$${ActionState}`
|
||||
|
||||
export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"])
|
||||
export type FeedbackKind = Schema.Schema.Type<typeof FeedbackKind>
|
||||
|
||||
const Mode = Schema.Literals(["light", "dark"])
|
||||
export type Mode = Schema.Schema.Type<typeof Mode>
|
||||
|
||||
const HexColor = Schema.String.check(Schema.isPattern(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i))
|
||||
|
||||
const ColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$", Schema.NonEmptyString])])
|
||||
|
||||
const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
|
||||
|
||||
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
|
||||
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
|
||||
|
||||
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
|
||||
const HueAliasDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
|
||||
|
||||
const HueDefinition = Schema.Struct({
|
||||
gray: HueScaleDefinition,
|
||||
red: HueScaleDefinition,
|
||||
orange: HueScaleDefinition,
|
||||
yellow: HueScaleDefinition,
|
||||
green: HueScaleDefinition,
|
||||
cyan: HueScaleDefinition,
|
||||
blue: HueScaleDefinition,
|
||||
purple: HueScaleDefinition,
|
||||
accent: HueAliasDefinition,
|
||||
neutral: HueAliasDefinition,
|
||||
})
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
||||
|
||||
const HueOverrideDefinition = Schema.Struct({
|
||||
gray: Schema.optional(HueScaleDefinition),
|
||||
red: Schema.optional(HueScaleDefinition),
|
||||
orange: Schema.optional(HueScaleDefinition),
|
||||
yellow: Schema.optional(HueScaleDefinition),
|
||||
green: Schema.optional(HueScaleDefinition),
|
||||
cyan: Schema.optional(HueScaleDefinition),
|
||||
blue: Schema.optional(HueScaleDefinition),
|
||||
purple: Schema.optional(HueScaleDefinition),
|
||||
accent: Schema.optional(HueAliasDefinition),
|
||||
neutral: Schema.optional(HueAliasDefinition),
|
||||
})
|
||||
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
|
||||
|
||||
const StatefulColorDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
$hovered: Schema.optional(ColorValue),
|
||||
$pressed: Schema.optional(ColorValue),
|
||||
$selected: Schema.optional(ColorValue),
|
||||
$focused: Schema.optional(ColorValue),
|
||||
$disabled: Schema.optional(ColorValue),
|
||||
})
|
||||
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
|
||||
|
||||
const ActionColorDefinition = Schema.Struct({
|
||||
primary: Schema.optional(StatefulColorDefinition),
|
||||
secondary: Schema.optional(StatefulColorDefinition),
|
||||
destructive: Schema.optional(StatefulColorDefinition),
|
||||
})
|
||||
|
||||
const TextFeedbackDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
subdued: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const BackgroundFeedbackDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const TextDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
subdued: Schema.optional(ColorValue),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(TextFeedbackDefinition),
|
||||
warning: Schema.optional(TextFeedbackDefinition),
|
||||
success: Schema.optional(TextFeedbackDefinition),
|
||||
info: Schema.optional(TextFeedbackDefinition),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
|
||||
|
||||
const BackgroundDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(BackgroundFeedbackDefinition),
|
||||
warning: Schema.optional(BackgroundFeedbackDefinition),
|
||||
success: Schema.optional(BackgroundFeedbackDefinition),
|
||||
info: Schema.optional(BackgroundFeedbackDefinition),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
|
||||
|
||||
export const SyntaxToken = Schema.Literals([
|
||||
"comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation",
|
||||
])
|
||||
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
|
||||
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
|
||||
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
|
||||
|
||||
export const MarkdownToken = Schema.Literals([
|
||||
"text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem",
|
||||
"listEnumeration", "image", "imageText", "codeBlock",
|
||||
])
|
||||
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
|
||||
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
|
||||
export type MarkdownDefinition = Schema.Schema.Type<typeof MarkdownDefinition>
|
||||
|
||||
const DiffDefinition = Schema.Struct({
|
||||
text: Schema.optional(
|
||||
Schema.Struct({
|
||||
added: Schema.optional(ColorValue),
|
||||
removed: Schema.optional(ColorValue),
|
||||
context: Schema.optional(ColorValue),
|
||||
hunkHeader: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
background: Schema.optional(
|
||||
Schema.Struct({
|
||||
added: Schema.optional(ColorValue),
|
||||
removed: Schema.optional(ColorValue),
|
||||
context: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
highlight: Schema.optional(
|
||||
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
|
||||
),
|
||||
lineNumber: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(ColorValue),
|
||||
background: Schema.optional(
|
||||
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
|
||||
|
||||
const ThemeTokensDefinition = Schema.Struct({
|
||||
color: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(TextDefinition),
|
||||
background: Schema.optional(BackgroundDefinition),
|
||||
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
|
||||
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
|
||||
diff: Schema.optional(DiffDefinition),
|
||||
syntax: Schema.optional(SyntaxDefinition),
|
||||
markdown: Schema.optional(MarkdownDefinition),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
|
||||
|
||||
const ThemeDefinitionFields = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export const ThemeDefinition = ThemeDefinitionFields
|
||||
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
|
||||
|
||||
const FileThemeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
|
||||
|
||||
const MergeModeDefinition = Schema.Struct({
|
||||
mergeMode: Schema.Literal(true),
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
|
||||
export const ModeDefinition = Schema.Union([FileThemeDefinition, MergeModeDefinition])
|
||||
export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
|
||||
|
||||
const FileMetadata = {
|
||||
$schema: Schema.optional(Schema.String),
|
||||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition })
|
||||
export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>
|
||||
37
packages/tui/src/theme/v2/select.ts
Normal file
37
packages/tui/src/theme/v2/select.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { expandTheme, mergeTheme } from "./expand"
|
||||
import type {
|
||||
FileThemeDefinition,
|
||||
MergeModeDefinition,
|
||||
Mode,
|
||||
ModeDefinition,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
} from "./index"
|
||||
|
||||
export function selectTheme(
|
||||
file: Omit<ThemeFile, "light" | "dark"> & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
mode?: Mode,
|
||||
): ThemeDefinition
|
||||
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
|
||||
export function selectTheme(file: ThemeFile, mode?: Mode) {
|
||||
return selectThemeMode(file, mode).theme
|
||||
}
|
||||
|
||||
export function selectThemeMode(
|
||||
file: ThemeFile,
|
||||
mode: Mode = "light",
|
||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||
if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
const selected = file[mode]
|
||||
if (!merges(selected)) return { theme: selected, mode, expanded: false }
|
||||
|
||||
const otherMode = mode === "light" ? "dark" : "light"
|
||||
const other = file[otherMode]
|
||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode, expanded: true }
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition): definition is MergeModeDefinition {
|
||||
return "mergeMode" in definition && definition.mergeMode === true
|
||||
}
|
||||
50
packages/tui/src/theme/v2/solid.ts
Normal file
50
packages/tui/src/theme/v2/solid.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createComponentTheme, type ComponentTheme } from "./component"
|
||||
import type { ContextKey, ResolvedTheme } from "./index"
|
||||
|
||||
type ThemeRuntime = {
|
||||
readonly resolved: Accessor<ResolvedTheme>
|
||||
readonly component: ComponentTheme
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeRuntime>()
|
||||
|
||||
export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme }>) {
|
||||
const resolved = () => props.theme
|
||||
return createComponent(ThemeContext.Provider, {
|
||||
value: { resolved, component: createComponentTheme(resolved) },
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function ContextProvider(props: ParentProps<{ context: ContextKey }>) {
|
||||
const parent = runtime()
|
||||
const context = () => {
|
||||
const value = parent.resolved().contexts[props.context]
|
||||
if (!value) throw new Error(`Theme context is not defined: ${props.context}`)
|
||||
return value
|
||||
}
|
||||
context()
|
||||
return createComponent(ThemeContext.Provider, {
|
||||
value: { resolved: parent.resolved, component: createComponentTheme(context) },
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return runtime().component
|
||||
}
|
||||
|
||||
export function useResolvedTheme() {
|
||||
return runtime().resolved
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) throw new Error("Theme context must be used within a ThemeProvider")
|
||||
return context
|
||||
}
|
||||
56
packages/tui/src/theme/v2/types.ts
Normal file
56
packages/tui/src/theme/v2/types.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
import type {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
MarkdownToken,
|
||||
ContextKey,
|
||||
SyntaxToken,
|
||||
} from "./schema"
|
||||
|
||||
export type ResolvedActionState = "default" | ActionState
|
||||
export type HueScale = Readonly<Record<HueStep, RGBA>>
|
||||
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
|
||||
|
||||
export type ResolvedThemeView = {
|
||||
readonly hue: Hue
|
||||
readonly color: {
|
||||
readonly text: {
|
||||
readonly default: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
|
||||
}
|
||||
readonly background: {
|
||||
readonly default: RGBA
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
|
||||
}
|
||||
readonly border: { readonly default: RGBA }
|
||||
readonly scrollbar: { readonly default: RGBA }
|
||||
readonly diff: {
|
||||
readonly text: {
|
||||
readonly added: RGBA
|
||||
readonly removed: RGBA
|
||||
readonly context: RGBA
|
||||
readonly hunkHeader: RGBA
|
||||
}
|
||||
readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
|
||||
readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
|
||||
readonly lineNumber: {
|
||||
readonly text: RGBA
|
||||
readonly background: { readonly added: RGBA; readonly removed: RGBA }
|
||||
}
|
||||
}
|
||||
readonly syntax: Readonly<Record<SyntaxToken, RGBA>>
|
||||
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedTheme = ResolvedThemeView & {
|
||||
readonly contexts: Readonly<Partial<Record<ContextKey, ResolvedThemeView>>>
|
||||
}
|
||||
213
packages/tui/src/theme/v2/v1-migrate.ts
Normal file
213
packages/tui/src/theme/v2/v1-migrate.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import type { Theme, ThemeJson } from "../index"
|
||||
import type { ThemeFile } from "./index"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
|
||||
export function migrateV1(theme: ThemeJson): ThemeFile {
|
||||
return {
|
||||
version: 2,
|
||||
light: migrateMode(resolveV1(theme, "light")),
|
||||
dark: migrateMode(resolveV1(theme, "dark")),
|
||||
}
|
||||
}
|
||||
|
||||
function migrateMode(theme: Theme): ThemeFile["light"] {
|
||||
const color = (key: ThemeColor) => hex(theme[key])
|
||||
const selected = hex(selectedForeground(theme, theme.primary))
|
||||
const destructive = hex(selectedForeground(theme, theme.error))
|
||||
|
||||
return {
|
||||
hue: { accent: accentScale(theme.accent) },
|
||||
color: {
|
||||
text: {
|
||||
default: color("text"),
|
||||
subdued: color("textMuted"),
|
||||
action: {
|
||||
primary: { default: selected },
|
||||
secondary: {
|
||||
default: color("text"),
|
||||
$hovered: color("text"),
|
||||
$pressed: color("text"),
|
||||
$selected: color("text"),
|
||||
$focused: color("text"),
|
||||
$disabled: color("textMuted"),
|
||||
},
|
||||
destructive: { default: destructive },
|
||||
},
|
||||
feedback: {
|
||||
error: { default: color("error") },
|
||||
warning: { default: color("warning") },
|
||||
success: { default: color("success") },
|
||||
info: { default: color("info") },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: color("background"),
|
||||
action: {
|
||||
primary: { default: color("primary") },
|
||||
secondary: {
|
||||
default: color("backgroundMenu"),
|
||||
$hovered: color("backgroundElement"),
|
||||
$pressed: color("backgroundElement"),
|
||||
$selected: color("backgroundMenu"),
|
||||
$focused: color("backgroundElement"),
|
||||
$disabled: color("backgroundMenu"),
|
||||
},
|
||||
destructive: { default: color("error") },
|
||||
},
|
||||
},
|
||||
border: { default: color("border") },
|
||||
scrollbar: { default: color("borderActive") },
|
||||
diff: {
|
||||
text: {
|
||||
added: color("diffAdded"),
|
||||
removed: color("diffRemoved"),
|
||||
context: color("diffContext"),
|
||||
hunkHeader: color("diffHunkHeader"),
|
||||
},
|
||||
background: {
|
||||
added: color("diffAddedBg"),
|
||||
removed: color("diffRemovedBg"),
|
||||
context: color("diffContextBg"),
|
||||
},
|
||||
highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") },
|
||||
lineNumber: {
|
||||
text: color("diffLineNumber"),
|
||||
background: {
|
||||
added: color("diffAddedLineNumberBg"),
|
||||
removed: color("diffRemovedLineNumberBg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: color("syntaxComment"),
|
||||
keyword: color("syntaxKeyword"),
|
||||
function: color("syntaxFunction"),
|
||||
variable: color("syntaxVariable"),
|
||||
string: color("syntaxString"),
|
||||
number: color("syntaxNumber"),
|
||||
type: color("syntaxType"),
|
||||
operator: color("syntaxOperator"),
|
||||
punctuation: color("syntaxPunctuation"),
|
||||
},
|
||||
markdown: {
|
||||
text: color("markdownText"),
|
||||
heading: color("markdownHeading"),
|
||||
link: color("markdownLink"),
|
||||
linkText: color("markdownLinkText"),
|
||||
code: color("markdownCode"),
|
||||
blockQuote: color("markdownBlockQuote"),
|
||||
emphasis: color("markdownEmph"),
|
||||
strong: color("markdownStrong"),
|
||||
horizontalRule: color("markdownHorizontalRule"),
|
||||
listItem: color("markdownListItem"),
|
||||
listEnumeration: color("markdownListEnumeration"),
|
||||
image: color("markdownImage"),
|
||||
imageText: color("markdownImageText"),
|
||||
codeBlock: color("markdownCodeBlock"),
|
||||
},
|
||||
},
|
||||
"@context:elevated": { color: { background: { default: color("backgroundPanel") } } },
|
||||
"@context:overlay": { color: { background: { default: color("backgroundMenu") } } },
|
||||
}
|
||||
}
|
||||
|
||||
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
function resolveColor(value: unknown, chain: string[] = []): RGBA {
|
||||
if (value instanceof RGBA) return value
|
||||
if (typeof value === "string") {
|
||||
if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
if (value.startsWith("#")) return RGBA.fromHex(value)
|
||||
if (chain.includes(value)) throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
|
||||
const next = defs[value] ?? theme.theme[value as ThemeColor]
|
||||
if (next === undefined) throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||
return resolveColor(next, [...chain, value])
|
||||
}
|
||||
if (typeof value === "number") return ansi(value)
|
||||
if (!value || typeof value !== "object" || !(mode in value)) throw new Error("Invalid V1 theme color")
|
||||
return resolveColor((value as Record<"dark" | "light", unknown>)[mode], chain)
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
resolved.selectedListItemText = hasSelectedListItemText
|
||||
? resolveColor(theme.theme.selectedListItemText)
|
||||
: resolved.background
|
||||
resolved.backgroundMenu = theme.theme.backgroundMenu
|
||||
? resolveColor(theme.theme.backgroundMenu)
|
||||
: resolved.backgroundElement
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
_hasSelectedListItemText: hasSelectedListItemText,
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
} as Theme
|
||||
}
|
||||
|
||||
function selectedForeground(theme: Theme, background: RGBA) {
|
||||
if (theme._hasSelectedListItemText) return theme.selectedListItemText
|
||||
if (theme.background.a !== 0) return theme.background
|
||||
return 0.299 * background.r + 0.587 * background.g + 0.114 * background.b > 0.5
|
||||
? RGBA.fromInts(0, 0, 0)
|
||||
: RGBA.fromInts(255, 255, 255)
|
||||
}
|
||||
|
||||
function accentScale(accent: RGBA) {
|
||||
return {
|
||||
100: mix(accent, 255, 0.66),
|
||||
200: mix(accent, 255, 0.33),
|
||||
300: hex(accent),
|
||||
400: mix(accent, 0, 0.1),
|
||||
500: mix(accent, 0, 0.2),
|
||||
600: mix(accent, 0, 0.3),
|
||||
700: mix(accent, 0, 0.4),
|
||||
800: mix(accent, 0, 0.5),
|
||||
900: mix(accent, 0, 0.6),
|
||||
}
|
||||
}
|
||||
|
||||
function mix(color: RGBA, target: number, amount: number) {
|
||||
const [r, g, b, a] = color.toInts()
|
||||
return hexInts(
|
||||
Math.round(r + (target - r) * amount),
|
||||
Math.round(g + (target - g) * amount),
|
||||
Math.round(b + (target - b) * amount),
|
||||
a,
|
||||
)
|
||||
}
|
||||
|
||||
function hex(color: RGBA) {
|
||||
return hexInts(...color.toInts())
|
||||
}
|
||||
|
||||
function hexInts(r: number, g: number, b: number, a: number) {
|
||||
const byte = (value: number) => value.toString(16).padStart(2, "0")
|
||||
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
||||
}
|
||||
|
||||
function ansi(code: number) {
|
||||
if (code < 16) {
|
||||
const colors = [
|
||||
"#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
|
||||
"#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
|
||||
]
|
||||
return RGBA.fromHex(colors[code] ?? "#000000")
|
||||
}
|
||||
if (code < 232) {
|
||||
const index = code - 16
|
||||
const value = (part: number) => (part === 0 ? 0 : part * 40 + 55)
|
||||
return RGBA.fromInts(value(Math.floor(index / 36)), value(Math.floor(index / 6) % 6), value(index % 6))
|
||||
}
|
||||
if (code < 256) {
|
||||
const gray = (code - 232) * 10 + 8
|
||||
return RGBA.fromInts(gray, gray, gray)
|
||||
}
|
||||
return RGBA.fromInts(0, 0, 0)
|
||||
}
|
||||
35
packages/tui/test/theme/v2/component.test.ts
Normal file
35
packages/tui/test/theme/v2/component.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { createComponentTheme } from "../../../src/theme/v2/component"
|
||||
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
|
||||
import { resolveTheme } from "../../../src/theme/v2/resolve"
|
||||
import { selectTheme } from "../../../src/theme/v2/select"
|
||||
import type { ContextKey } from "../../../src/theme/v2"
|
||||
|
||||
test("provides reactive property, variant, state, and context accessors", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
const [context, setContext] = createSignal<ContextKey>()
|
||||
const theme = createComponentTheme(() => {
|
||||
const key = context()
|
||||
return key ? resolved().contexts[key] ?? resolved() : resolved()
|
||||
})
|
||||
|
||||
expect(theme.color.text()).toBe(resolved().color.text.default)
|
||||
expect(theme.color.text.subdued()).toBe(resolved().color.text.subdued)
|
||||
expect(theme.color.text.action()).toBe(resolved().color.text.action.primary.default)
|
||||
expect(theme.color.text.action.primary("pressed")).toBe(resolved().color.text.action.primary.pressed)
|
||||
expect(theme.color.background.action.secondary("disabled")).toBe(
|
||||
resolved().color.background.action.secondary.disabled,
|
||||
)
|
||||
expect(theme.color.scrollbar()).toBe(resolved().color.scrollbar.default)
|
||||
expect(theme.color.diff.text.added()).toBe(resolved().color.diff.text.added)
|
||||
|
||||
setContext("@context:elevated")
|
||||
expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
|
||||
expect(theme.color.background.action.primary("selected")).toBe(
|
||||
resolved().contexts["@context:elevated"]!.color.background.action.primary.selected,
|
||||
)
|
||||
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
|
||||
})
|
||||
200
packages/tui/test/theme/v2/resolve.test.ts
Normal file
200
packages/tui/test/theme/v2/resolve.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
|
||||
import type { ThemeDefinition } from "../../../src/theme/v2"
|
||||
import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve"
|
||||
import { selectTheme } from "../../../src/theme/v2/select"
|
||||
|
||||
const light = selectTheme(DEFAULT_THEME, "light")
|
||||
const dark = selectTheme(DEFAULT_THEME, "dark")
|
||||
|
||||
test("resolves independent definitions and hue aliases", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
|
||||
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
|
||||
expect(lightTheme.color.text.default).toBeInstanceOf(RGBA)
|
||||
expect(darkTheme.color.background.default).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.color.syntax.keyword).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.color.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(lightTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
|
||||
lightTheme.hue.accent[500],
|
||||
)
|
||||
expect(lightTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
|
||||
lightTheme.hue.neutral[100],
|
||||
)
|
||||
expect(lightTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
|
||||
lightTheme.hue.accent[500],
|
||||
)
|
||||
expect(lightTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
|
||||
lightTheme.hue.neutral[100],
|
||||
)
|
||||
expect(darkTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
|
||||
darkTheme.hue.accent[400],
|
||||
)
|
||||
expect(darkTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
|
||||
darkTheme.hue.neutral[100],
|
||||
)
|
||||
expect(darkTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
|
||||
darkTheme.hue.accent[400],
|
||||
)
|
||||
expect(darkTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
|
||||
darkTheme.hue.neutral[900],
|
||||
)
|
||||
})
|
||||
|
||||
test("merges partial files with the selected OpenCode defaults", () => {
|
||||
const theme = resolveThemeFile(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
hue: light.hue,
|
||||
color: { text: { default: "#123456" } },
|
||||
},
|
||||
dark: { hue: dark.hue },
|
||||
},
|
||||
"light",
|
||||
)
|
||||
|
||||
expect(theme.color.text.default.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(theme.color.text.subdued.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(theme.color.background.action.destructive.pressed).toBeInstanceOf(RGBA)
|
||||
})
|
||||
|
||||
test("expands user structural fallbacks before merging defaults", () => {
|
||||
const expanded = resolveThemeFile(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
hue: light.hue,
|
||||
color: { background: { action: { primary: { default: "#123456" } } } },
|
||||
},
|
||||
dark: { hue: dark.hue },
|
||||
},
|
||||
"light",
|
||||
)
|
||||
const isolatedState = resolveThemeFile(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
hue: light.hue,
|
||||
color: { background: { action: { primary: { $pressed: "#654321" } } } },
|
||||
},
|
||||
dark: { hue: dark.hue },
|
||||
},
|
||||
"light",
|
||||
)
|
||||
|
||||
expect(expanded.color.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(isolatedState.color.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
|
||||
expect(isolatedState.color.background.action.primary.hovered.toInts()).toEqual(
|
||||
resolveTheme(light).color.background.action.primary.hovered.toInts(),
|
||||
)
|
||||
})
|
||||
|
||||
test("standalone themes skip OpenCode defaults and use the red core fallback", () => {
|
||||
const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const
|
||||
const lightTheme = resolveThemeFile(file, "light")
|
||||
const darkTheme = resolveThemeFile(file, "dark")
|
||||
|
||||
expect(lightTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
expect(lightTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
expect(darkTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
expect(darkTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
})
|
||||
|
||||
test("uses defaults for the selected mode when it merges the other mode", () => {
|
||||
const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark")
|
||||
expect(theme.color.background.default.toInts()).toEqual(resolveTheme(dark).color.background.default.toInts())
|
||||
})
|
||||
|
||||
test("resolves matched action variants and states", () => {
|
||||
const theme = resolveTheme(light)
|
||||
|
||||
expect(theme.color.text.action.primary.pressed).toBeInstanceOf(RGBA)
|
||||
expect(theme.color.background.action.primary.pressed).toBeInstanceOf(RGBA)
|
||||
expect(theme.color.text.action.secondary.default).toBeInstanceOf(RGBA)
|
||||
expect(theme.color.background.action.destructive.disabled).toBeInstanceOf(RGBA)
|
||||
})
|
||||
|
||||
test("context overrides rewire semantic references and apply state precedence", () => {
|
||||
const definition = override(light, {
|
||||
color: {
|
||||
text: {
|
||||
default: "#111111",
|
||||
action: {
|
||||
primary: { default: "$color.text.default", $pressed: "#222222" },
|
||||
secondary: { default: "$color.text.default" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"@context:elevated": {
|
||||
color: {
|
||||
text: {
|
||||
default: "#333333",
|
||||
action: { primary: { default: "#444444", $selected: "#555555" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const theme = resolveTheme(definition)
|
||||
const overlay = theme.contexts["@context:elevated"]!
|
||||
|
||||
expect(overlay.color.text.default.toInts()).toEqual([51, 51, 51, 255])
|
||||
expect(overlay.color.text.action.secondary.default.toInts()).toEqual([51, 51, 51, 255])
|
||||
expect(overlay.color.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
|
||||
expect(overlay.color.text.action.primary.selected.toInts()).toEqual([85, 85, 85, 255])
|
||||
})
|
||||
|
||||
test("rejects missing, base, and contextual reference cycles", () => {
|
||||
expect(() => resolveTheme(override(light, { color: { text: { default: "$missing.color" } } }))).toThrow(
|
||||
'Theme reference "$missing.color" was not found',
|
||||
)
|
||||
expect(() =>
|
||||
resolveTheme(
|
||||
override(light, {
|
||||
color: { text: { default: "$color.text.subdued", subdued: "$color.text.default" } },
|
||||
}),
|
||||
),
|
||||
).toThrow("Circular theme reference")
|
||||
expect(() =>
|
||||
resolveTheme(
|
||||
override(light, {
|
||||
"@context:elevated": { color: { text: { default: "$color.text.default" } } },
|
||||
}),
|
||||
),
|
||||
).toThrow("Circular theme reference")
|
||||
})
|
||||
|
||||
test("validates complete hues, resolved groups, and hue-only syntax", () => {
|
||||
expect(() =>
|
||||
resolveTheme(
|
||||
{
|
||||
...light,
|
||||
hue: { ...light.hue, accent: "$hue.missing" },
|
||||
} as unknown as ThemeDefinition,
|
||||
),
|
||||
).toThrow("$hue.missing")
|
||||
expect(() =>
|
||||
resolveTheme({
|
||||
...light,
|
||||
color: { ...light.color, syntax: { ...light.color?.syntax, keyword: "$color.text.default" } },
|
||||
} as unknown as ThemeDefinition),
|
||||
).toThrow("$color.text.default")
|
||||
})
|
||||
|
||||
function override(base: ThemeDefinition, value: Partial<ThemeDefinition>) {
|
||||
return merge(base, value) as ThemeDefinition
|
||||
}
|
||||
|
||||
function merge(...values: unknown[]): Record<string, unknown> {
|
||||
return values.reduce<Record<string, unknown>>((result, value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return result
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (item === undefined) continue
|
||||
result[key] = item && typeof item === "object" && !Array.isArray(item) ? merge(result[key], item) : item
|
||||
}
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
37
packages/tui/test/theme/v2/select.test.ts
Normal file
37
packages/tui/test/theme/v2/select.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
|
||||
import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select"
|
||||
|
||||
const hue = {} as HueDefinition
|
||||
const light = { hue, color: { text: { default: "#111111", subdued: "#222222" } } } satisfies ThemeDefinition
|
||||
const dark = { hue, color: { text: { default: "#eeeeee", subdued: "#dddddd" } } } satisfies ThemeDefinition
|
||||
|
||||
test("requires and selects independent light and dark themes", () => {
|
||||
const file = { version: 2, light, dark } satisfies ThemeFile
|
||||
expect(selectTheme(file)).toBe(light)
|
||||
expect(selectTheme(file, "light")).toBe(light)
|
||||
expect(selectTheme(file, "dark")).toBe(dark)
|
||||
expect(selectThemeMode(file, "dark").mode).toBe("dark")
|
||||
})
|
||||
|
||||
test("merges an expanded mode override over the other mode", () => {
|
||||
const file = {
|
||||
version: 2,
|
||||
light,
|
||||
dark: { mergeMode: true, color: { text: { default: "#ffffff" } } },
|
||||
} satisfies ThemeFile
|
||||
const selected = selectTheme(file, "dark")
|
||||
|
||||
expect(selected.hue).toBeDefined()
|
||||
expect(selected.color?.text?.default).toBe("#ffffff")
|
||||
expect(selected.color?.text?.subdued).toBe("$color.text.default")
|
||||
})
|
||||
|
||||
test("rejects mutual mode merging", () => {
|
||||
const file = {
|
||||
version: 2,
|
||||
light: { mergeMode: true },
|
||||
dark: { mergeMode: true },
|
||||
} satisfies ThemeFile
|
||||
expect(() => selectTheme(file)).toThrow("cannot both merge")
|
||||
})
|
||||
47
packages/tui/test/theme/v2/types.test.ts
Normal file
47
packages/tui/test/theme/v2/types.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
|
||||
|
||||
const text = {
|
||||
default: "$hue.neutral.900",
|
||||
subdued: "$hue.neutral.600",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $pressed: "$hue.neutral.200" },
|
||||
secondary: { default: "$hue.neutral.900" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
},
|
||||
} satisfies TextDefinition
|
||||
|
||||
const background = {
|
||||
default: "$hue.neutral.100",
|
||||
action: {
|
||||
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
|
||||
secondary: { default: "$hue.neutral.200" },
|
||||
destructive: { default: "$hue.red.600" },
|
||||
},
|
||||
feedback: { error: { default: "$hue.red.100" } },
|
||||
} satisfies BackgroundDefinition
|
||||
|
||||
const definition = {
|
||||
hue: {} as ThemeDefinition["hue"],
|
||||
color: { text, background, border: { default: "$hue.neutral.300" } },
|
||||
"@context:elevated": {
|
||||
color: {
|
||||
text: { default: "$hue.neutral.800" },
|
||||
background: { default: "$hue.neutral.200" },
|
||||
},
|
||||
},
|
||||
"@context:overlay": { color: { background: { default: "$hue.neutral.300" } } },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
|
||||
|
||||
test("supports property-first definitions, variants, states, and contexts", () => {
|
||||
expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
|
||||
expect(background.action.destructive.default).toBe("$hue.red.600")
|
||||
expect(definition["@context:elevated"].color?.text?.default).toBe("$hue.neutral.800")
|
||||
expect(definition["@context:overlay"].color?.background?.default).toBe("$hue.neutral.300")
|
||||
expect(file.light).toBe(definition)
|
||||
})
|
||||
62
packages/tui/test/theme/v2/v1-migrate.test.ts
Normal file
62
packages/tui/test/theme/v2/v1-migrate.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { DEFAULT_THEMES, resolveTheme as resolveV1, selectedForeground } from "../../../src/theme"
|
||||
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
|
||||
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
|
||||
|
||||
test("migrates resolved V1 modes into literal V2 tokens", () => {
|
||||
const migrated = migrateV1(DEFAULT_THEMES.opencode)
|
||||
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
|
||||
const resolved = resolveThemeFile(migrated, "light")
|
||||
|
||||
expect(migrated.standalone).toBeUndefined()
|
||||
expect(migrated.light.hue?.accent).toBeObject()
|
||||
if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale")
|
||||
expect(migrated.light.hue.accent[300]).toBe(hex(legacy.accent))
|
||||
expect(migrated.light.color?.background?.default).toBe(hex(legacy.background))
|
||||
expect(migrated.light.color?.background?.action?.primary?.default).toBe(hex(legacy.primary))
|
||||
expect(migrated.light.color?.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
|
||||
expect(migrated.light.color?.scrollbar?.default).toBe(hex(legacy.borderActive))
|
||||
expect(migrated.light.color?.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
|
||||
expect(migrated.light.color?.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
|
||||
expect(resolved.color.background.action.secondary.hovered.toInts()).toEqual(legacy.backgroundElement.toInts())
|
||||
expect(resolved.color.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
|
||||
expect(resolved.contexts["@context:elevated"]?.color.background.default.toInts()).toEqual(
|
||||
legacy.backgroundPanel.toInts(),
|
||||
)
|
||||
expect(resolved.contexts["@context:overlay"]?.color.background.default.toInts()).toEqual(
|
||||
legacy.backgroundMenu.toInts(),
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves V1 selected foreground behavior on transparent backgrounds", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.background = "transparent"
|
||||
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
||||
delete source.theme.selectedListItemText
|
||||
const migrated = migrateV1(source)
|
||||
|
||||
expect(migrated.light.color?.text?.action?.primary?.default).toBe("#000000")
|
||||
expect(migrated.dark.color?.text?.action?.primary?.default).toBe("#ffffff")
|
||||
})
|
||||
|
||||
test("retains V1 circular reference errors", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.defs = { ...source.defs, one: "two", two: "one" }
|
||||
source.theme.primary = "one"
|
||||
|
||||
expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
|
||||
})
|
||||
|
||||
test("migrates every built-in V1 theme in both modes", () => {
|
||||
for (const source of Object.values(DEFAULT_THEMES)) {
|
||||
const migrated = migrateV1(source)
|
||||
expect(resolveThemeFile(migrated, "light").color.text.default).toBeDefined()
|
||||
expect(resolveThemeFile(migrated, "dark").color.text.default).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
function hex(color: { toInts(): [number, number, number, number] }) {
|
||||
const [r, g, b, a] = color.toInts()
|
||||
const byte = (value: number) => value.toString(16).padStart(2, "0")
|
||||
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue