refactor(theme): align hue direction with mode (#49820)

This commit is contained in:
James Long
2026-09-18 13:29:19 -04:00
committed by GitHub
parent d81604714f
commit 810e79a6d4
27 changed files with 256 additions and 267 deletions
+125 -125
View File
@@ -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" } },
+7 -12
View File
@@ -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)
+3 -3
View File
@@ -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
-2
View File
@@ -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
}
+15 -15
View File
@@ -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 }))
}
+4 -4
View File
@@ -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()
}
+1 -1
View File
@@ -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"
+2 -4
View File
@@ -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>,
}
}
+1 -1
View File
@@ -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 (
<>
+1 -1
View File
@@ -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,
+5 -5
View File
@@ -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
+2 -3
View File
@@ -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),
+1 -1
View File
@@ -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),
),
])
+2 -5
View File
@@ -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),
}
}
+13 -13
View File
@@ -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"}
>
-1
View File
@@ -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
+2 -4
View File
@@ -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()
}
+1 -1
View File
@@ -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 [
+6 -6
View File
@@ -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])
})
+28 -23
View File
@@ -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")
})
+25 -25
View File
@@ -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))
+3
View File
@@ -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.