feat(theme): extract TUI theme package (#39378)
This commit is contained in:
parent
27e7b0558a
commit
c445d98188
36 changed files with 419 additions and 235 deletions
77
packages/theme/src/tui/color.ts
Normal file
77
packages/theme/src/tui/color.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
type OklchColor = {
|
||||
l: number
|
||||
c: number
|
||||
h: number
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function hue(value: number) {
|
||||
return ((value % 360) + 360) % 360
|
||||
}
|
||||
|
||||
function linearToSrgb(value: number) {
|
||||
if (value <= 0.0031308) return value * 12.92
|
||||
return 1.055 * Math.pow(value, 1 / 2.4) - 0.055
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number) {
|
||||
if (value <= 0.04045) return value / 12.92
|
||||
return Math.pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
|
||||
export function rgbToOklch(red: number, green: number, blue: number): OklchColor {
|
||||
const linearRed = srgbToLinear(red)
|
||||
const linearGreen = srgbToLinear(green)
|
||||
const linearBlue = srgbToLinear(blue)
|
||||
const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
|
||||
const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
|
||||
const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
|
||||
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot
|
||||
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot
|
||||
const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot
|
||||
const chroma = Math.sqrt(a * a + b * b)
|
||||
const angle = Math.atan2(b, a) * (180 / Math.PI)
|
||||
return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle }
|
||||
}
|
||||
|
||||
function oklchToRgb(color: OklchColor) {
|
||||
const a = color.c * Math.cos((color.h * Math.PI) / 180)
|
||||
const b = color.c * Math.sin((color.h * Math.PI) / 180)
|
||||
const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b
|
||||
const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b
|
||||
const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b
|
||||
const l = lRoot * lRoot * lRoot
|
||||
const m = mRoot * mRoot * mRoot
|
||||
const s = sRoot * sRoot * sRoot
|
||||
return {
|
||||
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
||||
}
|
||||
}
|
||||
|
||||
function fitOklch(color: OklchColor): OklchColor {
|
||||
const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) }
|
||||
const rgb = oklchToRgb(base)
|
||||
if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base
|
||||
|
||||
const fitted = Array.from({ length: 24 }).reduce<OklchColor | undefined>((result, _, index) => {
|
||||
if (result) return result
|
||||
const next = { ...base, c: base.c * Math.pow(0.9, index + 1) }
|
||||
const output = oklchToRgb(next)
|
||||
if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next
|
||||
}, undefined)
|
||||
return fitted ?? { ...base, c: 0 }
|
||||
}
|
||||
|
||||
export function oklchToHex(color: OklchColor) {
|
||||
const rgb = oklchToRgb(fitOklch(color))
|
||||
const toHex = (value: number) =>
|
||||
Math.round(clamp(value, 0, 1) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
|
||||
}
|
||||
440
packages/theme/src/tui/defaults.ts
Normal file
440
packages/theme/src/tui/defaults.ts
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
import type { HueName, ThemeDocument } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
"purple",
|
||||
"green",
|
||||
"orange",
|
||||
"red",
|
||||
"cyan",
|
||||
] as const satisfies readonly HueName[]
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
light: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.800",
|
||||
subdued: "$hue.neutral.600",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.800",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.700",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
|
||||
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.200",
|
||||
surface: {
|
||||
offset: "$hue.neutral.300",
|
||||
overlay: "$hue.neutral.400",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.600",
|
||||
$hovered: "$hue.interactive.700",
|
||||
$focused: "$hue.interactive.700",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$selected: "$hue.interactive.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.surface.offset",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.300" },
|
||||
scrollbar: { default: "$hue.neutral.400" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.700",
|
||||
removed: "$hue.red.700",
|
||||
context: "$hue.neutral.900",
|
||||
hunkHeader: "$hue.purple.600",
|
||||
},
|
||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.600",
|
||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.600",
|
||||
keyword: "$hue.purple.600",
|
||||
function: "$hue.accent.600",
|
||||
variable: "$hue.neutral.900",
|
||||
string: "$hue.green.700",
|
||||
number: "$hue.yellow.800",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.600",
|
||||
punctuation: "$hue.neutral.900",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.900",
|
||||
heading: "$hue.purple.600",
|
||||
link: "$hue.accent.600",
|
||||
linkText: "$hue.cyan.600",
|
||||
code: "$hue.green.700",
|
||||
blockQuote: "$hue.neutral.600",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.900",
|
||||
horizontalRule: "$hue.neutral.300",
|
||||
listItem: "$hue.accent.600",
|
||||
listEnumeration: "$hue.cyan.600",
|
||||
image: "$hue.accent.600",
|
||||
imageText: "$hue.cyan.600",
|
||||
codeBlock: "$hue.neutral.900",
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.surface.overlay" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.surface.overlay",
|
||||
action: { primary: { default: "$hue.interactive.500" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.200",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.200",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
|
||||
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.800",
|
||||
surface: {
|
||||
offset: "$hue.neutral.700",
|
||||
overlay: "$hue.neutral.600",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.500",
|
||||
$hovered: "$hue.interactive.600",
|
||||
$focused: "$hue.interactive.600",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$selected: "$hue.interactive.600",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.surface.offset",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.700" },
|
||||
scrollbar: { default: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.300",
|
||||
removed: "$hue.red.300",
|
||||
context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.400",
|
||||
keyword: "$hue.purple.400",
|
||||
function: "$hue.accent.400",
|
||||
variable: "$hue.neutral.100",
|
||||
string: "$hue.green.300",
|
||||
number: "$hue.yellow.200",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.100",
|
||||
heading: "$hue.purple.400",
|
||||
link: "$hue.accent.400",
|
||||
linkText: "$hue.cyan.400",
|
||||
code: "$hue.green.300",
|
||||
blockQuote: "$hue.neutral.400",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.100",
|
||||
horizontalRule: "$hue.neutral.700",
|
||||
listItem: "$hue.accent.400",
|
||||
listEnumeration: "$hue.cyan.400",
|
||||
image: "$hue.accent.400",
|
||||
imageText: "$hue.cyan.400",
|
||||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
"@context:elevated": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.surface.overlay" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.surface.overlay",
|
||||
action: { primary: { default: "$hue.interactive.400" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ThemeDocument
|
||||
108
packages/theme/src/tui/expand.ts
Normal file
108
packages/theme/src/tui/expand.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type {
|
||||
BackgroundDefinition,
|
||||
ModeDefinition,
|
||||
StatefulColorDefinition,
|
||||
TextDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index.js"
|
||||
import { ActionState } from "./schema.js"
|
||||
|
||||
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
|
||||
return {
|
||||
...definition,
|
||||
...expandTokens(definition),
|
||||
...Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
|
||||
return {
|
||||
...definition,
|
||||
text: expandText(definition.text),
|
||||
background: expandBackground(definition.background),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTheme(...values: unknown[]): Record<string, unknown> {
|
||||
return values.reduce<Record<string, unknown>>((result, value) => {
|
||||
if (!isRecord(value)) return result
|
||||
return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
|
||||
if (item === undefined || key === "mergeMode") return next
|
||||
return {
|
||||
...next,
|
||||
[key]: isRecord(item) ? mergeTheme(next[key], item) : item,
|
||||
}
|
||||
}, result)
|
||||
}, {})
|
||||
}
|
||||
|
||||
function expandText(definition: TextDefinition | undefined): TextDefinition | undefined {
|
||||
if (!definition) return
|
||||
return {
|
||||
...definition,
|
||||
subdued: definition.subdued ?? (definition.default ? "$text.default" : undefined),
|
||||
action: expandActions(definition.action, "text.action"),
|
||||
formfield: expandFormfield(definition.formfield, "text.formfield"),
|
||||
feedback: definition.feedback
|
||||
? Object.fromEntries(
|
||||
Object.entries(definition.feedback).map(([kind, feedback]) => {
|
||||
return [
|
||||
kind,
|
||||
{
|
||||
...feedback,
|
||||
subdued: feedback.subdued ?? (feedback.default ? `$text.feedback.${kind}.default` : undefined),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined {
|
||||
if (!definition) return
|
||||
return {
|
||||
...definition,
|
||||
action: expandActions(definition.action, "background.action"),
|
||||
formfield: expandFormfield(definition.formfield, "background.formfield"),
|
||||
}
|
||||
}
|
||||
|
||||
function expandFormfield(definition: StatefulColorDefinition | undefined, path: string) {
|
||||
if (!definition?.default) return definition
|
||||
return {
|
||||
...definition,
|
||||
...Object.fromEntries(
|
||||
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function expandActions<Definition extends Partial<Record<string, StatefulColorDefinition>>>(
|
||||
definition: Definition | undefined,
|
||||
path: string,
|
||||
) {
|
||||
if (!definition) return
|
||||
return Object.fromEntries(
|
||||
Object.entries(definition).map(([variant, value]) => {
|
||||
if (!value?.default) return [variant, value]
|
||||
return [
|
||||
variant,
|
||||
{
|
||||
...value,
|
||||
...Object.fromEntries(
|
||||
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]),
|
||||
),
|
||||
},
|
||||
]
|
||||
}),
|
||||
) as Definition
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
57
packages/theme/src/tui/fallback.ts
Normal file
57
packages/theme/src/tui/fallback.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { ThemeTokensDefinition } from "./index.js"
|
||||
import { ActionVariant, FeedbackKind } from "./schema.js"
|
||||
|
||||
export function fallback(): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
text: {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
default: red,
|
||||
surface: { offset: red, overlay: red },
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
border: { default: red },
|
||||
scrollbar: { default: red },
|
||||
diff: {
|
||||
text: { added: red, removed: red, context: red, hunkHeader: red },
|
||||
background: { added: red, removed: red, context: red },
|
||||
highlight: { added: red, removed: red },
|
||||
lineNumber: { text: red, background: { added: red, removed: red } },
|
||||
},
|
||||
syntax: {
|
||||
comment: red,
|
||||
keyword: red,
|
||||
function: red,
|
||||
variable: red,
|
||||
string: red,
|
||||
number: red,
|
||||
type: red,
|
||||
operator: red,
|
||||
punctuation: red,
|
||||
},
|
||||
markdown: {
|
||||
text: red,
|
||||
heading: red,
|
||||
link: red,
|
||||
linkText: red,
|
||||
code: red,
|
||||
blockQuote: red,
|
||||
emphasis: red,
|
||||
strong: red,
|
||||
horizontalRule: red,
|
||||
listItem: red,
|
||||
listEnumeration: red,
|
||||
image: red,
|
||||
imageText: red,
|
||||
codeBlock: red,
|
||||
},
|
||||
}
|
||||
}
|
||||
50
packages/theme/src/tui/index.ts
Normal file
50
packages/theme/src/tui/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export {
|
||||
ActionState,
|
||||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
CategoricalDefinition,
|
||||
FeedbackKind,
|
||||
FormfieldState,
|
||||
type FormfieldStateKey,
|
||||
HueAlias,
|
||||
HueName,
|
||||
HueStep,
|
||||
MarkdownDefinition,
|
||||
MarkdownToken,
|
||||
ModeDefinition,
|
||||
SyntaxDefinition,
|
||||
SyntaxToken,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
type BackgroundDefinition,
|
||||
type DiffDefinition,
|
||||
type FileThemeDefinition,
|
||||
type FormfieldColorDefinition,
|
||||
type HueDefinition,
|
||||
type HueOverrideDefinition,
|
||||
type MergeModeDefinition,
|
||||
type Mode,
|
||||
type StatefulColorDefinition,
|
||||
type ContextKey,
|
||||
type TextDefinition,
|
||||
type ThemeTokensDefinition,
|
||||
} from "./schema.js"
|
||||
|
||||
export type {
|
||||
Categorical,
|
||||
FormfieldColor,
|
||||
Hue,
|
||||
HueSource,
|
||||
HueScale,
|
||||
ResolvedActionState,
|
||||
ResolvedFormfieldState,
|
||||
ResolvedTheme,
|
||||
ResolvedThemeView,
|
||||
StatefulColor,
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
export { migrateV1 } from "./v1-migrate.js"
|
||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
|
||||
export { generateSyntax } from "./syntax.js"
|
||||
255
packages/theme/src/tui/resolve.ts
Normal file
255
packages/theme/src/tui/resolve.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
|
||||
import { fallback } from "./fallback.js"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
} from "./schema.js"
|
||||
import type {
|
||||
ActionStateKey,
|
||||
HueDefinition,
|
||||
HueScale,
|
||||
ResolvedActionState,
|
||||
ResolvedTheme,
|
||||
ResolvedThemeView,
|
||||
StatefulColorDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index.js"
|
||||
import { selectTheme, selectThemeMode } from "./select.js"
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
|
||||
|
||||
function decodeThemeDefinition(input: unknown) {
|
||||
try {
|
||||
return decodeThemeDefinitionSchema(input)
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, "theme")
|
||||
}
|
||||
}
|
||||
|
||||
export function themeDecodeError(error: unknown, name: string) {
|
||||
const message = Schema.isSchemaError(error) ? error.message : String(error)
|
||||
const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value"
|
||||
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
|
||||
}
|
||||
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
|
||||
} as ThemeDefinition)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
|
||||
}
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const base = tokens(definition)
|
||||
const resolved = resolveView(base, hue, categorical, hueSteps)
|
||||
const contexts = Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, override]) => {
|
||||
const contextual = contextualize(base, override as ThemeTokensDefinition)
|
||||
return [key, resolveView(contextual, hue, categorical, hueSteps)]
|
||||
}),
|
||||
)
|
||||
|
||||
return { ...resolved, contexts } as ResolvedTheme
|
||||
}
|
||||
|
||||
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
|
||||
return {
|
||||
text: definition.text,
|
||||
background: definition.background,
|
||||
border: definition.border,
|
||||
scrollbar: definition.scrollbar,
|
||||
diff: definition.diff,
|
||||
syntax: definition.syntax,
|
||||
markdown: definition.markdown,
|
||||
}
|
||||
}
|
||||
|
||||
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
|
||||
const result = mergeTheme(base, override)
|
||||
const baseText = base.text?.action
|
||||
const contextText = override.text?.action
|
||||
const baseBackground = base.background?.action
|
||||
const contextBackground = override.background?.action
|
||||
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
|
||||
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
|
||||
return {
|
||||
...result,
|
||||
text: { ...text, action: contextualActions(baseText, contextText) },
|
||||
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
|
||||
} as ThemeTokensDefinition
|
||||
}
|
||||
|
||||
function contextualActions(
|
||||
base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
ActionVariant.literals.map((variant) => {
|
||||
const baseVariant = base?.[variant]
|
||||
const contextVariant = context?.[variant]
|
||||
return [
|
||||
variant,
|
||||
Object.fromEntries(
|
||||
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
|
||||
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
|
||||
return [
|
||||
key ?? "default",
|
||||
(key ? contextVariant?.[key] : undefined) ??
|
||||
contextVariant?.default ??
|
||||
(key ? baseVariant?.[key] : undefined) ??
|
||||
baseVariant?.default,
|
||||
]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveView(
|
||||
definition: ThemeTokensDefinition,
|
||||
hue: ResolvedThemeView["hue"],
|
||||
categorical: ResolvedThemeView["categorical"],
|
||||
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
|
||||
): ResolvedThemeView {
|
||||
const source: Record<string, unknown> = { hue, ...definition }
|
||||
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps }
|
||||
}
|
||||
|
||||
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
|
||||
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
|
||||
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
|
||||
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
|
||||
}
|
||||
const shift = (color: RGBA, amount: number) => {
|
||||
const match = index.get(color)
|
||||
if (!match) return color
|
||||
const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0
|
||||
const position = Math.max(0, Math.min(HueStep.literals.length - 1, match.position + offset))
|
||||
return hue[match.hue][HueStep.literals[position]]
|
||||
}
|
||||
return {
|
||||
source: (color) => {
|
||||
const match = index.get(color)
|
||||
return match ? { hue: match.hue, step: match.step } : undefined
|
||||
},
|
||||
increase: (color, amount = 1) => shift(color, amount),
|
||||
decrease: (color, amount = 1) => shift(color, -amount),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHue(definition: HueDefinition) {
|
||||
const source = definition as Record<string, unknown>
|
||||
const cache = new Map<string, HueScale>()
|
||||
const expected = new Set<string>([...BaseHue.literals, ...HueAlias.literals])
|
||||
for (const name of Object.keys(source)) {
|
||||
if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`)
|
||||
}
|
||||
|
||||
function resolve(name: string, stack: string[]): HueScale {
|
||||
const hit = cache.get(name)
|
||||
if (hit) return hit
|
||||
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
|
||||
const value = source[name]
|
||||
if (typeof value === "string") {
|
||||
const match = /^\$hue\.([^.]+)$/.exec(value)
|
||||
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
|
||||
const target = resolve(match[1], [...stack, name])
|
||||
const result = Object.fromEntries(HueStep.literals.map((step) => [step, RGBA.clone(target[step])])) as HueScale
|
||||
cache.set(name, result)
|
||||
return result
|
||||
}
|
||||
if (!isRecord(value)) throw new Error(`Hue "${name}" was not found`)
|
||||
const result = Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
const color = value[step]
|
||||
if (typeof color !== "string" || !isHex(color)) throw new Error(`Invalid hue color at "hue.${name}.${step}"`)
|
||||
return [step, RGBA.fromHex(color)]
|
||||
}),
|
||||
) as HueScale
|
||||
for (const step of Object.keys(value)) {
|
||||
if (!HueStep.literals.includes(Number(step) as HueStep))
|
||||
throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
|
||||
}
|
||||
cache.set(name, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
|
||||
) as ResolvedThemeView["hue"]
|
||||
}
|
||||
|
||||
function createResolver(source: Record<string, unknown>) {
|
||||
const cache = new Map<string, RGBA>()
|
||||
|
||||
function resolve(value: unknown, path: string, stack: string[] = []): unknown {
|
||||
if (value instanceof RGBA) return value
|
||||
if (typeof value === "string") return resolveColor(value, path, stack)
|
||||
if (typeof value === "number") return value
|
||||
if (!isRecord(value)) throw new Error(`Invalid theme value at "${path}"`)
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [resolvedKey(key), resolve(item, `${path}.${key}`, stack)]),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveColor(value: string, path: string, stack: string[]) {
|
||||
if (value === "transparent") return RGBA.fromInts(0, 0, 0, 0)
|
||||
if (isHex(value)) return RGBA.fromHex(value)
|
||||
if (!value.startsWith("$")) throw new Error(`Invalid color "${value}" at "${path}"`)
|
||||
const target = value.slice(1)
|
||||
const hit = cache.get(target)
|
||||
if (hit) return hit
|
||||
if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`)
|
||||
const result = resolve(read(source, target), target, [...stack, target])
|
||||
if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`)
|
||||
cache.set(target, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return (value: unknown, path: string) => resolve(value, path)
|
||||
}
|
||||
|
||||
function resolvedKey(key: string) {
|
||||
if (!key.startsWith("$")) return key
|
||||
const state = key.slice(1)
|
||||
return (ActionState.literals as readonly string[]).includes(state) ? state : key
|
||||
}
|
||||
|
||||
function read(source: Record<string, unknown>, path: string) {
|
||||
const result = path.split(".").reduce<unknown>((value, key) => (isRecord(value) ? value[key] : undefined), source)
|
||||
if (result === undefined) throw new Error(`Theme reference "$${path}" was not found`)
|
||||
return result
|
||||
}
|
||||
|
||||
function isHex(value: string) {
|
||||
return /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof RGBA)
|
||||
}
|
||||
258
packages/theme/src/tui/schema.ts
Normal file
258
packages/theme/src/tui/schema.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { Schema } from "effect"
|
||||
|
||||
export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900])
|
||||
export type HueStep = Schema.Schema.Type<typeof HueStep>
|
||||
|
||||
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
|
||||
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
|
||||
|
||||
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
|
||||
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
|
||||
|
||||
export const ActionVariant = Schema.Literals(["primary", "destructive"])
|
||||
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
||||
|
||||
export const ActionState = Schema.Literals(["disabled", "pressed", "focused", "selected", "hovered"])
|
||||
export type ActionState = Schema.Schema.Type<typeof ActionState>
|
||||
export type ActionStateKey = `$${ActionState}`
|
||||
|
||||
export const FormfieldState = ActionState
|
||||
export type FormfieldState = ActionState
|
||||
export type FormfieldStateKey = `$${FormfieldState}`
|
||||
|
||||
export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"])
|
||||
export type FeedbackKind = Schema.Schema.Type<typeof FeedbackKind>
|
||||
|
||||
const Mode = Schema.Literals(["light", "dark"])
|
||||
export type Mode = Schema.Schema.Type<typeof Mode>
|
||||
|
||||
const HexColor = Schema.String.check(Schema.isPattern(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i))
|
||||
|
||||
const ColorValue = Schema.Union([
|
||||
HexColor,
|
||||
Schema.Literal("transparent"),
|
||||
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
|
||||
])
|
||||
|
||||
export const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
export type HueName = Schema.Schema.Type<typeof HueName>
|
||||
export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1))
|
||||
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
|
||||
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
|
||||
|
||||
const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
|
||||
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
|
||||
|
||||
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
|
||||
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
|
||||
|
||||
const HueDefinition = Schema.Struct({
|
||||
gray: HueValueDefinition,
|
||||
red: HueValueDefinition,
|
||||
orange: HueValueDefinition,
|
||||
yellow: HueValueDefinition,
|
||||
green: HueValueDefinition,
|
||||
cyan: HueValueDefinition,
|
||||
blue: HueValueDefinition,
|
||||
purple: HueValueDefinition,
|
||||
accent: HueValueDefinition,
|
||||
interactive: HueValueDefinition,
|
||||
neutral: HueValueDefinition,
|
||||
})
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
||||
|
||||
const HueOverrideDefinition = Schema.Struct({
|
||||
gray: Schema.optional(HueValueDefinition),
|
||||
red: Schema.optional(HueValueDefinition),
|
||||
orange: Schema.optional(HueValueDefinition),
|
||||
yellow: Schema.optional(HueValueDefinition),
|
||||
green: Schema.optional(HueValueDefinition),
|
||||
cyan: Schema.optional(HueValueDefinition),
|
||||
blue: Schema.optional(HueValueDefinition),
|
||||
purple: Schema.optional(HueValueDefinition),
|
||||
accent: Schema.optional(HueValueDefinition),
|
||||
interactive: Schema.optional(HueValueDefinition),
|
||||
neutral: Schema.optional(HueValueDefinition),
|
||||
})
|
||||
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
|
||||
|
||||
const StatefulColorDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
$hovered: Schema.optional(ColorValue),
|
||||
$focused: Schema.optional(ColorValue),
|
||||
$pressed: Schema.optional(ColorValue),
|
||||
$selected: Schema.optional(ColorValue),
|
||||
$disabled: Schema.optional(ColorValue),
|
||||
})
|
||||
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
|
||||
|
||||
export type FormfieldColorDefinition = StatefulColorDefinition
|
||||
|
||||
const ActionColorDefinition = Schema.Struct({
|
||||
primary: Schema.optional(StatefulColorDefinition),
|
||||
destructive: Schema.optional(StatefulColorDefinition),
|
||||
})
|
||||
|
||||
const TextFeedbackDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
subdued: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const BackgroundFeedbackDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const TextDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
subdued: Schema.optional(ColorValue),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
formfield: Schema.optional(StatefulColorDefinition),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(TextFeedbackDefinition),
|
||||
warning: Schema.optional(TextFeedbackDefinition),
|
||||
success: Schema.optional(TextFeedbackDefinition),
|
||||
info: Schema.optional(TextFeedbackDefinition),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
|
||||
|
||||
const BackgroundDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
surface: Schema.optional(
|
||||
Schema.Struct({
|
||||
offset: Schema.optional(ColorValue),
|
||||
overlay: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
formfield: Schema.optional(StatefulColorDefinition),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(BackgroundFeedbackDefinition),
|
||||
warning: Schema.optional(BackgroundFeedbackDefinition),
|
||||
success: Schema.optional(BackgroundFeedbackDefinition),
|
||||
info: Schema.optional(BackgroundFeedbackDefinition),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
|
||||
|
||||
export const SyntaxToken = Schema.Literals([
|
||||
"comment",
|
||||
"keyword",
|
||||
"function",
|
||||
"variable",
|
||||
"string",
|
||||
"number",
|
||||
"type",
|
||||
"operator",
|
||||
"punctuation",
|
||||
])
|
||||
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
|
||||
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
|
||||
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
|
||||
|
||||
export const MarkdownToken = Schema.Literals([
|
||||
"text",
|
||||
"heading",
|
||||
"link",
|
||||
"linkText",
|
||||
"code",
|
||||
"blockQuote",
|
||||
"emphasis",
|
||||
"strong",
|
||||
"horizontalRule",
|
||||
"listItem",
|
||||
"listEnumeration",
|
||||
"image",
|
||||
"imageText",
|
||||
"codeBlock",
|
||||
])
|
||||
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
|
||||
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
|
||||
export type MarkdownDefinition = Schema.Schema.Type<typeof MarkdownDefinition>
|
||||
|
||||
const DiffDefinition = Schema.Struct({
|
||||
text: Schema.optional(
|
||||
Schema.Struct({
|
||||
added: Schema.optional(ColorValue),
|
||||
removed: Schema.optional(ColorValue),
|
||||
context: Schema.optional(ColorValue),
|
||||
hunkHeader: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
background: Schema.optional(
|
||||
Schema.Struct({
|
||||
added: Schema.optional(ColorValue),
|
||||
removed: Schema.optional(ColorValue),
|
||||
context: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
highlight: Schema.optional(
|
||||
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
|
||||
),
|
||||
lineNumber: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(ColorValue),
|
||||
background: Schema.optional(
|
||||
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
|
||||
|
||||
const ThemeTokensDefinition = Schema.Struct({
|
||||
text: Schema.optional(TextDefinition),
|
||||
background: Schema.optional(BackgroundDefinition),
|
||||
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
|
||||
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
|
||||
diff: Schema.optional(DiffDefinition),
|
||||
syntax: Schema.optional(SyntaxDefinition),
|
||||
markdown: Schema.optional(MarkdownDefinition),
|
||||
})
|
||||
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
|
||||
|
||||
const ThemeDefinitionFields = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export const ThemeDefinition = ThemeDefinitionFields
|
||||
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
|
||||
|
||||
const FileThemeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
|
||||
|
||||
const MergeModeDefinition = Schema.Struct({
|
||||
mergeMode: Schema.Literal(true),
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
|
||||
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
|
||||
export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
|
||||
|
||||
const FileMetadata = {
|
||||
$schema: Schema.optional(Schema.String),
|
||||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeDocument = Schema.Union([
|
||||
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
|
||||
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
|
||||
])
|
||||
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>
|
||||
51
packages/theme/src/tui/select.ts
Normal file
51
packages/theme/src/tui/select.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { expandTheme, mergeTheme } from "./expand.js"
|
||||
import type {
|
||||
FileThemeDefinition,
|
||||
MergeModeDefinition,
|
||||
Mode,
|
||||
ModeDefinition,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
} from "./index.js"
|
||||
|
||||
export function selectTheme(
|
||||
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
mode?: Mode,
|
||||
): ThemeDefinition
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode) {
|
||||
return selectThemeMode(document, mode).theme
|
||||
}
|
||||
|
||||
export function selectThemeMode(
|
||||
document: ThemeDocument,
|
||||
mode: Mode = "light",
|
||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||
const modes = themeModes(document)
|
||||
const selectedMode = modes.includes(mode) ? mode : modes[0]
|
||||
const selected = document[selectedMode]
|
||||
if (!selected) throw new Error("Theme must provide at least one mode")
|
||||
if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
|
||||
|
||||
const otherMode = selectedMode === "light" ? "dark" : "light"
|
||||
const other = document[otherMode]
|
||||
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
|
||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
|
||||
}
|
||||
|
||||
export function themeModes(document: ThemeDocument): readonly Mode[] {
|
||||
if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||
if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||
return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined)
|
||||
}
|
||||
|
||||
export function supportsThemeMode(document: ThemeDocument, mode: Mode) {
|
||||
return themeModes(document).includes(mode)
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
|
||||
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
|
||||
}
|
||||
93
packages/theme/src/tui/syntax.ts
Normal file
93
packages/theme/src/tui/syntax.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
|
||||
import type { Mode, ResolvedThemeView } from "./index.js"
|
||||
|
||||
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
|
||||
const step = mode === "light" ? 800 : 200
|
||||
const syntax = theme.syntax
|
||||
const markdown = theme.markdown
|
||||
const feedback = theme.text.feedback
|
||||
|
||||
return SyntaxStyle.fromTheme([
|
||||
rule(["default"], theme.text.default),
|
||||
rule(["prompt"], theme.hue.accent[step]),
|
||||
rule(["extmark.file"], feedback.warning.default, { bold: true }),
|
||||
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
|
||||
// V1 migration preserves its selected/inverse foreground in this action state.
|
||||
rule(["extmark.paste"], theme.text.action.primary.focused, {
|
||||
background: feedback.warning.default,
|
||||
bold: true,
|
||||
}),
|
||||
rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
|
||||
rule(["string", "symbol", "character.special", "character"], syntax.string),
|
||||
rule(["number", "boolean", "constant", "float"], syntax.number),
|
||||
rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, {
|
||||
italic: true,
|
||||
}),
|
||||
rule(["keyword.type"], syntax.type, { bold: true, italic: true }),
|
||||
rule(["keyword.function", "function.method"], syntax.function),
|
||||
rule(["keyword"], syntax.keyword, { italic: true }),
|
||||
rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword),
|
||||
rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator),
|
||||
rule(
|
||||
["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"],
|
||||
syntax.variable,
|
||||
),
|
||||
rule(["variable.member", "function", "constructor"], syntax.function),
|
||||
rule(["type", "module", "class", "namespace"], syntax.type),
|
||||
rule(["type.definition"], syntax.type, { bold: true }),
|
||||
rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
|
||||
rule(
|
||||
["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
|
||||
feedback.error.default,
|
||||
),
|
||||
rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
|
||||
rule(["punctuation.special", "tag.delimiter"], syntax.operator),
|
||||
rule(
|
||||
[
|
||||
"markup.heading",
|
||||
"markup.heading.2",
|
||||
"markup.heading.3",
|
||||
"markup.heading.4",
|
||||
"markup.heading.5",
|
||||
"markup.heading.6",
|
||||
],
|
||||
markdown.heading,
|
||||
{ bold: true },
|
||||
),
|
||||
rule(["markup.heading.1"], markdown.heading, { bold: true, underline: true }),
|
||||
rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }),
|
||||
rule(["markup.italic"], markdown.emphasis, { italic: true }),
|
||||
rule(["markup.list"], markdown.listItem),
|
||||
rule(["markup.quote"], markdown.blockQuote, { italic: true }),
|
||||
rule(["markup.raw", "markup.raw.block"], markdown.code),
|
||||
rule(["markup.raw.inline"], markdown.code, { background: theme.background.default }),
|
||||
rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
|
||||
underline: true,
|
||||
}),
|
||||
rule(["markup.link.label"], markdown.linkText, { underline: true }),
|
||||
rule(["label"], markdown.linkText),
|
||||
rule(["spell", "nospell"], theme.text.default),
|
||||
rule(["markup.underline"], theme.text.default, { underline: true }),
|
||||
rule(["comment.error"], feedback.error.default, { italic: true, bold: true }),
|
||||
rule(["comment.warning"], feedback.warning.default, { italic: true, bold: true }),
|
||||
rule(["comment.todo", "comment.note"], feedback.info.default, { italic: true, bold: true }),
|
||||
rule(["attribute", "annotation"], feedback.warning.default),
|
||||
rule(["tag"], feedback.error.default),
|
||||
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.subdued),
|
||||
rule(["markup.list.checked"], feedback.success.default),
|
||||
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
|
||||
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
|
||||
rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
|
||||
rule(["error"], feedback.error.default, { bold: true }),
|
||||
rule(["warning"], feedback.warning.default, { bold: true }),
|
||||
rule(["info"], feedback.info.default),
|
||||
])
|
||||
}
|
||||
|
||||
function rule(
|
||||
scope: string[],
|
||||
foreground: RGBA,
|
||||
style: Omit<ThemeTokenStyle["style"], "foreground"> = {},
|
||||
): ThemeTokenStyle {
|
||||
return { scope, style: { foreground, ...style } }
|
||||
}
|
||||
68
packages/theme/src/tui/types.ts
Normal file
68
packages/theme/src/tui/types.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
import type {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
MarkdownToken,
|
||||
ContextKey,
|
||||
SyntaxToken,
|
||||
} from "./schema.js"
|
||||
|
||||
export type ResolvedActionState = "default" | ActionState
|
||||
export type ResolvedFormfieldState = ResolvedActionState
|
||||
export type HueScale = Readonly<Record<HueStep, RGBA>>
|
||||
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
|
||||
export type Categorical = readonly HueScale[]
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
|
||||
export type FormfieldColor = StatefulColor
|
||||
|
||||
export type ResolvedThemeView = {
|
||||
readonly hue: Hue
|
||||
readonly categorical: Categorical
|
||||
readonly source: (color: RGBA) => HueSource | undefined
|
||||
readonly increase: (color: RGBA, amount?: number) => RGBA
|
||||
readonly decrease: (color: RGBA, amount?: number) => RGBA
|
||||
readonly text: {
|
||||
readonly default: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly formfield: FormfieldColor
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
|
||||
}
|
||||
readonly background: {
|
||||
readonly default: RGBA
|
||||
readonly surface: {
|
||||
readonly offset: RGBA
|
||||
readonly overlay: RGBA
|
||||
}
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly formfield: FormfieldColor
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
|
||||
}
|
||||
readonly border: { readonly default: RGBA }
|
||||
readonly scrollbar: { readonly default: RGBA }
|
||||
readonly diff: {
|
||||
readonly text: {
|
||||
readonly added: RGBA
|
||||
readonly removed: RGBA
|
||||
readonly context: RGBA
|
||||
readonly hunkHeader: RGBA
|
||||
}
|
||||
readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
|
||||
readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
|
||||
readonly lineNumber: {
|
||||
readonly text: RGBA
|
||||
readonly background: { readonly added: RGBA; readonly removed: RGBA }
|
||||
}
|
||||
}
|
||||
readonly syntax: Readonly<Record<SyntaxToken, RGBA>>
|
||||
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
|
||||
}
|
||||
|
||||
export type ResolvedTheme = ResolvedThemeView & {
|
||||
readonly contexts: Readonly<Partial<Record<ContextKey, ResolvedThemeView>>>
|
||||
}
|
||||
462
packages/theme/src/tui/v1-migrate.ts
Normal file
462
packages/theme/src/tui/v1-migrate.ts
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "./color.js"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
|
||||
import { HueStep } from "./schema.js"
|
||||
import type { Theme, ThemeV1Json } from "./v1.js"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
||||
type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
|
||||
|
||||
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
|
||||
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
|
||||
const minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
|
||||
export function migrateV1(theme: ThemeV1Json): ThemeDocument {
|
||||
const light = resolveV1(theme, "light")
|
||||
const dark = resolveV1(theme, "dark")
|
||||
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
|
||||
const lightMode = detectMode(light)
|
||||
const darkMode = detectMode(dark)
|
||||
if (lightMode === darkMode) {
|
||||
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
|
||||
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
standalone: true,
|
||||
light: migrateMode(light, "light"),
|
||||
dark: migrateMode(dark, "dark"),
|
||||
}
|
||||
}
|
||||
|
||||
function detectMode(theme: Theme): Mode {
|
||||
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
|
||||
}
|
||||
|
||||
function luminance(color: RGBA) {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
const color = (key: ThemeColor) => hex(theme[key])
|
||||
const selected = hex(selectedForeground(theme, theme.primary))
|
||||
const destructive = hex(selectedForeground(theme, theme.error))
|
||||
const hues = inferHues(theme, mode)
|
||||
const categorical = categoricalTokens.flatMap((token) => {
|
||||
const hue = hues.byToken[token]
|
||||
return hue ? [hue] : []
|
||||
})
|
||||
const uniqueCategorical = categorical.filter((hue, index) => categorical.indexOf(hue) === index)
|
||||
const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
|
||||
const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
|
||||
const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
|
||||
const background = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
|
||||
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
||||
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
|
||||
|
||||
return referenceHues({
|
||||
hue: {
|
||||
gray: neutralScale(theme, mode),
|
||||
...Object.fromEntries(
|
||||
chromaticHues.map((name) => {
|
||||
const match = hues.byHue[name]
|
||||
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
||||
}),
|
||||
),
|
||||
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
|
||||
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: text,
|
||||
subdued: textMuted,
|
||||
action: {
|
||||
primary: {
|
||||
default: "$text.default",
|
||||
$disabled: textMuted,
|
||||
$focused: selected,
|
||||
$selected: primary,
|
||||
},
|
||||
destructive: { default: destructive, $disabled: textMuted },
|
||||
},
|
||||
formfield: {
|
||||
default: text,
|
||||
$hovered: primary,
|
||||
$focused: primary,
|
||||
$pressed: primary,
|
||||
$disabled: textMuted,
|
||||
$selected: primary,
|
||||
},
|
||||
feedback: {
|
||||
error: { default: color("error") },
|
||||
warning: { default: color("warning") },
|
||||
success: { default: color("success") },
|
||||
info: { default: color("info") },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: background,
|
||||
surface: {
|
||||
offset: backgroundPanel,
|
||||
overlay: backgroundMenu,
|
||||
},
|
||||
action: {
|
||||
primary: { default: "transparent", $hovered: backgroundPanel, $focused: primary, $selected: "transparent" },
|
||||
destructive: { default: color("error") },
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: color("border") },
|
||||
scrollbar: { default: color("borderActive") },
|
||||
diff: {
|
||||
text: {
|
||||
added: color("diffAdded"),
|
||||
removed: color("diffRemoved"),
|
||||
context: color("diffContext"),
|
||||
hunkHeader: color("diffHunkHeader"),
|
||||
},
|
||||
background: {
|
||||
added: color("diffAddedBg"),
|
||||
removed: color("diffRemovedBg"),
|
||||
context: color("diffContextBg"),
|
||||
},
|
||||
highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") },
|
||||
lineNumber: {
|
||||
text: color("diffLineNumber"),
|
||||
background: {
|
||||
added: color("diffAddedLineNumberBg"),
|
||||
removed: color("diffRemovedLineNumberBg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: color("syntaxComment"),
|
||||
keyword: color("syntaxKeyword"),
|
||||
function: color("syntaxFunction"),
|
||||
variable: color("syntaxVariable"),
|
||||
string: color("syntaxString"),
|
||||
number: color("syntaxNumber"),
|
||||
type: color("syntaxType"),
|
||||
operator: color("syntaxOperator"),
|
||||
punctuation: color("syntaxPunctuation"),
|
||||
},
|
||||
markdown: {
|
||||
text: color("markdownText"),
|
||||
heading: color("markdownHeading"),
|
||||
link: color("markdownLink"),
|
||||
linkText: color("markdownLinkText"),
|
||||
code: color("markdownCode"),
|
||||
blockQuote: color("markdownBlockQuote"),
|
||||
emphasis: color("markdownEmph"),
|
||||
strong: color("markdownStrong"),
|
||||
horizontalRule: color("markdownHorizontalRule"),
|
||||
listItem: color("markdownListItem"),
|
||||
listEnumeration: color("markdownListEnumeration"),
|
||||
image: color("markdownImage"),
|
||||
imageText: color("markdownImageText"),
|
||||
codeBlock: color("markdownCodeBlock"),
|
||||
},
|
||||
"@context:elevated": {
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
action: { primary: { $hovered: "$background.surface.overlay" } },
|
||||
},
|
||||
},
|
||||
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
||||
})
|
||||
}
|
||||
|
||||
function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
|
||||
const definitions = theme.hue as Record<string, string | Partial<Record<HueStep, string>>> | undefined
|
||||
if (!definitions) return theme
|
||||
const scales = new Map<string, Partial<Record<HueStep, string>>>()
|
||||
|
||||
function resolve(name: string, chain: string[] = []): Partial<Record<HueStep, string>> | undefined {
|
||||
const cached = scales.get(name)
|
||||
if (cached) return cached
|
||||
if (chain.includes(name)) return
|
||||
const value = definitions?.[name]
|
||||
if (!value) return
|
||||
if (typeof value !== "string") {
|
||||
scales.set(name, value)
|
||||
return value
|
||||
}
|
||||
const target = /^\$hue\.([^.]+)$/.exec(value)?.[1]
|
||||
if (!target) return
|
||||
const scale = resolve(target, [...chain, name])
|
||||
if (scale) scales.set(name, scale)
|
||||
return scale
|
||||
}
|
||||
|
||||
const references = new Map<string, string>()
|
||||
const index = (name: string, overwrite: boolean) => {
|
||||
const scale = resolve(name)
|
||||
if (!scale) return
|
||||
HueStep.literals.forEach((step) => {
|
||||
const color = scale[step]
|
||||
if (!color || (!overwrite && references.has(color.toLowerCase()))) return
|
||||
references.set(color.toLowerCase(), `$hue.${name}.${step}`)
|
||||
})
|
||||
}
|
||||
chromaticHues.forEach((name) => index(name, false))
|
||||
index("gray", false)
|
||||
index("accent", true)
|
||||
index("interactive", true)
|
||||
index("neutral", true)
|
||||
|
||||
function replace(value: unknown): unknown {
|
||||
if (typeof value === "string") return references.get(value.toLowerCase()) ?? value
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item)]))
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(theme).map(([key, value]) => [key, key === "hue" || key === "categorical" ? value : replace(value)]),
|
||||
) as FileThemeDefinition
|
||||
}
|
||||
|
||||
function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
const colors: readonly [V1HueToken, RGBA][] = [
|
||||
["accent", theme.accent],
|
||||
["success", theme.success],
|
||||
["warning", theme.warning],
|
||||
["primary", theme.primary],
|
||||
["error", theme.error],
|
||||
["info", theme.info],
|
||||
["secondary", theme.secondary],
|
||||
]
|
||||
const inferred = colors.reduce<{
|
||||
byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
||||
byToken: Partial<Record<V1HueToken, ChromaticHue>>
|
||||
}>(
|
||||
(result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
if (!nearest) return result
|
||||
const current = result.byHue[nearest.name]
|
||||
return {
|
||||
byHue:
|
||||
current && current.distance <= nearest.distance
|
||||
? result.byHue
|
||||
: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
||||
byToken: { ...result.byToken, [token]: nearest.name },
|
||||
}
|
||||
},
|
||||
{ byHue: {}, byToken: {} },
|
||||
)
|
||||
return (
|
||||
[
|
||||
["accent", theme.accent],
|
||||
["primary", theme.primary],
|
||||
] as const
|
||||
).reduce((result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
if (!nearest) return result
|
||||
return {
|
||||
byHue: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
||||
byToken: { ...result.byToken, [token]: nearest.name },
|
||||
}
|
||||
}, inferred)
|
||||
}
|
||||
|
||||
function inferHue(color: RGBA, mode: Mode) {
|
||||
const value = toOklch(color)
|
||||
if (ambiguous(color, value.c)) return
|
||||
const anchor = inferenceAnchor(value.l)
|
||||
return chromaticHues
|
||||
.map((name) => ({
|
||||
name,
|
||||
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
||||
}))
|
||||
.sort((first, second) => first.distance - second.distance)[0]
|
||||
}
|
||||
|
||||
function inferenceAnchor(lightness: number): HueStep {
|
||||
return lightness >= lightThreshold ? 300 : 700
|
||||
}
|
||||
|
||||
function hueDistance(first: number, second: number) {
|
||||
const difference = Math.abs(first - second)
|
||||
return Math.min(difference, 360 - difference)
|
||||
}
|
||||
|
||||
function ambiguous(color: RGBA, chroma = toOklch(color).c) {
|
||||
return color.toInts()[3] === 0 || chroma < minimumChroma
|
||||
}
|
||||
|
||||
function resolveV1(theme: ThemeV1Json, mode: "dark" | "light"): Theme {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
function resolveColor(value: unknown, chain: string[] = []): RGBA {
|
||||
if (value instanceof RGBA) return value
|
||||
if (typeof value === "string") {
|
||||
if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
if (value.startsWith("#")) return RGBA.fromHex(value)
|
||||
if (chain.includes(value)) throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
|
||||
const next = defs[value] ?? theme.theme[value as ThemeColor]
|
||||
if (next === undefined) throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||
return resolveColor(next, [...chain, value])
|
||||
}
|
||||
if (typeof value === "number") return ansi(value)
|
||||
if (!value || typeof value !== "object" || !(mode in value)) throw new Error("Invalid V1 theme color")
|
||||
return resolveColor((value as Record<"dark" | "light", unknown>)[mode], chain)
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(theme.theme)
|
||||
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||
.map(([key, value]) => [key, resolveColor(value)]),
|
||||
) as Partial<Record<ThemeColor, RGBA>>
|
||||
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
|
||||
resolved.selectedListItemText = hasSelectedListItemText
|
||||
? resolveColor(theme.theme.selectedListItemText)
|
||||
: resolved.background
|
||||
resolved.backgroundMenu = theme.theme.backgroundMenu
|
||||
? resolveColor(theme.theme.backgroundMenu)
|
||||
: resolved.backgroundElement
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
_hasSelectedListItemText: hasSelectedListItemText,
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
} as Theme
|
||||
}
|
||||
|
||||
function selectedForeground(theme: Theme, background: RGBA) {
|
||||
if (theme._hasSelectedListItemText) return theme.selectedListItemText
|
||||
if (theme.background.a !== 0) return theme.background
|
||||
return 0.299 * background.r + 0.587 * background.g + 0.114 * background.b > 0.5
|
||||
? RGBA.fromInts(0, 0, 0)
|
||||
: RGBA.fromInts(255, 255, 255)
|
||||
}
|
||||
|
||||
function hueScale(color: RGBA, mode: "light" | "dark") {
|
||||
const value = toOklch(color)
|
||||
const anchor = mode === "light" ? 800 : 200
|
||||
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
|
||||
const alpha = color.toInts()[3]
|
||||
return Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
if (step === anchor) return [step, hex(color)]
|
||||
const progress = mode === "light" ? (anchor - step) / (anchor - 100) : (step - anchor) / (900 - anchor)
|
||||
const generated = oklchToHex({
|
||||
l: value.l + (endpoint - value.l) * progress,
|
||||
c: value.c * (1 - progress * 0.5),
|
||||
h: value.h,
|
||||
})
|
||||
return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
|
||||
}),
|
||||
) as Record<HueStep, string>
|
||||
}
|
||||
|
||||
function neutralScale(theme: Theme, mode: "light" | "dark") {
|
||||
const anchors = neutralAnchors(theme, mode)
|
||||
return Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
const exact = anchors.find((anchor) => anchor.step === step)
|
||||
if (exact) return [step, hex(exact.color)]
|
||||
const first = anchors[0]!
|
||||
const last = anchors.at(-1)!
|
||||
const [lower, upper] =
|
||||
step < first.step
|
||||
? [first, anchors[1]!]
|
||||
: step > last.step
|
||||
? [anchors.at(-2)!, last]
|
||||
: [anchors.filter((anchor) => anchor.step < step).at(-1)!, anchors.find((anchor) => anchor.step > step)!]
|
||||
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
|
||||
}),
|
||||
) as Record<HueStep, string>
|
||||
}
|
||||
|
||||
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
||||
const light: { step: HueStep; color: RGBA }[] = [
|
||||
{ step: 200, color: theme.background },
|
||||
{ step: 300, color: theme.backgroundPanel },
|
||||
{ step: 400, color: theme.backgroundElement || theme.backgroundMenu },
|
||||
{ step: 600, color: theme.textMuted },
|
||||
{ step: 800, color: theme.text },
|
||||
]
|
||||
if (mode === "light") return light
|
||||
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
|
||||
}
|
||||
|
||||
function interpolate(first: RGBA, second: RGBA, amount: number) {
|
||||
const start = toOklch(first)
|
||||
const end = toOklch(second)
|
||||
const startHue = Number.isFinite(start.h) ? start.h : Number.isFinite(end.h) ? end.h : 0
|
||||
const endHue = Number.isFinite(end.h) ? end.h : startHue
|
||||
const hue = ((((endHue - startHue) % 360) + 540) % 360) - 180
|
||||
const generated = oklchToHex({
|
||||
l: start.l + (end.l - start.l) * amount,
|
||||
c: start.c + (end.c - start.c) * amount,
|
||||
h: startHue + hue * amount,
|
||||
})
|
||||
const alpha = Math.max(
|
||||
0,
|
||||
Math.min(255, Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)),
|
||||
)
|
||||
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
|
||||
}
|
||||
|
||||
function toOklch(color: RGBA) {
|
||||
const [red, green, blue] = color.toInts()
|
||||
return rgbToOklch(red / 255, green / 255, blue / 255)
|
||||
}
|
||||
|
||||
function hex(color: RGBA) {
|
||||
return hexInts(...color.toInts())
|
||||
}
|
||||
|
||||
function hexInts(r: number, g: number, b: number, a: number) {
|
||||
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
||||
}
|
||||
|
||||
function byte(value: number) {
|
||||
return value.toString(16).padStart(2, "0")
|
||||
}
|
||||
|
||||
function ansi(code: number) {
|
||||
if (code < 16) {
|
||||
const colors = [
|
||||
"#000000",
|
||||
"#800000",
|
||||
"#008000",
|
||||
"#808000",
|
||||
"#000080",
|
||||
"#800080",
|
||||
"#008080",
|
||||
"#c0c0c0",
|
||||
"#808080",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#ffff00",
|
||||
"#0000ff",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
"#ffffff",
|
||||
]
|
||||
return RGBA.fromHex(colors[code] ?? "#000000")
|
||||
}
|
||||
if (code < 232) {
|
||||
const index = code - 16
|
||||
const value = (part: number) => (part === 0 ? 0 : part * 40 + 55)
|
||||
return RGBA.fromInts(value(Math.floor(index / 36)), value(Math.floor(index / 6) % 6), value(index % 6))
|
||||
}
|
||||
if (code < 256) {
|
||||
const gray = (code - 232) * 10 + 8
|
||||
return RGBA.fromInts(gray, gray, gray)
|
||||
}
|
||||
return RGBA.fromInts(0, 0, 0)
|
||||
}
|
||||
76
packages/theme/src/tui/v1.ts
Normal file
76
packages/theme/src/tui/v1.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
|
||||
export type Theme = {
|
||||
readonly primary: RGBA
|
||||
readonly secondary: RGBA
|
||||
readonly accent: RGBA
|
||||
readonly error: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly success: RGBA
|
||||
readonly info: RGBA
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly selectedListItemText: RGBA
|
||||
readonly background: RGBA
|
||||
readonly backgroundPanel: RGBA
|
||||
readonly backgroundElement: RGBA
|
||||
readonly backgroundMenu: RGBA
|
||||
readonly border: RGBA
|
||||
readonly borderActive: RGBA
|
||||
readonly borderSubtle: RGBA
|
||||
readonly diffAdded: RGBA
|
||||
readonly diffRemoved: RGBA
|
||||
readonly diffContext: RGBA
|
||||
readonly diffHunkHeader: RGBA
|
||||
readonly diffHighlightAdded: RGBA
|
||||
readonly diffHighlightRemoved: RGBA
|
||||
readonly diffAddedBg: RGBA
|
||||
readonly diffRemovedBg: RGBA
|
||||
readonly diffContextBg: RGBA
|
||||
readonly diffLineNumber: RGBA
|
||||
readonly diffAddedLineNumberBg: RGBA
|
||||
readonly diffRemovedLineNumberBg: RGBA
|
||||
readonly markdownText: RGBA
|
||||
readonly markdownHeading: RGBA
|
||||
readonly markdownLink: RGBA
|
||||
readonly markdownLinkText: RGBA
|
||||
readonly markdownCode: RGBA
|
||||
readonly markdownBlockQuote: RGBA
|
||||
readonly markdownEmph: RGBA
|
||||
readonly markdownStrong: RGBA
|
||||
readonly markdownHorizontalRule: RGBA
|
||||
readonly markdownListItem: RGBA
|
||||
readonly markdownListEnumeration: RGBA
|
||||
readonly markdownImage: RGBA
|
||||
readonly markdownImageText: RGBA
|
||||
readonly markdownCodeBlock: RGBA
|
||||
readonly syntaxComment: RGBA
|
||||
readonly syntaxKeyword: RGBA
|
||||
readonly syntaxFunction: RGBA
|
||||
readonly syntaxVariable: RGBA
|
||||
readonly syntaxString: RGBA
|
||||
readonly syntaxNumber: RGBA
|
||||
readonly syntaxType: RGBA
|
||||
readonly syntaxOperator: RGBA
|
||||
readonly syntaxPunctuation: RGBA
|
||||
readonly thinkingOpacity: number
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
|
||||
export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
export type HexColor = `#${string}`
|
||||
export type RefName = string
|
||||
export type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeV1Json = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
selectedListItemText?: ColorValue
|
||||
backgroundMenu?: ColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue