mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(theme): split base and mode overrides (#49826)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { HueName, ThemeDocument } from "./schema.js"
|
||||
import type { BaseThemeDefinition, HueName, Mode, ThemeDefinition, ThemeDocument } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
@@ -9,8 +9,7 @@ export const DEFAULT_CATEGORICAL = [
|
||||
"cyan",
|
||||
] as const satisfies readonly HueName[]
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
const modes = {
|
||||
light: {
|
||||
hue: {
|
||||
gray: {
|
||||
@@ -441,4 +440,13 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<Mode, ThemeDefinition>
|
||||
|
||||
const { hue: _, ...base } = modes.light
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
base: base satisfies BaseThemeDefinition,
|
||||
light: { hue: modes.light.hue },
|
||||
dark: modes.dark,
|
||||
} satisfies ThemeDocument
|
||||
|
||||
@@ -27,7 +27,7 @@ export function mergeTheme(...values: unknown[]): Record<string, unknown> {
|
||||
return values.reduce<Record<string, unknown>>((result, value) => {
|
||||
if (!isRecord(value)) return result
|
||||
return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
|
||||
if (item === undefined || key === "mergeMode") return next
|
||||
if (item === undefined) return next
|
||||
return {
|
||||
...next,
|
||||
[key]: isRecord(item) ? mergeTheme(next[key], item) : item,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Mode, ThemeTokensDefinition } from "./index.js"
|
||||
import { DEFAULT_THEME } from "./defaults.js"
|
||||
import { ActionVariant, FeedbackKind } from "./schema.js"
|
||||
|
||||
export function fallback(mode: Mode): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
text: {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
status: DEFAULT_THEME[mode].text.status,
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
default: red,
|
||||
raised: { base: red, high: red, max: red },
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
border: { default: red },
|
||||
scrollbar: { default: red },
|
||||
diff: {
|
||||
text: { added: red, removed: red, context: red, hunkHeader: red },
|
||||
background: { added: red, removed: red, context: red },
|
||||
highlight: { added: red, removed: red },
|
||||
lineNumber: { text: red, background: { added: red, removed: red } },
|
||||
},
|
||||
syntax: {
|
||||
comment: red,
|
||||
keyword: red,
|
||||
function: red,
|
||||
variable: red,
|
||||
string: red,
|
||||
number: red,
|
||||
type: red,
|
||||
operator: red,
|
||||
punctuation: red,
|
||||
},
|
||||
markdown: {
|
||||
text: red,
|
||||
heading: red,
|
||||
link: red,
|
||||
linkText: red,
|
||||
code: red,
|
||||
blockQuote: red,
|
||||
emphasis: red,
|
||||
strong: red,
|
||||
horizontalRule: red,
|
||||
listItem: red,
|
||||
listEnumeration: red,
|
||||
image: red,
|
||||
imageText: red,
|
||||
codeBlock: red,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
BaseThemeDefinition,
|
||||
CategoricalDefinition,
|
||||
FeedbackKind,
|
||||
FormfieldState,
|
||||
@@ -20,11 +21,8 @@ export {
|
||||
ThemeDocument,
|
||||
type BackgroundDefinition,
|
||||
type DiffDefinition,
|
||||
type FileThemeDefinition,
|
||||
type FormfieldColorDefinition,
|
||||
type HueDefinition,
|
||||
type HueOverrideDefinition,
|
||||
type MergeModeDefinition,
|
||||
type Mode,
|
||||
type StatefulColorDefinition,
|
||||
type TextDefinition,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
|
||||
import { fallback } from "./fallback.js"
|
||||
import { expandTheme, mergeTheme } from "./expand.js"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
@@ -26,7 +24,7 @@ import type {
|
||||
StatefulColorDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index.js"
|
||||
import { selectTheme, selectThemeMode } from "./select.js"
|
||||
import { selectThemeMode } from "./select.js"
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition, { reportInput: true })
|
||||
|
||||
@@ -46,15 +44,8 @@ export function themeDecodeError(error: unknown, name: string) {
|
||||
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback(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)
|
||||
const definition = expandTheme(selected.theme)
|
||||
return resolveExpandedTheme(definition)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
@@ -63,7 +54,7 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
|
||||
const categorical = definition.categorical.map((name) => hue[name])
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const base = tokens(definition)
|
||||
const views = {} as Record<SurfaceName, ResolvedTheme>
|
||||
|
||||
@@ -61,21 +61,6 @@ const HueDefinition = Schema.Struct({
|
||||
})
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
||||
|
||||
const HueOverrideDefinition = Schema.Struct({
|
||||
gray: Schema.optional(HueValueDefinition),
|
||||
red: Schema.optional(HueValueDefinition),
|
||||
orange: Schema.optional(HueValueDefinition),
|
||||
yellow: Schema.optional(HueValueDefinition),
|
||||
green: Schema.optional(HueValueDefinition),
|
||||
cyan: Schema.optional(HueValueDefinition),
|
||||
blue: Schema.optional(HueValueDefinition),
|
||||
purple: Schema.optional(HueValueDefinition),
|
||||
accent: Schema.optional(HueValueDefinition),
|
||||
interactive: Schema.optional(HueValueDefinition),
|
||||
neutral: Schema.optional(HueValueDefinition),
|
||||
})
|
||||
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
|
||||
|
||||
const StatefulColorDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
$hovered: Schema.optional(ColorValue),
|
||||
@@ -225,41 +210,110 @@ const ThemeTokensDefinition = Schema.Struct({
|
||||
})
|
||||
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
|
||||
|
||||
const CompleteStatefulColorDefinition = Schema.Struct({
|
||||
default: ColorValue,
|
||||
$hovered: Schema.optional(ColorValue),
|
||||
$focused: Schema.optional(ColorValue),
|
||||
$pressed: Schema.optional(ColorValue),
|
||||
$selected: Schema.optional(ColorValue),
|
||||
$disabled: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const CompleteActionColorDefinition = Schema.Struct({
|
||||
primary: CompleteStatefulColorDefinition,
|
||||
secondary: CompleteStatefulColorDefinition,
|
||||
destructive: CompleteStatefulColorDefinition,
|
||||
})
|
||||
|
||||
const CompleteTextFeedbackDefinition = Schema.Struct({ default: ColorValue, subdued: Schema.optional(ColorValue) })
|
||||
const CompleteBackgroundFeedbackDefinition = Schema.Struct({ default: ColorValue })
|
||||
|
||||
const CompleteThemeTokensDefinition = Schema.Struct({
|
||||
text: Schema.Struct({
|
||||
default: ColorValue,
|
||||
subdued: ColorValue,
|
||||
action: CompleteActionColorDefinition,
|
||||
formfield: CompleteStatefulColorDefinition,
|
||||
status: Schema.Struct({
|
||||
running: ColorValue,
|
||||
question: ColorValue,
|
||||
permission: ColorValue,
|
||||
unread: ColorValue,
|
||||
}),
|
||||
feedback: Schema.Struct({
|
||||
error: CompleteTextFeedbackDefinition,
|
||||
warning: CompleteTextFeedbackDefinition,
|
||||
success: CompleteTextFeedbackDefinition,
|
||||
info: CompleteTextFeedbackDefinition,
|
||||
}),
|
||||
}),
|
||||
background: Schema.Struct({
|
||||
default: ColorValue,
|
||||
raised: Schema.Struct({ base: ColorValue, high: ColorValue, max: ColorValue }),
|
||||
action: CompleteActionColorDefinition,
|
||||
formfield: CompleteStatefulColorDefinition,
|
||||
feedback: Schema.Struct({
|
||||
error: CompleteBackgroundFeedbackDefinition,
|
||||
warning: CompleteBackgroundFeedbackDefinition,
|
||||
success: CompleteBackgroundFeedbackDefinition,
|
||||
info: CompleteBackgroundFeedbackDefinition,
|
||||
}),
|
||||
}),
|
||||
border: Schema.Struct({ default: ColorValue }),
|
||||
scrollbar: Schema.Struct({ default: ColorValue }),
|
||||
diff: Schema.Struct({
|
||||
text: Schema.Struct({ added: ColorValue, removed: ColorValue, context: ColorValue, hunkHeader: ColorValue }),
|
||||
background: Schema.Struct({ added: ColorValue, removed: ColorValue, context: ColorValue }),
|
||||
highlight: Schema.Struct({ added: ColorValue, removed: ColorValue }),
|
||||
lineNumber: Schema.Struct({
|
||||
text: ColorValue,
|
||||
background: Schema.Struct({ added: ColorValue, removed: ColorValue }),
|
||||
}),
|
||||
}),
|
||||
syntax: Schema.Record(SyntaxToken, HueColorValue),
|
||||
markdown: Schema.Record(MarkdownToken, HueColorValue),
|
||||
})
|
||||
|
||||
const ThemeDefinitionFields = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
categorical: CategoricalDefinition,
|
||||
...CompleteThemeTokensDefinition.fields,
|
||||
"@dialog": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export const ThemeDefinition = ThemeDefinitionFields
|
||||
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
|
||||
|
||||
const FileThemeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
export const BaseThemeDefinition = Schema.Struct({
|
||||
categorical: CategoricalDefinition,
|
||||
...CompleteThemeTokensDefinition.fields,
|
||||
"@dialog": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
|
||||
export type BaseThemeDefinition = Schema.Schema.Type<typeof BaseThemeDefinition>
|
||||
|
||||
const MergeModeDefinition = Schema.Struct({
|
||||
mergeMode: Schema.Literal(true),
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
export const ModeDefinition = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@dialog": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
|
||||
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
|
||||
export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
|
||||
|
||||
const FileMetadata = {
|
||||
$schema: Schema.optional(Schema.String),
|
||||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeDocument = Schema.Union([
|
||||
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
|
||||
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
|
||||
Schema.Struct({
|
||||
...FileMetadata,
|
||||
base: BaseThemeDefinition,
|
||||
light: ModeDefinition,
|
||||
dark: Schema.optional(ModeDefinition),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...FileMetadata,
|
||||
base: BaseThemeDefinition,
|
||||
light: Schema.optional(ModeDefinition),
|
||||
dark: ModeDefinition,
|
||||
}),
|
||||
])
|
||||
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>
|
||||
|
||||
@@ -1,51 +1,25 @@
|
||||
import { expandTheme, mergeTheme } from "./expand.js"
|
||||
import type {
|
||||
FileThemeDefinition,
|
||||
MergeModeDefinition,
|
||||
Mode,
|
||||
ModeDefinition,
|
||||
ThemeDefinition,
|
||||
ThemeDocument,
|
||||
} from "./index.js"
|
||||
import { mergeTheme } from "./expand.js"
|
||||
import type { Mode, ThemeDefinition, ThemeDocument } from "./index.js"
|
||||
|
||||
export function selectTheme(
|
||||
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
mode?: Mode,
|
||||
): ThemeDefinition
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode) {
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode): ThemeDefinition {
|
||||
return selectThemeMode(document, mode).theme
|
||||
}
|
||||
|
||||
export function selectThemeMode(
|
||||
document: ThemeDocument,
|
||||
mode: Mode = "light",
|
||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||
): { theme: ThemeDefinition; mode: Mode } {
|
||||
const modes = themeModes(document)
|
||||
const selectedMode = modes.includes(mode) ? mode : modes[0]
|
||||
const selected = document[selectedMode]
|
||||
if (!selected) throw new Error("Theme must provide at least one mode")
|
||||
if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
|
||||
|
||||
const otherMode = selectedMode === "light" ? "dark" : "light"
|
||||
const other = document[otherMode]
|
||||
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
|
||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
|
||||
return { theme: mergeTheme(document.base, selected) as ThemeDefinition, mode: selectedMode }
|
||||
}
|
||||
|
||||
export function themeModes(document: ThemeDocument): readonly Mode[] {
|
||||
if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||
if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||
return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined)
|
||||
}
|
||||
|
||||
export function supportsThemeMode(document: ThemeDocument, mode: Mode) {
|
||||
return themeModes(document).includes(mode)
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
|
||||
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "./color.js"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
|
||||
import { DEFAULT_CATEGORICAL } from "./defaults.js"
|
||||
import type { BaseThemeDefinition, HueDefinition, Mode, ThemeDefinition, ThemeDocument } from "./index.js"
|
||||
import { HueStep } from "./schema.js"
|
||||
import type { Theme, ThemeV1Json } from "./v1.js"
|
||||
|
||||
@@ -13,6 +13,27 @@ const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "gree
|
||||
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
|
||||
const minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
// Canonical OKLCH hue angles classify arbitrary V1 colors without coupling migration to a built-in theme.
|
||||
const hueAngles = {
|
||||
light: {
|
||||
red: 19.571,
|
||||
orange: 66.29,
|
||||
yellow: 98.111,
|
||||
green: 154.449,
|
||||
cyan: 207.078,
|
||||
blue: 251.813,
|
||||
purple: 306.383,
|
||||
},
|
||||
dark: {
|
||||
red: 27.518,
|
||||
orange: 38.402,
|
||||
yellow: 66.442,
|
||||
green: 150.069,
|
||||
cyan: 223.128,
|
||||
blue: 264.376,
|
||||
purple: 301.924,
|
||||
},
|
||||
} satisfies Record<"light" | "dark", Record<ChromaticHue, number>>
|
||||
|
||||
export function migrateV1(theme: ThemeV1Json): ThemeDocument {
|
||||
const light = resolveV1(theme, "light")
|
||||
@@ -21,18 +42,26 @@ export function migrateV1(theme: ThemeV1Json): ThemeDocument {
|
||||
const lightMode = detectMode(light)
|
||||
const darkMode = detectMode(dark)
|
||||
if (lightMode === darkMode) {
|
||||
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
|
||||
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
|
||||
const definition = migrateMode(lightMode === "light" ? light : dark, lightMode)
|
||||
if (lightMode === "light") return { version: 2, base: base(definition), light: { hue: definition.hue } }
|
||||
return { version: 2, base: base(definition), dark: { hue: definition.hue } }
|
||||
}
|
||||
}
|
||||
const lightDefinition = migrateMode(light, "light")
|
||||
const darkDefinition = migrateMode(dark, "dark")
|
||||
return {
|
||||
version: 2,
|
||||
standalone: true,
|
||||
light: migrateMode(light, "light"),
|
||||
dark: migrateMode(dark, "dark"),
|
||||
base: base(lightDefinition),
|
||||
light: { hue: lightDefinition.hue },
|
||||
dark: darkDefinition,
|
||||
}
|
||||
}
|
||||
|
||||
function base(definition: ThemeDefinition): BaseThemeDefinition {
|
||||
const { hue: _, ...base } = definition
|
||||
return base
|
||||
}
|
||||
|
||||
function detectMode(theme: Theme): Mode {
|
||||
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
|
||||
}
|
||||
@@ -41,11 +70,11 @@ function luminance(color: RGBA) {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
function migrateMode(theme: Theme, mode: Mode): ThemeDefinition {
|
||||
const color = (key: ThemeColor) => hex(theme[key])
|
||||
const selected = hex(selectedForeground(theme, theme.primary))
|
||||
const destructive = hex(selectedForeground(theme, theme.error))
|
||||
const hues = inferHues(theme, mode)
|
||||
const hues = inferHues(theme)
|
||||
const categorical = categoricalTokens.flatMap((token) => {
|
||||
const hue = hues.byToken[token]
|
||||
return hue ? [hue] : []
|
||||
@@ -71,7 +100,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
|
||||
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
} as HueDefinition,
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: text,
|
||||
@@ -94,6 +123,12 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
$disabled: textMuted,
|
||||
$selected: primary,
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: color("error") },
|
||||
warning: { default: color("warning") },
|
||||
@@ -182,7 +217,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
})
|
||||
}
|
||||
|
||||
function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
|
||||
function referenceHues(theme: ThemeDefinition): ThemeDefinition {
|
||||
const definitions = theme.hue as Record<string, string | Partial<Record<HueStep, string>>> | undefined
|
||||
if (!definitions) return theme
|
||||
const scales = new Map<string, Partial<Record<HueStep, string>>>()
|
||||
@@ -228,10 +263,10 @@ function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(theme).map(([key, value]) => [key, key === "hue" || key === "categorical" ? value : replace(value)]),
|
||||
) as FileThemeDefinition
|
||||
) as ThemeDefinition
|
||||
}
|
||||
|
||||
function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
function inferHues(theme: Theme) {
|
||||
const colors: readonly [V1HueToken, RGBA][] = [
|
||||
["accent", theme.accent],
|
||||
["success", theme.success],
|
||||
@@ -246,7 +281,7 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
byToken: Partial<Record<V1HueToken, ChromaticHue>>
|
||||
}>(
|
||||
(result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
const nearest = inferHue(color)
|
||||
if (!nearest) return result
|
||||
const current = result.byHue[nearest.name]
|
||||
return {
|
||||
@@ -265,7 +300,7 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
["primary", theme.primary],
|
||||
] as const
|
||||
).reduce((result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
const nearest = inferHue(color)
|
||||
if (!nearest) return result
|
||||
return {
|
||||
byHue: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
||||
@@ -274,23 +309,18 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
}, inferred)
|
||||
}
|
||||
|
||||
function inferHue(color: RGBA, mode: Mode) {
|
||||
function inferHue(color: RGBA) {
|
||||
const value = toOklch(color)
|
||||
if (ambiguous(color, value.c)) return
|
||||
const anchor = inferenceAnchor(value.l, mode)
|
||||
const reference = value.l >= lightThreshold ? hueAngles.light : hueAngles.dark
|
||||
return chromaticHues
|
||||
.map((name) => ({
|
||||
name,
|
||||
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
||||
distance: hueDistance(value.h, reference[name]),
|
||||
}))
|
||||
.sort((first, second) => first.distance - second.distance)[0]
|
||||
}
|
||||
|
||||
function inferenceAnchor(lightness: number, mode: Mode): HueStep {
|
||||
if (mode === "light") return lightness >= lightThreshold ? 700 : 300
|
||||
return lightness >= lightThreshold ? 300 : 700
|
||||
}
|
||||
|
||||
function hueDistance(first: number, second: number) {
|
||||
const difference = Math.abs(first - second)
|
||||
return Math.min(difference, 360 - difference)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument } from "../src/tui/index.js"
|
||||
import { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument, selectTheme } from "../src/tui/index.js"
|
||||
import type { ThemeV1Json } from "../src/tui/v1.js"
|
||||
|
||||
test.each(["light", "dark"] as const)("built-in %s themes resolve status colors", async (mode) => {
|
||||
@@ -19,43 +19,55 @@ test.each(["light", "dark"] as const)("built-in %s themes resolve status colors"
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("custom %s themes inherit the unread attention color", (mode) => {
|
||||
for (const standalone of [false, true]) {
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
standalone,
|
||||
[mode]: {
|
||||
hue: { ...DEFAULT_THEME[mode].hue, accent: "$hue.purple" },
|
||||
text: { status: { unread: "#abcdef" } },
|
||||
},
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).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.surface("dialog").text.status).toEqual(theme.text.status)
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const definition = {
|
||||
...base,
|
||||
hue: { ...base.hue, accent: "$hue.purple" },
|
||||
text: {
|
||||
...base.text,
|
||||
status: { ...base.text.status, unread: "#abcdef" },
|
||||
},
|
||||
}
|
||||
const { hue, ...tokens } = definition
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
base: tokens,
|
||||
[mode]: { hue },
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).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.surface("dialog").text.status).toEqual(theme.text.status)
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
|
||||
for (const standalone of [false, true]) {
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
standalone,
|
||||
[mode]: {
|
||||
hue: { ...DEFAULT_THEME[mode].hue, interactive: "$hue.purple", accent: "$hue.orange" },
|
||||
text: {
|
||||
status: { question: "#123456", permission: "#654321" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
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()
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const definition = {
|
||||
...base,
|
||||
hue: { ...base.hue, interactive: "$hue.purple", accent: "$hue.orange" },
|
||||
text: {
|
||||
...base.text,
|
||||
status: {
|
||||
...base.text.status,
|
||||
question: "#123456",
|
||||
permission: "#654321",
|
||||
},
|
||||
},
|
||||
}
|
||||
const { hue, ...tokens } = definition
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
base: tokens,
|
||||
[mode]: { hue },
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { DEFAULT_THEME } from "@opencode/theme/tui"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
@@ -25,7 +26,11 @@ test("uses an available mode while retaining the pinned preference", async () =>
|
||||
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||
darkOnly.theme.background = "#111111"
|
||||
darkOnly.theme.text = "#eeeeee"
|
||||
const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const
|
||||
const native = {
|
||||
version: 2,
|
||||
base: { ...DEFAULT_THEME.base, text: { ...DEFAULT_THEME.base.text, default: "#abcdef" } },
|
||||
dark: { hue: DEFAULT_THEME.dark.hue },
|
||||
} as const
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
|
||||
function Probe() {
|
||||
@@ -79,7 +84,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
|
||||
|
||||
test.each([
|
||||
["schema", { version: 2, light: { categorical: [] } }],
|
||||
["mode merging", { version: 2, light: { mergeMode: true } }],
|
||||
["partial mode", { version: 2, light: { text: { default: "#ffffff" } } }],
|
||||
["token reference", { version: 2, light: { text: { default: "$missing" } } }],
|
||||
] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => {
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { CliRenderEvents, RGBA, TextRenderable } from "@opentui/core"
|
||||
import { DEFAULT_THEME } from "@opencode/theme/tui"
|
||||
import path from "node:path"
|
||||
import { Writable } from "node:stream"
|
||||
import { coalesceProgressCommit, resolveRunAgent, RunFooter } from "../../src/mini/footer"
|
||||
@@ -370,7 +371,11 @@ test("explicit theme refresh reloads custom colors without a palette event", asy
|
||||
for (const color of ["#123456", "#abcdef"]) {
|
||||
await Bun.write(
|
||||
path.join(tmp.path, "themes", "mini-refresh.json"),
|
||||
JSON.stringify({ version: 2, dark: { text: { default: color } } }),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
base: { ...DEFAULT_THEME.base, text: { ...DEFAULT_THEME.base.text, default: color } },
|
||||
dark: { hue: DEFAULT_THEME.dark.hue },
|
||||
}),
|
||||
)
|
||||
await app.footer.refreshTheme()
|
||||
await app.renderOnce()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||
import { DEFAULT_THEME, resolveThemeDocument, type ResolvedTheme } from "@opencode/theme/tui"
|
||||
import { DEFAULT_THEME, resolveThemeDocument, selectTheme, type ResolvedTheme } from "@opencode/theme/tui"
|
||||
import {
|
||||
RUN_THEME_MONO,
|
||||
RUN_THEME_FALLBACK,
|
||||
@@ -185,30 +185,50 @@ test.each(["light", "dark"] as const)("shares the %s system scheme while retaini
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)(
|
||||
"loads %s custom files with shared partial and standalone fallback",
|
||||
"loads complete %s custom theme files",
|
||||
async (mode) => {
|
||||
for (const standalone of [false, true]) {
|
||||
const source = {
|
||||
version: 2,
|
||||
standalone,
|
||||
[mode]: {
|
||||
hue: DEFAULT_THEME[mode].hue,
|
||||
text: {
|
||||
default: "#123456",
|
||||
action: { primary: { $focused: "#56789a" } },
|
||||
formfield: { default: "#234567", $selected: "#345678", $focused: "#6789ab" },
|
||||
feedback: { warning: { default: "#456789" } },
|
||||
},
|
||||
background: { action: { primary: { $focused: "#789abc" } }, formfield: { $focused: "#89abcd" } },
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const definition = {
|
||||
...base,
|
||||
text: {
|
||||
...base.text,
|
||||
default: "#123456",
|
||||
action: {
|
||||
...base.text.action,
|
||||
primary: { ...base.text.action.primary, $focused: "#56789a" },
|
||||
},
|
||||
}
|
||||
await Bun.write(path.join(tmp.path, "themes", "mini-custom.json"), JSON.stringify(source))
|
||||
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode) }), { name: "mini-custom", mode })
|
||||
try {
|
||||
expectFooter(theme, resolveThemeDocument(parseTheme(source), mode))
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
formfield: {
|
||||
...base.text.formfield,
|
||||
default: "#234567",
|
||||
$selected: "#345678",
|
||||
$focused: "#6789ab",
|
||||
},
|
||||
feedback: {
|
||||
...base.text.feedback,
|
||||
warning: { ...base.text.feedback.warning, default: "#456789" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
...base.background,
|
||||
action: {
|
||||
...base.background.action,
|
||||
primary: { ...base.background.action.primary, $focused: "#789abc" },
|
||||
},
|
||||
formfield: { ...base.background.formfield, $focused: "#89abcd" },
|
||||
},
|
||||
}
|
||||
const { hue, ...tokens } = definition
|
||||
const source = {
|
||||
version: 2,
|
||||
base: tokens,
|
||||
[mode]: { hue },
|
||||
}
|
||||
await Bun.write(path.join(tmp.path, "themes", "mini-custom.json"), JSON.stringify(source))
|
||||
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode) }), { name: "mini-custom", mode })
|
||||
try {
|
||||
expectFooter(theme, resolveThemeDocument(parseTheme(source), mode))
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -253,7 +273,10 @@ test.each(["light", "dark"] as const)("resolves dark-only Aura on an automatic %
|
||||
test.each(["light", "dark"] as const)(
|
||||
"falls back only for unsupported modes of a %s-only custom theme",
|
||||
async (mode) => {
|
||||
const source = { version: 2, [mode]: { text: { default: "#123456" } } }
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const definition = { ...base, text: { ...base.text, default: "#123456" } }
|
||||
const { hue, ...tokens } = definition
|
||||
const source = { version: 2, base: tokens, [mode]: { hue } }
|
||||
await Bun.write(path.join(tmp.path, "themes", "mini-one-mode.json"), JSON.stringify(source))
|
||||
for (const requested of ["light", "dark"] as const) {
|
||||
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, requested) }), {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { TerminalColors } from "@opentui/core"
|
||||
import { DEFAULT_THEME } from "@opencode/theme/tui"
|
||||
import { DEFAULT_THEMES, hasTheme, parseTheme, resolveTheme } from "../src/theme"
|
||||
import { discoverThemes } from "../src/theme/discovery"
|
||||
import { configDirectories } from "../src/util/config-directories"
|
||||
@@ -26,7 +27,11 @@ test("parses unversioned and explicit V1 themes lazily once", () => {
|
||||
})
|
||||
|
||||
test("decodes native V2 themes lazily once", () => {
|
||||
const source = { version: 2, light: { categorical: ["red"] } } as const
|
||||
const source = {
|
||||
version: 2,
|
||||
base: DEFAULT_THEME.base,
|
||||
light: { ...DEFAULT_THEME.light, categorical: ["red"] },
|
||||
} as const
|
||||
|
||||
const document = parseTheme(source)
|
||||
expect(document.light?.categorical).toEqual(["red"])
|
||||
|
||||
@@ -30,9 +30,15 @@ function resolveSource(source: ThemeDocumentSource, mode?: Mode, name?: string)
|
||||
return resolveThemeDocument(parseTheme(source, name), mode)
|
||||
}
|
||||
|
||||
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")
|
||||
function complete(mode: Mode, value: unknown = {}): ThemeDocumentSource {
|
||||
const definition = override(mode === "light" ? light : dark, value)
|
||||
const { hue, ...base } = definition
|
||||
return { version: 2, base, [mode]: { hue } }
|
||||
}
|
||||
|
||||
test("resolves complete one-mode documents in the available mode", () => {
|
||||
const resolvedLight = resolveSource(complete("light"), "dark")
|
||||
const resolvedDark = resolveSource(complete("dark"), "light")
|
||||
|
||||
expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
|
||||
expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
|
||||
@@ -45,29 +51,31 @@ test("rejects theme documents without a mode", () => {
|
||||
})
|
||||
|
||||
test("validates and resolves categorical hues in configured order", () => {
|
||||
const theme = resolveSource({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light")
|
||||
const theme = resolveSource(complete("light", { categorical: ["accent", "red", "interactive"] }), "light")
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.accent)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.red)
|
||||
expect(theme.categorical[2]).toBe(theme.hue.interactive)
|
||||
expect(theme.surface("dialog").categorical).toBe(theme.categorical)
|
||||
expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
|
||||
expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme")
|
||||
expect(() => resolveSource(complete("light", { categorical: [] }), "light")).toThrow("Invalid theme")
|
||||
expect(() =>
|
||||
resolveSource(complete("light", { categorical: ["magenta"] as never }), "light"),
|
||||
).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("generates syntax with one categorical hue", () => {
|
||||
const theme = resolveSource({ version: 2, light: { categorical: ["red"] } }, "light")
|
||||
const theme = resolveSource(complete("light", { categorical: ["red"] }), "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 })
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.blue)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.purple)
|
||||
test("rejects incomplete themes instead of merging defaults", () => {
|
||||
expect(() => resolveSource({ version: 2, light: { hue: light.hue } }, "light")).toThrow("Invalid theme")
|
||||
expect(() => resolveSource({ version: 2, light: { ...light, categorical: undefined } }, "light")).toThrow(
|
||||
"Invalid theme",
|
||||
)
|
||||
})
|
||||
|
||||
test("resolves independent definitions and hue aliases", () => {
|
||||
@@ -115,7 +123,10 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
||||
},
|
||||
)
|
||||
const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
|
||||
const overridden = resolveSource(
|
||||
complete("light", { hue: { ...light.hue, blue: "$hue.red" } }),
|
||||
"light",
|
||||
)
|
||||
|
||||
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
|
||||
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
|
||||
@@ -157,31 +168,19 @@ test("steps by hue source when adjacent colors have equal values", () => {
|
||||
expect(theme.increase(theme.hue.neutral[300])).toBe(theme.hue.neutral[400])
|
||||
})
|
||||
|
||||
test("merges partial documents with the selected OpenCode defaults", () => {
|
||||
const theme = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
hue: light.hue,
|
||||
text: { default: "#123456" },
|
||||
},
|
||||
dark: { hue: dark.hue },
|
||||
test("resolves complete light and dark definitions independently", () => {
|
||||
const lightDefinition = override(light, {
|
||||
text: {
|
||||
...light.text,
|
||||
action: { ...light.text.action, secondary: { default: "#123456", $hovered: "#234567" } },
|
||||
},
|
||||
"light",
|
||||
)
|
||||
|
||||
expect(theme.text.default.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(theme.text.subdued.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(theme.background.action.destructive.pressed).toBeInstanceOf(RGBA)
|
||||
})
|
||||
|
||||
test("resolves custom secondary actions and falls back per mode", () => {
|
||||
})
|
||||
const { hue, ...base } = lightDefinition
|
||||
const document = {
|
||||
version: 2,
|
||||
light: {
|
||||
text: { action: { secondary: { default: "#123456", $hovered: "#234567" } } },
|
||||
},
|
||||
dark: {},
|
||||
base,
|
||||
light: { hue },
|
||||
dark,
|
||||
} as const
|
||||
const lightTheme = resolveSource(document, "light")
|
||||
const darkTheme = resolveSource(document, "dark")
|
||||
@@ -192,58 +191,19 @@ test("resolves custom secondary actions and falls back per mode", () => {
|
||||
expect(darkTheme.text.action.secondary.hovered).toBe(darkTheme.text.default)
|
||||
})
|
||||
|
||||
test("expands user structural fallbacks before merging defaults", () => {
|
||||
const expanded = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
hue: light.hue,
|
||||
background: { action: { primary: { default: "#123456" } } },
|
||||
test("expands structural state fallbacks within a complete theme", () => {
|
||||
const expanded = resolveTheme({
|
||||
...light,
|
||||
background: {
|
||||
...light.background,
|
||||
action: {
|
||||
...light.background.action,
|
||||
primary: { default: "#123456" },
|
||||
},
|
||||
dark: { hue: dark.hue },
|
||||
},
|
||||
"light",
|
||||
)
|
||||
const isolatedState = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
hue: light.hue,
|
||||
background: { action: { primary: { $pressed: "#654321" } } },
|
||||
},
|
||||
dark: { hue: dark.hue },
|
||||
},
|
||||
"light",
|
||||
)
|
||||
})
|
||||
|
||||
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).background.action.primary.focused.toInts(),
|
||||
)
|
||||
})
|
||||
|
||||
test("standalone themes skip OpenCode defaults and use the red core fallback", () => {
|
||||
const document = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const
|
||||
const lightTheme = resolveSource(document, "light")
|
||||
const darkTheme = resolveSource(document, "dark")
|
||||
|
||||
expect(lightTheme.text.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
expect(lightTheme.background.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
expect(darkTheme.text.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
expect(darkTheme.background.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
})
|
||||
|
||||
test("uses defaults for the selected mode when it merges the other mode", () => {
|
||||
const theme = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: { hue: light.hue, background: { default: "#123456" } },
|
||||
dark: { mergeMode: true },
|
||||
},
|
||||
"dark",
|
||||
)
|
||||
expect(theme.background.default.toInts()).toEqual([18, 52, 86, 255])
|
||||
})
|
||||
|
||||
test("resolves matched action variants and states", () => {
|
||||
@@ -264,9 +224,12 @@ test("resolves matched action variants and states", () => {
|
||||
test("resolves dialog surfaces from direct colors", () => {
|
||||
const theme = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: { background: { raised: { base: "#123456", high: "#234567" } } },
|
||||
dark: {},
|
||||
...complete("light", {
|
||||
background: {
|
||||
...light.background,
|
||||
raised: { ...light.background.raised, base: "#123456", high: "#234567" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
"light",
|
||||
)
|
||||
@@ -276,22 +239,16 @@ test("resolves dialog surfaces from direct colors", () => {
|
||||
})
|
||||
|
||||
test("resolves transparent colors", () => {
|
||||
const theme = resolveSource({
|
||||
version: 2,
|
||||
light: { background: { formfield: { default: "transparent" } } },
|
||||
dark: { background: { formfield: { default: "transparent" } } },
|
||||
})
|
||||
const theme = resolveSource(complete("light", {
|
||||
background: { ...light.background, formfield: { default: "transparent" } },
|
||||
}))
|
||||
expect(theme.background.formfield.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
})
|
||||
|
||||
test("reports theme decoding failures as native errors", () => {
|
||||
expect(() =>
|
||||
resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: { text: { default: "opaque" } },
|
||||
dark: {},
|
||||
} as never,
|
||||
complete("light", { text: { ...light.text, default: "opaque" } }) as never,
|
||||
"light",
|
||||
"custom",
|
||||
),
|
||||
@@ -351,7 +308,7 @@ test("validates complete hues, resolved groups, and hue-only syntax", () => {
|
||||
).toThrow("$text.default")
|
||||
})
|
||||
|
||||
function override(base: ThemeDefinition, value: Partial<ThemeDefinition>) {
|
||||
function override(base: ThemeDefinition, value: unknown) {
|
||||
return merge(base, value) as ThemeDefinition
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +1,30 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
selectTheme,
|
||||
selectThemeMode,
|
||||
supportsThemeMode,
|
||||
themeModes,
|
||||
type HueDefinition,
|
||||
type ThemeDefinition,
|
||||
type ThemeDocument,
|
||||
} from "@opencode/theme/tui"
|
||||
import { DEFAULT_THEME, selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "@opencode/theme/tui"
|
||||
|
||||
const hue = {} as HueDefinition
|
||||
const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
|
||||
const dark = {
|
||||
hue,
|
||||
categorical: ["purple"],
|
||||
text: { default: "#eeeeee", subdued: "#dddddd" },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
test("requires and selects independent light and dark themes", () => {
|
||||
const document = { version: 2, light, dark } satisfies ThemeDocument
|
||||
expect(selectTheme(document)).toBe(light)
|
||||
expect(selectTheme(document, "light")).toBe(light)
|
||||
expect(selectTheme(document, "dark")).toBe(dark)
|
||||
expect(selectThemeMode(document, "dark").mode).toBe("dark")
|
||||
})
|
||||
|
||||
test("merges an expanded mode override over the other mode", () => {
|
||||
const document = {
|
||||
version: 2,
|
||||
light,
|
||||
dark: { mergeMode: true, text: { default: "#ffffff" } },
|
||||
} satisfies ThemeDocument
|
||||
const selected = selectTheme(document, "dark")
|
||||
|
||||
expect(selected.hue).toBeDefined()
|
||||
expect(selected.text?.default).toBe("#ffffff")
|
||||
expect(selected.text?.subdued).toBe("$text.default")
|
||||
})
|
||||
|
||||
test("replaces categorical order in a merge mode", () => {
|
||||
const selected = selectTheme(
|
||||
{ version: 2, light, dark: { mergeMode: true, categorical: ["accent", "cyan"] } },
|
||||
"dark",
|
||||
)
|
||||
|
||||
expect(selected.categorical).toEqual(["accent", "cyan"])
|
||||
test("selects complete light and dark themes independently", () => {
|
||||
expect(selectTheme(DEFAULT_THEME)).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.light })
|
||||
expect(selectTheme(DEFAULT_THEME, "light")).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.light })
|
||||
expect(selectTheme(DEFAULT_THEME, "dark")).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark })
|
||||
expect(selectThemeMode(DEFAULT_THEME, "dark")).toEqual({
|
||||
theme: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark },
|
||||
mode: "dark",
|
||||
})
|
||||
})
|
||||
|
||||
test("selects the available mode when the requested mode is missing", () => {
|
||||
const lightOnly = { version: 2, light } satisfies ThemeDocument
|
||||
const darkOnly = { version: 2, dark } satisfies ThemeDocument
|
||||
const lightOnly = { version: 2, base: DEFAULT_THEME.base, light: DEFAULT_THEME.light } as const
|
||||
const darkOnly = { version: 2, base: DEFAULT_THEME.base, dark: DEFAULT_THEME.dark } as const
|
||||
|
||||
expect(themeModes(lightOnly)).toEqual(["light"])
|
||||
expect(themeModes(darkOnly)).toEqual(["dark"])
|
||||
expect(supportsThemeMode(lightOnly, "light")).toBeTrue()
|
||||
expect(supportsThemeMode(lightOnly, "dark")).toBeFalse()
|
||||
expect(selectThemeMode(lightOnly, "dark")).toEqual({ theme: light, mode: "light", expanded: false })
|
||||
expect(selectThemeMode(darkOnly, "light")).toEqual({ theme: dark, mode: "dark", expanded: false })
|
||||
})
|
||||
|
||||
test("rejects a merge mode without its base mode", () => {
|
||||
expect(() => selectThemeMode({ version: 2, light: { mergeMode: true } })).toThrow(
|
||||
"light theme cannot merge without a dark theme",
|
||||
)
|
||||
expect(() => selectThemeMode({ version: 2, dark: { mergeMode: true } })).toThrow(
|
||||
"dark theme cannot merge without a light theme",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects mutual mode merging", () => {
|
||||
const document = {
|
||||
version: 2,
|
||||
light: { mergeMode: true },
|
||||
dark: { mergeMode: true },
|
||||
} satisfies ThemeDocument
|
||||
expect(() => selectTheme(document)).toThrow("cannot both merge")
|
||||
expect(selectThemeMode(lightOnly, "dark")).toEqual({
|
||||
theme: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.light },
|
||||
mode: "light",
|
||||
})
|
||||
expect(selectThemeMode(darkOnly, "light")).toEqual({
|
||||
theme: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark },
|
||||
mode: "dark",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_THEME, selectTheme } from "@opencode/theme/tui"
|
||||
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode/theme/tui"
|
||||
|
||||
const text = {
|
||||
@@ -38,16 +39,19 @@ const background = {
|
||||
} satisfies BackgroundDefinition
|
||||
|
||||
const definition = {
|
||||
hue: {} as ThemeDefinition["hue"],
|
||||
categorical: ["blue", "accent"],
|
||||
text,
|
||||
background,
|
||||
border: { default: "$hue.neutral.300" },
|
||||
...selectTheme(DEFAULT_THEME, "light"),
|
||||
"@dialog": { background: { default: "$background.raised.base" } },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
export const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument
|
||||
export const lightOnly = { version: 2, light: definition } satisfies ThemeDocument
|
||||
export const darkOnly = { version: 2, dark: definition } satisfies ThemeDocument
|
||||
export const document = {
|
||||
version: 2,
|
||||
base: DEFAULT_THEME.base,
|
||||
light: { hue: definition.hue },
|
||||
dark: definition,
|
||||
} satisfies ThemeDocument
|
||||
export const lightOnly = { version: 2, base: DEFAULT_THEME.base, light: { hue: definition.hue } } satisfies ThemeDocument
|
||||
export const darkOnly = { version: 2, base: DEFAULT_THEME.base, dark: definition } satisfies ThemeDocument
|
||||
// @ts-expect-error A theme document must provide at least one mode.
|
||||
export const empty = { version: 2 } satisfies ThemeDocument
|
||||
// @ts-expect-error A base mode must be complete; partial tokens are only valid under @dialog.
|
||||
export const partial = { version: 2, base: { text, background }, light: { hue: definition.hue } } satisfies ThemeDocument
|
||||
|
||||
@@ -15,24 +15,23 @@ test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
|
||||
const resolved = resolveThemeDocument(migrated, "light")
|
||||
|
||||
expect(migrated.standalone).toBeTrue()
|
||||
expect(migrated.light.categorical?.length).toBeGreaterThan(0)
|
||||
expect(migrated.base.categorical?.length).toBeGreaterThan(0)
|
||||
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.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.800")
|
||||
expect(migrated.light.background?.raised?.base).toBe("$hue.neutral.700")
|
||||
expect(migrated.light.background?.raised?.high).toBe("$hue.neutral.600")
|
||||
expect(migrated.base.text?.default).toBe("$hue.neutral.200")
|
||||
expect(migrated.base.text?.subdued).toBe("$hue.neutral.400")
|
||||
expect(migrated.base.background?.action?.primary?.default).toBe("transparent")
|
||||
expect(migrated.base.background?.default).toBe("$hue.neutral.800")
|
||||
expect(migrated.base.background?.raised?.base).toBe("$hue.neutral.700")
|
||||
expect(migrated.base.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")
|
||||
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.light.text?.action?.secondary?.default).toBe("$text.subdued")
|
||||
expect(migrated.light.text?.action?.secondary?.$hovered).toBe("$text.default")
|
||||
expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
|
||||
expect(migrated.base.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.base.text?.action?.secondary?.default).toBe("$text.subdued")
|
||||
expect(migrated.base.text?.action?.secondary?.$hovered).toBe("$text.default")
|
||||
expect(migrated.base.background?.action?.primary?.$selected).toBe("transparent")
|
||||
expect(resolved.background.raised.base.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
||||
expect(resolved.background.raised.high.toInts()).toEqual(legacy.backgroundElement.toInts())
|
||||
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
|
||||
@@ -62,10 +61,10 @@ 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.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")
|
||||
expect(migrated.base.border?.default).toBe("$hue.interactive.200")
|
||||
expect(migrated.base.scrollbar?.default).toBe("$hue.accent.200")
|
||||
expect(migrated.base.syntax?.keyword).toMatch(/^\$hue\.[^.]+\.200$/)
|
||||
expect(migrated.base.markdown?.emphasis).toBe("#123456")
|
||||
})
|
||||
|
||||
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
|
||||
@@ -115,11 +114,11 @@ test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
source.theme.error = mapped("red")
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.light?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
expect(migrated.base.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
|
||||
source.theme.accent = source.theme.secondary
|
||||
expect(migrateV1(source).light?.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
|
||||
expect(migrateV1(source).base.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
|
||||
})
|
||||
|
||||
test("gives accent and primary ownership of their inferred hues", () => {
|
||||
@@ -159,7 +158,7 @@ test("uses default categorical hues when V1 semantic colors are ambiguous", () =
|
||||
source.theme.error = "transparent"
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.light?.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
expect(migrated.base.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
})
|
||||
|
||||
@@ -210,7 +209,7 @@ test("uses the default text reference for primary actions on transparent backgro
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||
|
||||
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.base.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
|
||||
})
|
||||
|
||||
|
||||
@@ -62,17 +62,20 @@ The `system` theme is available when OpenCode can read your terminal palette.
|
||||
|
||||
## Files
|
||||
|
||||
To create a global theme named `ocean`, save this file:
|
||||
To create a global theme named `ocean`, use the theme tool to generate a complete file in the global theme directory.
|
||||
Its relevant structure looks like this:
|
||||
|
||||
```json title="~/.config/opencode/themes/ocean.json"
|
||||
```json title="~/.config/opencode/themes/ocean.json (excerpt)"
|
||||
{
|
||||
"version": 2,
|
||||
"base": {
|
||||
"text": {
|
||||
"default": "#d8f3ff"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"hue": {
|
||||
"accent": "$hue.cyan"
|
||||
},
|
||||
"text": {
|
||||
"default": "#d8f3ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,46 +94,30 @@ same name.
|
||||
|
||||
## Format
|
||||
|
||||
Every V2 theme declares `version: 2` and at least one of `light` or `dark`:
|
||||
Every V2 theme declares `version: 2`, one complete `base` token tree, and at least one `light` or `dark` hue palette.
|
||||
Modes inherit only from that file's base; they do not inherit from the built-in OpenCode theme or from each other. Use
|
||||
the theme tool to generate a complete base before customizing it.
|
||||
|
||||
The examples below show only the sections relevant to each concept; they are not complete theme files.
|
||||
|
||||
```json title="themes/ocean.json"
|
||||
{
|
||||
"version": 2,
|
||||
"light": {
|
||||
"base": {
|
||||
"text": {
|
||||
"default": "#16324f"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"text": {
|
||||
"default": "#d8f3ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Custom themes inherit unspecified values from the built-in OpenCode theme. To base one mode on the other, add
|
||||
`mergeMode: true` to the mode containing the overrides:
|
||||
|
||||
```json title="themes/ocean.json"
|
||||
{
|
||||
"version": 2,
|
||||
"light": {
|
||||
"text": {
|
||||
"default": "#16324f"
|
||||
}
|
||||
"hue": {}
|
||||
},
|
||||
"dark": {
|
||||
"mergeMode": true,
|
||||
"text": {
|
||||
"default": "#d8f3ff"
|
||||
}
|
||||
"hue": {},
|
||||
"text": { "default": "#d8f3ff" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only one mode can use `mergeMode`, and the other mode must be present.
|
||||
|
||||
## Colors
|
||||
|
||||
Use a hex color, `transparent`, or a reference to another theme color:
|
||||
@@ -138,7 +125,7 @@ Use a hex color, `transparent`, or a reference to another theme color:
|
||||
```json title="themes/ocean.json"
|
||||
{
|
||||
"version": 2,
|
||||
"dark": {
|
||||
"base": {
|
||||
"text": {
|
||||
"default": "#d8f3ff",
|
||||
"subdued": "$hue.neutral.400"
|
||||
@@ -160,12 +147,14 @@ Override a hue alias to change a family of related colors at once:
|
||||
```json title="themes/ocean.json"
|
||||
{
|
||||
"version": 2,
|
||||
"base": {
|
||||
"categorical": ["accent", "purple", "green"]
|
||||
},
|
||||
"dark": {
|
||||
"hue": {
|
||||
"accent": "$hue.cyan",
|
||||
"interactive": "$hue.blue"
|
||||
},
|
||||
"categorical": ["accent", "purple", "green"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -189,7 +178,7 @@ Use state keys to change interactive colors. Unspecified states use the nearest
|
||||
```json title="themes/ocean.json"
|
||||
{
|
||||
"version": 2,
|
||||
"dark": {
|
||||
"base": {
|
||||
"text": {
|
||||
"action": {
|
||||
"primary": {
|
||||
@@ -212,12 +201,13 @@ Form fields use the same state keys directly under `text.formfield` or `backgrou
|
||||
|
||||
## Tokens
|
||||
|
||||
Override only the token groups your theme needs:
|
||||
The complete `base` contains every token group. A mode may override any part of it after providing its complete hue
|
||||
palette:
|
||||
|
||||
```json title="themes/ocean.json"
|
||||
{
|
||||
"version": 2,
|
||||
"dark": {
|
||||
"base": {
|
||||
"border": {
|
||||
"default": "$hue.neutral.700"
|
||||
},
|
||||
@@ -258,7 +248,7 @@ contextual `@dialog` surface. The TUI re-resolves the palette after applying `@d
|
||||
```json title="themes/ocean.json"
|
||||
{
|
||||
"version": 2,
|
||||
"dark": {
|
||||
"base": {
|
||||
"background": {
|
||||
"default": "#071521",
|
||||
"raised": {
|
||||
|
||||
Reference in New Issue
Block a user