mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(theme): align hue direction with mode (#49820)
This commit is contained in:
+125
-125
@@ -14,92 +14,92 @@ export const DEFAULT_THEME = {
|
||||
light: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
100: "#111827",
|
||||
200: "#1f2937",
|
||||
300: "#374151",
|
||||
400: "#4b5563",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
600: "#9ca3af",
|
||||
700: "#d1d5db",
|
||||
800: "#e5e7eb",
|
||||
900: "#f3f4f6",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
100: "#7f1d1d",
|
||||
200: "#991b1b",
|
||||
300: "#b91c1c",
|
||||
400: "#dc2626",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
600: "#f87171",
|
||||
700: "#fca5a5",
|
||||
800: "#fecaca",
|
||||
900: "#fee2e2",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
100: "#7c2d12",
|
||||
200: "#9a3412",
|
||||
300: "#c2410c",
|
||||
400: "#ea580c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
600: "#fb923c",
|
||||
700: "#fdba74",
|
||||
800: "#fed7aa",
|
||||
900: "#ffedd5",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
100: "#713f12",
|
||||
200: "#854d0e",
|
||||
300: "#a16207",
|
||||
400: "#ca8a04",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
600: "#facc15",
|
||||
700: "#fde047",
|
||||
800: "#fef08a",
|
||||
900: "#fef9c3",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
100: "#14532d",
|
||||
200: "#166534",
|
||||
300: "#15803d",
|
||||
400: "#16a34a",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
600: "#4ade80",
|
||||
700: "#86efac",
|
||||
800: "#bbf7d0",
|
||||
900: "#dcfce7",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
100: "#164e63",
|
||||
200: "#155e75",
|
||||
300: "#0e7490",
|
||||
400: "#0891b2",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
600: "#22d3ee",
|
||||
700: "#67e8f9",
|
||||
800: "#a5f3fc",
|
||||
900: "#cffafe",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
100: "#1e3a8a",
|
||||
200: "#1e40af",
|
||||
300: "#1d4ed8",
|
||||
400: "#2563eb",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
600: "#60a5fa",
|
||||
700: "#93c5fd",
|
||||
800: "#bfdbfe",
|
||||
900: "#dbeafe",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
100: "#581c87",
|
||||
200: "#6b21a8",
|
||||
300: "#7e22ce",
|
||||
400: "#9333ea",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
600: "#c084fc",
|
||||
700: "#d8b4fe",
|
||||
800: "#e9d5ff",
|
||||
900: "#f3e8ff",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
@@ -107,64 +107,64 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.800",
|
||||
subdued: "$hue.neutral.600",
|
||||
default: "$hue.neutral.200",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
primary: { default: "$hue.neutral.800", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$text.subdued", $hovered: "$text.default" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.800", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.800",
|
||||
default: "$hue.neutral.200",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$pressed: "$hue.neutral.800",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.700",
|
||||
$selected: "$hue.interactive.300",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.800",
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.800",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
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" },
|
||||
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.200",
|
||||
default: "$hue.neutral.800",
|
||||
raised: {
|
||||
base: "$hue.neutral.300",
|
||||
high: "$hue.neutral.400",
|
||||
base: "$hue.neutral.700",
|
||||
high: "$hue.neutral.600",
|
||||
max: "$hue.neutral.500",
|
||||
},
|
||||
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",
|
||||
default: "$hue.interactive.400",
|
||||
$hovered: "$hue.interactive.300",
|
||||
$focused: "$hue.interactive.300",
|
||||
$pressed: "$hue.interactive.200",
|
||||
$selected: "$hue.interactive.300",
|
||||
$disabled: "$hue.neutral.700",
|
||||
},
|
||||
secondary: { default: "transparent" },
|
||||
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",
|
||||
default: "$hue.red.400",
|
||||
$hovered: "$hue.red.300",
|
||||
$focused: "$hue.red.300",
|
||||
$pressed: "$hue.red.200",
|
||||
$selected: "$hue.red.300",
|
||||
$disabled: "$hue.neutral.700",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.raised.base",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$pressed: "$hue.interactive.200",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
@@ -175,51 +175,51 @@ export const DEFAULT_THEME = {
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.300" },
|
||||
scrollbar: { default: "$hue.neutral.400" },
|
||||
border: { default: "$hue.neutral.700" },
|
||||
scrollbar: { default: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.700",
|
||||
removed: "$hue.red.700",
|
||||
context: "$hue.neutral.900",
|
||||
hunkHeader: "$hue.purple.600",
|
||||
added: "$hue.green.300",
|
||||
removed: "$hue.red.300",
|
||||
context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||
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.600",
|
||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
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",
|
||||
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.600",
|
||||
punctuation: "$hue.neutral.900",
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
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",
|
||||
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.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",
|
||||
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",
|
||||
},
|
||||
"@dialog": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
text: { action: { primary: { default: "$hue.neutral.900" } } },
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } },
|
||||
|
||||
@@ -51,29 +51,24 @@ export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
|
||||
const core = expandTokens(fallback(selected.mode))
|
||||
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,
|
||||
selected.mode,
|
||||
)
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
|
||||
} as ThemeDefinition)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)), mode)
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
|
||||
}
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
|
||||
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 raise = (color: RGBA) => (mode === "light" ? hueSteps.increase(color) : hueSteps.decrease(color))
|
||||
const base = tokens(definition)
|
||||
const views = {} as Record<SurfaceName, ResolvedTheme>
|
||||
const view = (tokens: ThemeTokensDefinition): ResolvedTheme => ({
|
||||
...resolveView(tokens, hue, categorical, hueSteps),
|
||||
raise,
|
||||
surface: (name) => views[name],
|
||||
})
|
||||
views.dialog = definition["@dialog"] ? view(contextualize(base, definition["@dialog"])) : view(base)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
|
||||
import type { Mode, ResolvedThemeTokens } from "./index.js"
|
||||
import type { ResolvedThemeTokens } from "./index.js"
|
||||
|
||||
export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
|
||||
const step = mode === "light" ? 800 : 200
|
||||
export function generateSyntax(theme: ResolvedThemeTokens) {
|
||||
const step = 200
|
||||
const syntax = theme.syntax
|
||||
const markdown = theme.markdown
|
||||
const feedback = theme.text.feedback
|
||||
|
||||
@@ -74,8 +74,6 @@ export type ResolvedThemeTokens = {
|
||||
}
|
||||
|
||||
export type ResolvedTheme = ResolvedThemeTokens & {
|
||||
/** Lift a ramp color one step away from the background: lighter in light mode, darker in dark mode. */
|
||||
readonly raise: (color: RGBA) => RGBA
|
||||
/** The same theme re-resolved on a raised surface. Absolute: every view's surfaces are the base theme's. */
|
||||
readonly surface: (name: SurfaceName) => ResolvedTheme
|
||||
}
|
||||
|
||||
@@ -51,17 +51,17 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
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"
|
||||
const text = "$hue.neutral.200"
|
||||
const textMuted = "$hue.neutral.400"
|
||||
const primary = "$hue.interactive.200"
|
||||
const background = "$hue.neutral.800"
|
||||
const backgroundPanel = "$hue.neutral.700"
|
||||
const backgroundMenu = "$hue.neutral.600"
|
||||
const backgroundRaisedMax = "$hue.neutral.500"
|
||||
|
||||
return referenceHues({
|
||||
hue: {
|
||||
gray: neutralScale(theme, mode),
|
||||
gray: neutralScale(theme),
|
||||
...Object.fromEntries(
|
||||
chromaticHues.map((name) => {
|
||||
const match = hues.byHue[name]
|
||||
@@ -277,7 +277,7 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
function inferHue(color: RGBA, mode: Mode) {
|
||||
const value = toOklch(color)
|
||||
if (ambiguous(color, value.c)) return
|
||||
const anchor = inferenceAnchor(value.l)
|
||||
const anchor = inferenceAnchor(value.l, mode)
|
||||
return chromaticHues
|
||||
.map((name) => ({
|
||||
name,
|
||||
@@ -286,7 +286,8 @@ function inferHue(color: RGBA, mode: Mode) {
|
||||
.sort((first, second) => first.distance - second.distance)[0]
|
||||
}
|
||||
|
||||
function inferenceAnchor(lightness: number): HueStep {
|
||||
function inferenceAnchor(lightness: number, mode: Mode): HueStep {
|
||||
if (mode === "light") return lightness >= lightThreshold ? 700 : 300
|
||||
return lightness >= lightThreshold ? 300 : 700
|
||||
}
|
||||
|
||||
@@ -347,13 +348,13 @@ function selectedForeground(theme: Theme, background: RGBA) {
|
||||
|
||||
function hueScale(color: RGBA, mode: "light" | "dark") {
|
||||
const value = toOklch(color)
|
||||
const anchor = mode === "light" ? 800 : 200
|
||||
const anchor = 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 progress = (step - anchor) / (900 - anchor)
|
||||
const generated = oklchToHex({
|
||||
l: value.l + (endpoint - value.l) * progress,
|
||||
c: value.c * (1 - progress * 0.5),
|
||||
@@ -364,8 +365,8 @@ function hueScale(color: RGBA, mode: "light" | "dark") {
|
||||
) as Record<HueStep, string>
|
||||
}
|
||||
|
||||
function neutralScale(theme: Theme, mode: "light" | "dark") {
|
||||
const anchors = neutralAnchors(theme, mode)
|
||||
function neutralScale(theme: Theme) {
|
||||
const anchors = neutralAnchors(theme)
|
||||
return Object.fromEntries(
|
||||
HueStep.literals.map((step) => {
|
||||
const exact = anchors.find((anchor) => anchor.step === step)
|
||||
@@ -383,7 +384,7 @@ function neutralScale(theme: Theme, mode: "light" | "dark") {
|
||||
) as Record<HueStep, string>
|
||||
}
|
||||
|
||||
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
||||
function neutralAnchors(theme: Theme) {
|
||||
const light: { step: HueStep; color: RGBA }[] = [
|
||||
{ step: 200, color: theme.background },
|
||||
{ step: 300, color: theme.backgroundPanel },
|
||||
@@ -391,7 +392,6 @@ function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
||||
{ 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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors"
|
||||
).json()
|
||||
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
|
||||
const theme = resolveThemeDocument(document, mode)
|
||||
expect(theme.text.status.running.equals(theme.hue.interactive[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.running.equals(theme.hue.interactive[200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.accent[200])).toBeTrue()
|
||||
expect(theme.surface("dialog").text.status).toEqual(theme.text.status)
|
||||
}
|
||||
})
|
||||
@@ -53,8 +53,8 @@ test.each(["light", "dark"] as const)("custom %s themes inherit and override sta
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.running.equals(theme.hue.purple[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.orange[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.running.equals(theme.hue.purple[200])).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.orange[200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(RGBA.fromHex("#654321"))).toBeTrue()
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ export function DevToolsBar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
|
||||
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.decrease(theme.background.default)}>
|
||||
<Show when={panel()}>
|
||||
<box
|
||||
position="absolute"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { locationKey, useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
@@ -43,9 +43,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
const location = useLocation()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const toast = useToast()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme().surface("dialog")
|
||||
const mode = themes.mode
|
||||
const paths = useTuiPaths()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
@@ -201,7 +199,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
gutter: running
|
||||
? (color: RGBA) => <Spinner color={color} />
|
||||
: tabs.has(session.id)
|
||||
? () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>▪</text>
|
||||
? () => <text fg={theme.hue.accent[200]}>▪</text>
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocal } from "../context/local"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
@@ -29,9 +29,7 @@ export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme().surface("dialog")
|
||||
const mode = themes.mode
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
@@ -178,7 +176,7 @@ export function DialogSessionList() {
|
||||
? (color: RGBA) => <Spinner color={color} />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
: () => <text fg={theme.hue.accent[200]}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1642,7 +1642,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
|
||||
const promptBg = createMemo(() => theme.raise(theme.background.raised.base))
|
||||
const promptBg = createMemo(() => theme.decrease(theme.background.raised.base))
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1535,7 +1535,7 @@ function HorizontalSessionTabs(props: {
|
||||
const lifted = (hovered() === tab.sessionID || dragged()) && !selected()
|
||||
const base = lifted ? theme.background.action.primary.hovered : theme.background.default
|
||||
// A dragged tab lifts to full selected elevation while it is held.
|
||||
return tint(base, theme.raise(theme.background.raised.base), dragged() ? 1 : selection())
|
||||
return tint(base, theme.decrease(theme.background.raised.base), dragged() ? 1 : selection())
|
||||
})
|
||||
const pulseColor = () => tint(background(), theme.text.default, 0.45)
|
||||
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
|
||||
|
||||
@@ -156,7 +156,7 @@ export function TerminalPane(props: {
|
||||
|
||||
createEffect(() => {
|
||||
const tokens = themes.currentTokens()
|
||||
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.raised.base)
|
||||
terminalTheme = terminalPalette(tokens, tokens.background.raised.base)
|
||||
applyTerminalTheme()
|
||||
})
|
||||
|
||||
@@ -335,9 +335,9 @@ function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefi
|
||||
return !!first && !!second && first.cols === second.cols && first.rows === second.rows
|
||||
}
|
||||
|
||||
function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", background: RGBA) {
|
||||
const base = mode === "dark" ? 200 : 800
|
||||
const bright = mode === "dark" ? 100 : 900
|
||||
function terminalPalette(theme: ResolvedThemeTokens, background: RGBA) {
|
||||
const base = 200
|
||||
const bright = 100
|
||||
const colors = [
|
||||
background,
|
||||
theme.text.feedback.error.default,
|
||||
@@ -354,7 +354,7 @@ function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", bac
|
||||
theme.hue.blue[bright],
|
||||
theme.hue.purple[bright],
|
||||
theme.hue.cyan[bright],
|
||||
theme.hue.neutral[mode === "dark" ? 100 : 900],
|
||||
theme.hue.neutral[100],
|
||||
]
|
||||
return Buffer.from(
|
||||
colors
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type ModelPreference,
|
||||
type ModelPreferenceModel,
|
||||
} from "../model-preference"
|
||||
import { useTheme, useThemes } from "./theme"
|
||||
import { useTheme } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
import { useData } from "./data"
|
||||
@@ -32,7 +32,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const args = useArgs()
|
||||
@@ -74,7 +73,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
const colors = createMemo(() => {
|
||||
const step = mode() === "light" ? 800 : 200
|
||||
const step = 200
|
||||
return dedupeWith(
|
||||
theme.categorical.map((scale) => scale[step]),
|
||||
(first, second) => first.equals(second),
|
||||
|
||||
@@ -326,7 +326,7 @@ const themeContext = createSimpleContext({
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
|
||||
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens(), mode()))
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens()))
|
||||
const service: Themes = {
|
||||
current,
|
||||
currentTokens: tokens,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { Logo } from "../../component/logo"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { statsMetrics, statsNumber } from "./stats-data"
|
||||
|
||||
@@ -30,7 +30,6 @@ const digits: Record<string, string[]> = {
|
||||
export function StatsPoster(props: { stats: SessionStatsInfo }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const width = () => Math.max(12, Math.min(110, dimensions().width - 8))
|
||||
const compact = () => dimensions().height < 38
|
||||
const metrics = createMemo(() => statsMetrics(props.stats))
|
||||
@@ -55,7 +54,7 @@ export function StatsPoster(props: { stats: SessionStatsInfo }) {
|
||||
const shades = createMemo(() => [
|
||||
theme.text.subdued,
|
||||
...[0.3, 0.5, 0.75, 1].map((alpha) =>
|
||||
tint(theme.background.default, theme.categorical[0][themes.mode() === "light" ? 800 : 200], alpha),
|
||||
tint(theme.background.default, theme.categorical[0][200], alpha),
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
@@ -111,7 +111,6 @@ function nearestIndexed(indexed: RGBA[], color: RGBA): RGBA {
|
||||
function map(
|
||||
theme: ResolvedTheme,
|
||||
indexed: RGBA[],
|
||||
mode: "light" | "dark",
|
||||
syntax?: SyntaxStyle,
|
||||
system = false,
|
||||
): RunTheme {
|
||||
@@ -149,7 +148,7 @@ function map(
|
||||
success: exact(theme.text.feedback.success.default),
|
||||
link: exact(theme.markdown.link),
|
||||
categorical: dedupeWith(
|
||||
theme.categorical.map((scale) => exact(scale[mode === "light" ? 800 : 200])),
|
||||
theme.categorical.map((scale) => exact(scale[200])),
|
||||
(a, b) => a.equals(b),
|
||||
),
|
||||
warning: exact(theme.text.feedback.warning.default),
|
||||
@@ -195,12 +194,10 @@ function map(
|
||||
export const RUN_THEME_FALLBACK = map(
|
||||
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "dark"),
|
||||
ansiPalette,
|
||||
"dark",
|
||||
)
|
||||
export const RUN_THEME_FALLBACK_LIGHT = map(
|
||||
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "light"),
|
||||
ansiPalette,
|
||||
"light",
|
||||
)
|
||||
|
||||
function monoTheme(mode: "dark" | "light"): RunTheme {
|
||||
@@ -300,7 +297,7 @@ export async function resolveRunTheme(
|
||||
? ansiPalette.map((color, index) => (colors.palette[index] ? RGBA.fromIndex(index, colors.palette[index]!) : color))
|
||||
: ansiPalette
|
||||
return {
|
||||
...map(theme, indexed, mode, generateSyntax(theme, mode), name === "system" && resolved !== undefined),
|
||||
...map(theme, indexed, generateSyntax(theme), name === "system" && resolved !== undefined),
|
||||
background: RGBA.defaultBackground(colors?.defaultBackground ?? theme.background.default),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,7 +1292,7 @@ export function Session(props: {
|
||||
paddingLeft: 1,
|
||||
visible: showScrollbar(),
|
||||
trackOptions: {
|
||||
backgroundColor: theme.raise(theme.background.raised.base),
|
||||
backgroundColor: theme.decrease(theme.background.raised.base),
|
||||
foregroundColor: theme.border.default,
|
||||
},
|
||||
}}
|
||||
@@ -1836,7 +1836,7 @@ function SessionReasoningGroupView(props: {
|
||||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.raised.base)}
|
||||
borderColor={theme.decrease(theme.background.raised.base)}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<code
|
||||
@@ -2210,7 +2210,7 @@ function RevertMessage(props: {
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
|
||||
backgroundColor={hover() ? theme.decrease(theme.background.raised.base) : theme.background.raised.base}
|
||||
>
|
||||
<text fg={theme.text.subdued}>
|
||||
{props.count} message{props.count === 1 ? "" : "s"} reverted
|
||||
@@ -2339,7 +2339,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
|
||||
backgroundColor={hover() ? theme.decrease(theme.background.raised.base) : theme.background.raised.base}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
@@ -2350,14 +2350,14 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
bg: theme.hue.accent[mode() === "light" ? 300 : 200],
|
||||
fg: theme.background.raised.base,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{" skill "}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.raised.base), fg: theme.text.subdued }}>
|
||||
<span style={{ bg: theme.decrease(theme.background.raised.base), fg: theme.text.subdued }}>
|
||||
{` ${skill.name} `}
|
||||
</span>
|
||||
</text>
|
||||
@@ -2374,14 +2374,14 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
bg: theme.hue.accent[mode() === "light" ? 300 : 200],
|
||||
fg: theme.background.raised.base,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{` ${label} `}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.raised.base), fg: theme.text.subdued }}>
|
||||
<span style={{ bg: theme.decrease(theme.background.raised.base), fg: theme.text.subdued }}>
|
||||
{" "}
|
||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||
</span>
|
||||
@@ -2417,7 +2417,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.raised.base) : theme.background.raised.base}
|
||||
backgroundColor={hover() ? theme.decrease(theme.background.raised.base) : theme.background.raised.base}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
@@ -2762,7 +2762,7 @@ function StatusBadge(props: { children: string; raised?: boolean }) {
|
||||
const theme = useTheme()
|
||||
const background = () => (props.raised ? theme.background.raised.base : theme.background.default)
|
||||
return (
|
||||
<text flexShrink={0} bg={theme.raise(background())} fg={theme.text.subdued}>
|
||||
<text flexShrink={0} bg={theme.decrease(background())} fg={theme.text.subdued}>
|
||||
{" "}
|
||||
{props.children}{" "}
|
||||
</text>
|
||||
@@ -2783,7 +2783,7 @@ type BlockToolProps = {
|
||||
|
||||
function BlockTool(props: BlockToolProps) {
|
||||
const theme = useTheme()
|
||||
const background = theme.background.raised.base
|
||||
const background = () => theme.background.raised.base
|
||||
const ctx = use()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
@@ -2799,7 +2799,7 @@ function BlockTool(props: BlockToolProps) {
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
gap={1}
|
||||
backgroundColor={hover() ? theme.raise(background) : background}
|
||||
backgroundColor={hover() ? theme.decrease(background()) : background()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.background.default}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
@@ -3234,7 +3234,7 @@ function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const input = createMemo(() => Object.entries(props.call().input ?? {}))
|
||||
const expandable = createMemo(() => input().length > 0)
|
||||
const expandedColor = createMemo(() => theme.raise(theme.text.subdued))
|
||||
const expandedColor = createMemo(() => theme.decrease(theme.text.subdued))
|
||||
const color = createMemo(() => {
|
||||
if (props.call().status === "error") return theme.text.feedback.error.default
|
||||
if (hover()) return theme.text.default
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ReasoningPart(props: {
|
||||
<box
|
||||
border={!inMinimal() || expanded() ? ["left"] : undefined}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.default)}
|
||||
borderColor={theme.decrease(theme.background.default)}
|
||||
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
|
||||
>
|
||||
<box onMouseUp={toggle}>
|
||||
@@ -70,7 +70,7 @@ export function ReasoningPart(props: {
|
||||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.default)}
|
||||
borderColor={theme.decrease(theme.background.default)}
|
||||
paddingLeft={inMinimal() ? 3 : 1}
|
||||
>
|
||||
<code
|
||||
|
||||
@@ -335,7 +335,7 @@ function RejectPrompt(props: {
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.raise(theme.background.raised.base)}
|
||||
backgroundColor={theme.decrease(theme.background.raised.base)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
gap={1}
|
||||
@@ -546,7 +546,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.raise(theme.background.raised.base)}
|
||||
backgroundColor={theme.decrease(theme.background.raised.base)}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
|
||||
@@ -38,7 +38,6 @@ export function createComponentTheme(
|
||||
source: (color: RGBA) => view().source(color),
|
||||
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
|
||||
raise: (color: RGBA) => view().raise(color),
|
||||
surface(name: SurfaceName) {
|
||||
const cached = surfaces.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CliRenderEvents, InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Keymap, type KeymapCommand } from "../context/keymap"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
|
||||
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -106,9 +106,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
type VisibleAction = (Action & { label: string }) | FooterHint
|
||||
|
||||
const dialog = useDialog()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme().surface("dialog")
|
||||
const mode = themes.mode
|
||||
const config = useConfig().data
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const renderer = useRenderer()
|
||||
@@ -718,7 +716,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<Show
|
||||
when={options[0]?.categoryView}
|
||||
fallback={
|
||||
<text fg={theme.hue.accent[mode() === "light" ? 800 : 200]} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.hue.accent[200]} attributes={TextAttributes.BOLD}>
|
||||
{category}
|
||||
</text>
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ test("dialog surfaces are absolute and can be inherited through the theme contex
|
||||
expect(dialog.background.default).toBe(themes.currentTokens().background.raised.base)
|
||||
expect(contextual.background.default).toBe(dialog.background.default)
|
||||
expect(contextual.text.default).toBe(dialog.text.default)
|
||||
expect(dialog.raise(dialog.background.raised.base)).toBe(themes.currentTokens().hue.neutral[600])
|
||||
expect(dialog.decrease(dialog.background.raised.base)).toBe(themes.currentTokens().hue.neutral[600])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ test.each(["light", "dark"] as const)("uses shared %s defaults and named built-i
|
||||
expectFooter(theme, expected)
|
||||
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
|
||||
expect(theme.footer.categorical.map((color) => rgba(color).toInts())).toEqual(
|
||||
expected.categorical.map((scale) => scale[mode === "light" ? 800 : 200].toInts()),
|
||||
expected.categorical.map((scale) => scale[200].toInts()),
|
||||
)
|
||||
expect(theme.block.syntax?.getAllStyles().size).toBeGreaterThan(0)
|
||||
for (const color of [
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DEFAULT_THEME, resolveTheme, selectTheme } from "@opencode/theme/tui"
|
||||
import { createComponentTheme } from "../../../src/theme/component"
|
||||
|
||||
test("provides reactive properties, states, surfaces, and color operations", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"), "light"))
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
const theme = createComponentTheme(resolved)
|
||||
const current = theme.surface("dialog")
|
||||
|
||||
@@ -14,12 +14,12 @@ test("provides reactive properties, states, surfaces, and color operations", ()
|
||||
expect(theme.hue.interactive[500]).toBe(resolved().hue.interactive[500])
|
||||
expect(theme.hue.gray[200]).toBe(resolved().hue.gray[200])
|
||||
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(theme.increase(theme.background.raised.base, 1)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.raise(theme.background.raised.base)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.increase(theme.background.raised.base, 1)).toBe(resolved().hue.neutral[800])
|
||||
expect(theme.decrease(theme.background.raised.base)).toBe(resolved().hue.neutral[600])
|
||||
expect(theme.decrease(theme.hue.red[300], 2)).toBe(resolved().hue.red[100])
|
||||
expect(theme.increase(theme.hue.red[900], 3)).toBe(resolved().hue.red[900])
|
||||
expect(theme.decrease(theme.hue.red[100], 3)).toBe(resolved().hue.red[100])
|
||||
expect(theme.source(theme.background.raised.base)).toEqual({ hue: "neutral", step: 300 })
|
||||
expect(theme.source(theme.background.raised.base)).toEqual({ hue: "neutral", step: 700 })
|
||||
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
|
||||
expect(theme.source(equivalent)).toBeUndefined()
|
||||
expect(theme.increase(equivalent, 1)).toBe(equivalent)
|
||||
@@ -78,9 +78,9 @@ test("provides reactive properties, states, surfaces, and color operations", ()
|
||||
expect(current.background.action.primary.hovered).toBe(resolved().background.raised.high)
|
||||
expect(current.background.formfield.selected).toBe(resolved().surface("dialog").background.formfield.selected)
|
||||
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark"), "dark"))
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
expect(current.text.default).toBe(resolved().surface("dialog").text.default)
|
||||
expect(current.background.default).toBe(resolved().background.raised.base)
|
||||
expect(current.decrease(current.background.raised.base, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(current.raise(current.background.raised.base)).toBe(resolved().hue.neutral[600])
|
||||
expect(current.decrease(current.background.raised.base)).toBe(resolved().hue.neutral[600])
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
BaseHue,
|
||||
DEFAULT_THEME,
|
||||
generateSyntax,
|
||||
resolveTheme,
|
||||
@@ -14,6 +15,17 @@ import { parseTheme, type ThemeDocumentSource } from "../../../src/theme"
|
||||
const light = selectTheme(DEFAULT_THEME, "light")
|
||||
const dark = selectTheme(DEFAULT_THEME, "dark")
|
||||
|
||||
test("orders light hues dark-to-light and dark hues light-to-dark", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
const luminance = (color: RGBA) => 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
|
||||
BaseHue.literals.forEach((name) => {
|
||||
expect(luminance(lightTheme.hue[name][100])).toBeLessThan(luminance(lightTheme.hue[name][900]))
|
||||
expect(luminance(darkTheme.hue[name][100])).toBeGreaterThan(luminance(darkTheme.hue[name][900]))
|
||||
})
|
||||
})
|
||||
|
||||
function resolveSource(source: ThemeDocumentSource, mode?: Mode, name?: string) {
|
||||
return resolveThemeDocument(parseTheme(source, name), mode)
|
||||
}
|
||||
@@ -22,8 +34,8 @@ test("resolves one-mode documents with defaults for the available mode", () => {
|
||||
const resolvedLight = resolveSource({ version: 2, light: {} }, "dark")
|
||||
const resolvedDark = resolveSource({ version: 2, dark: {} }, "light")
|
||||
|
||||
expect(resolvedLight.background.default.equals(resolveTheme(light, "light").background.default)).toBeTrue()
|
||||
expect(resolvedDark.background.default.equals(resolveTheme(dark, "dark").background.default)).toBeTrue()
|
||||
expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
|
||||
expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
|
||||
expect(resolvedLight.categorical.length).toBeGreaterThan(0)
|
||||
expect(resolvedDark.categorical.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -45,22 +57,22 @@ test("validates and resolves categorical hues in configured order", () => {
|
||||
|
||||
test("generates syntax with one categorical hue", () => {
|
||||
const theme = resolveSource({ version: 2, light: { categorical: ["red"] } }, "light")
|
||||
const syntax = generateSyntax(theme, "light")
|
||||
const syntax = generateSyntax(theme)
|
||||
|
||||
expect(syntax.getStyleId("extmark.skill")).not.toBeNull()
|
||||
syntax.destroy()
|
||||
})
|
||||
|
||||
test("uses the default categorical order for direct definitions", () => {
|
||||
const theme = resolveTheme({ ...light, categorical: undefined }, "light")
|
||||
const theme = resolveTheme({ ...light, categorical: undefined })
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.blue)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.purple)
|
||||
})
|
||||
|
||||
test("resolves independent definitions and hue aliases", () => {
|
||||
const lightTheme = resolveTheme(light, "light")
|
||||
const darkTheme = resolveTheme(dark, "dark")
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
|
||||
expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue()
|
||||
@@ -71,18 +83,18 @@ test("resolves independent definitions and hue aliases", () => {
|
||||
expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
|
||||
expect(lightTheme.source(lightTheme.background.raised.base)).toEqual({ hue: "neutral", step: 300 })
|
||||
expect(lightTheme.source(lightTheme.background.raised.base)).toEqual({ hue: "neutral", step: 700 })
|
||||
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
|
||||
expect(lightTheme.surface("dialog").increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.raise(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(darkTheme.raise(darkTheme.hue.red[200])).toBe(darkTheme.hue.red[100])
|
||||
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
|
||||
expect(darkTheme.decrease(darkTheme.hue.red[200])).toBe(darkTheme.hue.red[100])
|
||||
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
|
||||
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[300])
|
||||
expect(lightTheme.background.raised.high).toBe(lightTheme.hue.neutral[400])
|
||||
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[700])
|
||||
expect(lightTheme.background.raised.high).toBe(lightTheme.hue.neutral[600])
|
||||
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
|
||||
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[800])
|
||||
// Surfaces re-resolve the palette after applying their theme-provided overrides.
|
||||
const dialog = lightTheme.surface("dialog")
|
||||
expect(dialog.background.default).toBe(lightTheme.background.raised.base)
|
||||
@@ -91,7 +103,7 @@ test("resolves independent definitions and hue aliases", () => {
|
||||
expect(dialog.background.action.primary.hovered).toBe(lightTheme.background.raised.high)
|
||||
expect(dialog.background.action.primary.default).toBe(lightTheme.hue.interactive[500])
|
||||
expect(dialog.background.action.primary.focused).toBe(lightTheme.hue.interactive[500])
|
||||
expect(dialog.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(dialog.text.action.primary.default).toBe(lightTheme.hue.neutral[900])
|
||||
expect(dialog.surface("dialog")).toBe(dialog)
|
||||
expect(darkTheme.surface("dialog").background.default).toBe(darkTheme.background.raised.base)
|
||||
})
|
||||
@@ -102,7 +114,6 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||
...light,
|
||||
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
||||
},
|
||||
"light",
|
||||
)
|
||||
const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
|
||||
|
||||
@@ -121,7 +132,6 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||
...light,
|
||||
hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" },
|
||||
},
|
||||
"light",
|
||||
),
|
||||
).toThrow("Circular hue reference: red -> blue -> red")
|
||||
})
|
||||
@@ -137,7 +147,6 @@ test("steps by hue source when adjacent colors have equal values", () => {
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
},
|
||||
"light",
|
||||
)
|
||||
|
||||
expect(theme.hue.neutral[200]).not.toBe(theme.hue.neutral[300])
|
||||
@@ -210,7 +219,7 @@ test("expands user structural fallbacks before merging defaults", () => {
|
||||
expect(expanded.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(isolatedState.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
|
||||
expect(isolatedState.background.action.primary.focused.toInts()).toEqual(
|
||||
resolveTheme(light, "light").background.action.primary.focused.toInts(),
|
||||
resolveTheme(light).background.action.primary.focused.toInts(),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -238,7 +247,7 @@ test("uses defaults for the selected mode when it merges the other mode", () =>
|
||||
})
|
||||
|
||||
test("resolves matched action variants and states", () => {
|
||||
const theme = resolveTheme(light, "light")
|
||||
const theme = resolveTheme(light)
|
||||
|
||||
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
|
||||
expect(theme.text.action.primary.hovered).toBeInstanceOf(RGBA)
|
||||
@@ -303,7 +312,6 @@ test("surface overrides rewire references and reset action states from their def
|
||||
},
|
||||
},
|
||||
}),
|
||||
"light",
|
||||
)
|
||||
const raised = theme.surface("dialog")
|
||||
expect(raised.text.default.toInts()).toEqual([51, 51, 51, 255])
|
||||
@@ -312,7 +320,7 @@ test("surface overrides rewire references and reset action states from their def
|
||||
})
|
||||
|
||||
test("rejects missing and circular references", () => {
|
||||
expect(() => resolveTheme(override(light, { text: { default: "$missing" } }), "light")).toThrow(
|
||||
expect(() => resolveTheme(override(light, { text: { default: "$missing" } }))).toThrow(
|
||||
'Theme reference "$missing" was not found',
|
||||
)
|
||||
expect(() =>
|
||||
@@ -320,7 +328,6 @@ test("rejects missing and circular references", () => {
|
||||
override(light, {
|
||||
text: { default: "$text.subdued", subdued: "$text.default" },
|
||||
}),
|
||||
"light",
|
||||
),
|
||||
).toThrow("Circular theme reference")
|
||||
})
|
||||
@@ -332,7 +339,6 @@ test("validates complete hues, resolved groups, and hue-only syntax", () => {
|
||||
...light,
|
||||
hue: { ...light.hue, accent: "$hue.missing" },
|
||||
} as unknown as ThemeDefinition,
|
||||
"light",
|
||||
),
|
||||
).toThrow("$hue.missing")
|
||||
expect(() =>
|
||||
@@ -341,7 +347,6 @@ test("validates complete hues, resolved groups, and hue-only syntax", () => {
|
||||
...light,
|
||||
syntax: { ...light.syntax, keyword: "$text.default" },
|
||||
} as unknown as ThemeDefinition,
|
||||
"light",
|
||||
),
|
||||
).toThrow("$text.default")
|
||||
})
|
||||
|
||||
@@ -20,12 +20,12 @@ test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
expect(migrated.dark.categorical?.length).toBeGreaterThan(0)
|
||||
expect(migrated.light.hue?.accent).toMatch(/^\$hue\.[^.]+$/)
|
||||
expect(migrated.light.hue?.interactive).toMatch(/^\$hue\.[^.]+$/)
|
||||
expect(migrated.light.text?.default).toBe("$hue.neutral.800")
|
||||
expect(migrated.light.text?.subdued).toBe("$hue.neutral.600")
|
||||
expect(migrated.light.text?.default).toBe("$hue.neutral.200")
|
||||
expect(migrated.light.text?.subdued).toBe("$hue.neutral.400")
|
||||
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
|
||||
expect(migrated.light.background?.default).toBe("$hue.neutral.200")
|
||||
expect(migrated.light.background?.raised?.base).toBe("$hue.neutral.300")
|
||||
expect(migrated.light.background?.raised?.high).toBe("$hue.neutral.400")
|
||||
expect(migrated.light.background?.default).toBe("$hue.neutral.800")
|
||||
expect(migrated.light.background?.raised?.base).toBe("$hue.neutral.700")
|
||||
expect(migrated.light.background?.raised?.high).toBe("$hue.neutral.600")
|
||||
expect(migrated.dark.background?.default).toBe("$hue.neutral.800")
|
||||
expect(migrated.dark.background?.raised?.base).toBe("$hue.neutral.700")
|
||||
expect(migrated.dark.background?.raised?.high).toBe("$hue.neutral.600")
|
||||
@@ -40,8 +40,8 @@ test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
|
||||
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.hue.accent[800].toInts()).toEqual(legacy.accent.toInts())
|
||||
expect(resolved.hue.interactive[800].toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.hue.accent[200].toInts()).toEqual(legacy.accent.toInts())
|
||||
expect(resolved.hue.interactive[200].toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
|
||||
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.text.action.secondary.default.toInts()).toEqual(legacy.textMuted.toInts())
|
||||
@@ -62,9 +62,9 @@ test("references generated hues from matching token colors", () => {
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light) throw new Error("Expected light mode")
|
||||
|
||||
expect(migrated.light.border?.default).toBe("$hue.interactive.800")
|
||||
expect(migrated.light.scrollbar?.default).toBe("$hue.accent.800")
|
||||
expect(migrated.light.syntax?.keyword).toMatch(/^\$hue\.[^.]+\.800$/)
|
||||
expect(migrated.light.border?.default).toBe("$hue.interactive.200")
|
||||
expect(migrated.light.scrollbar?.default).toBe("$hue.accent.200")
|
||||
expect(migrated.light.syntax?.keyword).toMatch(/^\$hue\.[^.]+\.200$/)
|
||||
expect(migrated.light.markdown?.emphasis).toBe("#123456")
|
||||
})
|
||||
|
||||
@@ -85,9 +85,9 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
||||
const darkRed = migrated.dark.hue?.red
|
||||
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
|
||||
|
||||
expect(lightRed[800]).toBe("#ff6666")
|
||||
expect(lightRed[200]).toBe("#ff6666")
|
||||
expect(darkRed[200]).toBe("#450000")
|
||||
expect(lightRed[900]).not.toBe(lightRed[800])
|
||||
expect(lightRed[100]).not.toBe(lightRed[200])
|
||||
expect(darkRed[100]).not.toBe(darkRed[200])
|
||||
expect(migrated.light.hue?.orange).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
|
||||
@@ -104,7 +104,7 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
||||
test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
|
||||
light: DEFAULT_THEME.light.hue[name][700],
|
||||
light: DEFAULT_THEME.light.hue[name][300],
|
||||
dark: DEFAULT_THEME.dark.hue[name][300],
|
||||
})
|
||||
source.theme.secondary = mapped("purple")
|
||||
@@ -124,10 +124,10 @@ test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
|
||||
test("gives accent and primary ownership of their inferred hues", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.success = DEFAULT_THEME.light.hue.orange[300]
|
||||
source.theme.accent = DEFAULT_THEME.light.hue.orange[400]
|
||||
source.theme.info = DEFAULT_THEME.light.hue.blue[300]
|
||||
source.theme.primary = DEFAULT_THEME.light.hue.blue[400]
|
||||
source.theme.success = DEFAULT_THEME.light.hue.orange[700]
|
||||
source.theme.accent = DEFAULT_THEME.light.hue.orange[600]
|
||||
source.theme.info = DEFAULT_THEME.light.hue.blue[700]
|
||||
source.theme.primary = DEFAULT_THEME.light.hue.blue[600]
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light) throw new Error("Expected light mode")
|
||||
@@ -135,8 +135,8 @@ test("gives accent and primary ownership of their inferred hues", () => {
|
||||
const blue = migrated.light.hue?.blue
|
||||
if (typeof orange !== "object" || typeof blue !== "object") throw new Error("Expected concrete hue scales")
|
||||
|
||||
expect(orange[800]).toBe(source.theme.accent)
|
||||
expect(blue[800]).toBe(source.theme.primary)
|
||||
expect(orange[200]).toBe(source.theme.accent)
|
||||
expect(blue[200]).toBe(source.theme.primary)
|
||||
expect(migrated.light.hue?.accent).toBe("$hue.orange")
|
||||
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
|
||||
|
||||
@@ -144,7 +144,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
|
||||
const collisionMode = migrateV1(source).light
|
||||
const collision = collisionMode?.hue?.orange
|
||||
if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
|
||||
expect(collision[800]).toBe(source.theme.primary)
|
||||
expect(collision[200]).toBe(source.theme.primary)
|
||||
expect(collisionMode?.hue?.accent).toBe("$hue.orange")
|
||||
expect(collisionMode?.hue?.interactive).toBe("$hue.orange")
|
||||
})
|
||||
@@ -180,11 +180,11 @@ test("builds and extrapolates gray from V1 surfaces and text without using menus
|
||||
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
|
||||
|
||||
expect(lightGray[100]).not.toBe(lightGray[200])
|
||||
expect(lightGray[200]).toBe(hex(light.background))
|
||||
expect(lightGray[300]).toBe(hex(light.backgroundPanel))
|
||||
expect(lightGray[400]).toBe(hex(light.backgroundElement))
|
||||
expect(lightGray[600]).toBe(hex(light.textMuted))
|
||||
expect(lightGray[800]).toBe(hex(light.text))
|
||||
expect(lightGray[200]).toBe(hex(light.text))
|
||||
expect(lightGray[400]).toBe(hex(light.textMuted))
|
||||
expect(lightGray[600]).toBe(hex(light.backgroundElement))
|
||||
expect(lightGray[700]).toBe(hex(light.backgroundPanel))
|
||||
expect(lightGray[800]).toBe(hex(light.background))
|
||||
expect(lightGray[900]).not.toBe(lightGray[800])
|
||||
expect(darkGray[100]).not.toBe(darkGray[200])
|
||||
expect(darkGray[200]).toBe(hex(dark.text))
|
||||
|
||||
@@ -176,6 +176,9 @@ Override a hue alias to change a family of related colors at once:
|
||||
| Alias | `accent`, `interactive`, `neutral` |
|
||||
| Step | `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900` |
|
||||
|
||||
Hue steps follow the mode's contrast direction: light themes run from dark at `100` to light at `900`, while dark
|
||||
themes run from light at `100` to dark at `900`.
|
||||
|
||||
`categorical` sets the ordered hues used to distinguish agents and other repeated items. It must contain at least one
|
||||
base hue or alias.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user