mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(theme): support dynamic hue names (#50728)
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
import type { HueName } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = ["blue", "purple", "green", "orange", "red", "cyan"] as const satisfies readonly HueName[]
|
||||
@@ -2,13 +2,11 @@ export {
|
||||
ActionState,
|
||||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
BaseThemeDefinition,
|
||||
CategoricalDefinition,
|
||||
FeedbackKind,
|
||||
FormfieldState,
|
||||
type FormfieldStateKey,
|
||||
HueAlias,
|
||||
HueName,
|
||||
HueStep,
|
||||
MarkdownDefinition,
|
||||
@@ -24,6 +22,7 @@ export {
|
||||
type FormfieldColorDefinition,
|
||||
type HueDefinition,
|
||||
type Mode,
|
||||
type SemanticHue,
|
||||
type StatefulColorDefinition,
|
||||
type TextDefinition,
|
||||
type ThemeTokensDefinition,
|
||||
@@ -42,10 +41,9 @@ export type {
|
||||
ResolvedThemeTokens,
|
||||
StatefulColor,
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL } from "./categorical.js"
|
||||
export { rgbToOklch } from "./color.js"
|
||||
export { expandTheme } from "./expand.js"
|
||||
export { migrateV1 } from "./v1-migrate.js"
|
||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
export { parseThemeDocument, resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
|
||||
export { generateSyntax } from "./syntax.js"
|
||||
|
||||
@@ -4,8 +4,6 @@ import { expandTheme, mergeTheme } from "./expand.js"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
SurfaceName,
|
||||
ThemeDefinition,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
import type {
|
||||
ActionStateKey,
|
||||
ActionStates,
|
||||
HueDefinition,
|
||||
HueScale,
|
||||
Mode,
|
||||
ResolvedActionState,
|
||||
@@ -24,9 +21,12 @@ import type {
|
||||
StatefulColorDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index.js"
|
||||
import { selectThemeMode } from "./select.js"
|
||||
import { selectThemeMode, themeModes } from "./select.js"
|
||||
|
||||
type ResolvedHue = ResolvedThemeTokens["hue"] & Readonly<Record<string, HueScale>>
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition, { reportInput: true })
|
||||
const decodeThemeDocumentSchema = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
|
||||
|
||||
function decodeThemeDefinition(input: unknown) {
|
||||
try {
|
||||
@@ -42,6 +42,16 @@ export function themeDecodeError(error: unknown, name: string) {
|
||||
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
|
||||
}
|
||||
|
||||
export function parseThemeDocument(input: unknown, name = "theme") {
|
||||
try {
|
||||
const document = decodeThemeDocumentSchema(input)
|
||||
themeModes(document).forEach((mode) => resolveThemeDocument(document, mode))
|
||||
return document
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, name)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = expandTheme(selected.theme)
|
||||
@@ -54,7 +64,11 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = definition.categorical.map((name) => hue[name])
|
||||
const categorical = definition.categorical.map((name) => {
|
||||
const scale = hue[name]
|
||||
if (!scale) throw new Error(`Categorical hue "${name}" was not found`)
|
||||
return scale
|
||||
})
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const base = tokens(definition)
|
||||
const views = {} as Record<SurfaceName, ResolvedTheme>
|
||||
@@ -156,10 +170,10 @@ function statefulColor(color: StatefulColor): StatefulColor {
|
||||
}
|
||||
|
||||
function compileHueSteps(
|
||||
hue: ResolvedThemeTokens["hue"],
|
||||
hue: ResolvedHue,
|
||||
): Pick<ResolvedThemeTokens, "source" | "increase" | "decrease"> {
|
||||
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
|
||||
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
|
||||
const index = new WeakMap<RGBA, { hue: string; step: HueStep; position: number }>()
|
||||
for (const [name, scale] of Object.entries(hue)) {
|
||||
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
|
||||
}
|
||||
const shift = (color: RGBA, amount: number) => {
|
||||
@@ -179,13 +193,9 @@ function compileHueSteps(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHue(definition: HueDefinition) {
|
||||
function resolveHue(definition: ThemeDefinition["hue"]) {
|
||||
const source = definition as Record<string, unknown>
|
||||
const cache = new Map<string, HueScale>()
|
||||
const expected = new Set<string>([...BaseHue.literals, ...HueAlias.literals])
|
||||
for (const name of Object.keys(source)) {
|
||||
if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`)
|
||||
}
|
||||
|
||||
function resolve(name: string, stack: string[]): HueScale {
|
||||
const hit = cache.get(name)
|
||||
@@ -193,8 +203,9 @@ function resolveHue(definition: HueDefinition) {
|
||||
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
|
||||
const value = source[name]
|
||||
if (typeof value === "string") {
|
||||
const match = /^\$hue\.([^.]+)$/.exec(value)
|
||||
const match = /^\$hue\.(.+)$/.exec(value)
|
||||
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
|
||||
if (source[match[1]] === undefined) throw new Error(`Hue alias "${value}" references a missing hue`)
|
||||
const target = resolve(match[1], [...stack, name])
|
||||
const result = Object.fromEntries(HueStep.literals.map((step) => [step, RGBA.clone(target[step])])) as HueScale
|
||||
cache.set(name, result)
|
||||
@@ -216,9 +227,7 @@ function resolveHue(definition: HueDefinition) {
|
||||
return result
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
|
||||
) as ResolvedThemeTokens["hue"]
|
||||
return Object.fromEntries(Object.keys(source).map((name) => [name, resolve(name, [])])) as ResolvedHue
|
||||
}
|
||||
|
||||
function createResolver(source: Record<string, unknown>) {
|
||||
@@ -242,7 +251,7 @@ function createResolver(source: Record<string, unknown>) {
|
||||
const hit = cache.get(target)
|
||||
if (hit) return hit
|
||||
if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`)
|
||||
const result = resolve(read(source, target), target, [...stack, target])
|
||||
const result = resolve(readColor(source, target), target, [...stack, target])
|
||||
if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`)
|
||||
cache.set(target, result)
|
||||
return result
|
||||
@@ -251,6 +260,16 @@ function createResolver(source: Record<string, unknown>) {
|
||||
return (value: unknown, path: string) => resolve(value, path)
|
||||
}
|
||||
|
||||
function readColor(source: Record<string, unknown>, path: string) {
|
||||
const match = /^hue\.(.+)\.(100|200|300|400|500|600|700|800|900)$/.exec(path)
|
||||
if (!match?.[1] || !match[2]) return read(source, path)
|
||||
const scales = source.hue
|
||||
const scale = isRecord(scales) ? scales[match[1]] : undefined
|
||||
const color = isRecord(scale) ? scale[match[2]] : undefined
|
||||
if (color === undefined) throw new Error(`Theme reference "$${path}" was not found`)
|
||||
return color
|
||||
}
|
||||
|
||||
function resolvedKey(key: string) {
|
||||
if (!key.startsWith("$")) return key
|
||||
const state = key.slice(1)
|
||||
|
||||
@@ -2,12 +2,7 @@ import { Schema } from "effect"
|
||||
|
||||
export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900])
|
||||
export type HueStep = Schema.Schema.Type<typeof HueStep>
|
||||
|
||||
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
|
||||
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
|
||||
|
||||
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
|
||||
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
|
||||
export type SemanticHue = "accent" | "interactive" | "neutral"
|
||||
|
||||
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
|
||||
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
||||
@@ -37,29 +32,32 @@ const ColorValue = Schema.Union([
|
||||
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
|
||||
])
|
||||
|
||||
export const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
export const HueName = Schema.NonEmptyString
|
||||
export type HueName = Schema.Schema.Type<typeof HueName>
|
||||
export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1))
|
||||
export type CategoricalDefinition = Schema.Schema.Type<typeof CategoricalDefinition>
|
||||
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
|
||||
const HueColorValue = Schema.Union([
|
||||
HexColor,
|
||||
Schema.String.check(Schema.isPattern(/^\$hue\..+\.(?:100|200|300|400|500|600|700|800|900)$/)),
|
||||
])
|
||||
|
||||
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
|
||||
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
|
||||
|
||||
const HueDefinition = Schema.Struct({
|
||||
gray: HueValueDefinition,
|
||||
red: HueValueDefinition,
|
||||
orange: HueValueDefinition,
|
||||
yellow: HueValueDefinition,
|
||||
green: HueValueDefinition,
|
||||
cyan: HueValueDefinition,
|
||||
blue: HueValueDefinition,
|
||||
purple: HueValueDefinition,
|
||||
accent: HueValueDefinition,
|
||||
interactive: HueValueDefinition,
|
||||
neutral: HueValueDefinition,
|
||||
})
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
||||
const HueValueDefinition = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^\$hue\..+$/)),
|
||||
HueScaleDefinition,
|
||||
])
|
||||
type HueValueDefinition = Schema.Schema.Type<typeof HueValueDefinition>
|
||||
const HueRecord = Schema.Record(HueName, HueValueDefinition)
|
||||
const HueDefinition = HueRecord.check(
|
||||
Schema.makeFilter<Schema.Schema.Type<typeof HueRecord>>((hues) => {
|
||||
const missing = (["accent", "interactive", "neutral"] satisfies readonly SemanticHue[]).filter(
|
||||
(name) => hues[name] === undefined,
|
||||
)
|
||||
return missing.length ? `Missing required semantic hues: ${missing.join(", ")}` : undefined
|
||||
}),
|
||||
)
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition> &
|
||||
Readonly<Record<SemanticHue, HueValueDefinition>>
|
||||
|
||||
const StatefulColorDefinition = Schema.Struct({
|
||||
base: Schema.optional(ColorValue),
|
||||
|
||||
@@ -2,11 +2,10 @@ import type { RGBA } from "@opentui/core"
|
||||
import type {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
FeedbackKind,
|
||||
HueAlias,
|
||||
HueStep,
|
||||
MarkdownToken,
|
||||
SemanticHue,
|
||||
SurfaceName,
|
||||
SyntaxToken,
|
||||
} from "./schema.js"
|
||||
@@ -14,8 +13,8 @@ import type {
|
||||
export type ResolvedActionState = "base" | ActionState
|
||||
export type ResolvedFormfieldState = ResolvedActionState
|
||||
export type HueScale = Readonly<Record<HueStep, RGBA>>
|
||||
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
|
||||
export type Hue = Readonly<Record<SemanticHue, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: string; step: HueStep }>
|
||||
export type Categorical = readonly HueScale[]
|
||||
export type ActionStates = Readonly<Partial<Record<ActionState, boolean>>>
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>> & {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "./color.js"
|
||||
import { DEFAULT_CATEGORICAL } from "./categorical.js"
|
||||
import type { BaseThemeDefinition, HueDefinition, Mode, ThemeDefinition, ThemeDocument } from "./index.js"
|
||||
import { HueStep } from "./schema.js"
|
||||
import type { Theme, ThemeV1Json } from "./v1.js"
|
||||
@@ -10,7 +9,15 @@ type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "p
|
||||
type V1HueToken = "secondary" | "accent" | "success" | "warning" | "primary" | "error" | "info"
|
||||
|
||||
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
|
||||
const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "success", "warning", "primary", "error"]
|
||||
const categoricalTokens: readonly V1HueToken[] = [
|
||||
"secondary",
|
||||
"accent",
|
||||
"success",
|
||||
"warning",
|
||||
"primary",
|
||||
"error",
|
||||
"info",
|
||||
]
|
||||
const minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
// Canonical swatches copied from the original default-theme classifier keep V1 migration self-contained.
|
||||
@@ -98,16 +105,16 @@ function migrateMode(theme: Theme, mode: Mode): ThemeDefinition {
|
||||
hue: {
|
||||
gray: neutralScale(theme),
|
||||
...Object.fromEntries(
|
||||
chromaticHues.map((name) => {
|
||||
chromaticHues.flatMap((name) => {
|
||||
const match = hues.byHue[name]
|
||||
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
||||
return match ? [[name, hueScale(match.color, mode)]] : []
|
||||
}),
|
||||
),
|
||||
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,
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : ["neutral"],
|
||||
text: {
|
||||
base: text,
|
||||
muted: textMuted,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Schema } from "effect"
|
||||
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError, type ModeDefinition } from "@opencode/theme/tui"
|
||||
import {
|
||||
migrateV1,
|
||||
parseThemeDocument,
|
||||
resolveThemeDocument,
|
||||
type ThemeDocument,
|
||||
type ModeDefinition,
|
||||
} from "@opencode/theme/tui"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
|
||||
import opencode from "./assets/v2/opencode.json" with { type: "json" }
|
||||
@@ -14,7 +19,6 @@ let customThemes: Record<string, ThemeDocumentSource> = {}
|
||||
let systemTheme: ThemeDocumentSource | undefined
|
||||
const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>()
|
||||
const parsed = new WeakMap<object, ThemeDocument>()
|
||||
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
|
||||
let opencodeTheme: (ThemeDocument & {
|
||||
readonly light: ModeDefinition
|
||||
readonly dark: ModeDefinition
|
||||
@@ -22,9 +26,9 @@ let opencodeTheme: (ThemeDocument & {
|
||||
|
||||
export function getOpenCodeTheme() {
|
||||
if (opencodeTheme) return opencodeTheme
|
||||
const decoded = decodeThemeDocument(opencode) as NonNullable<typeof opencodeTheme>
|
||||
opencodeTheme = decoded
|
||||
return decoded
|
||||
const document = parseThemeDocument(opencode, "opencode") as NonNullable<typeof opencodeTheme>
|
||||
opencodeTheme = document
|
||||
return document
|
||||
}
|
||||
|
||||
function listThemes(): Record<string, ThemeDocumentSource> {
|
||||
@@ -59,7 +63,7 @@ export function parseTheme(source: ThemeDocumentSource, name = "theme") {
|
||||
const cached = parsed.get(source)
|
||||
if (cached) return cached
|
||||
|
||||
const document = "theme" in source ? migrateV1(source as ThemeV1Json) : decodeV2Theme(source, name)
|
||||
const document = "theme" in source ? migrateV1(source as ThemeV1Json) : parseThemeDocument(source, name)
|
||||
|
||||
parsed.set(source, document)
|
||||
return document
|
||||
@@ -116,11 +120,3 @@ export function resolveTheme(theme: ThemeV1Json, mode: "dark" | "light"): Theme
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function decodeV2Theme(source: ThemeDocumentSource, name: string) {
|
||||
try {
|
||||
return decodeThemeDocument(source)
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { resolveThemeDocument } from "@opencode/theme/tui"
|
||||
import { resolveThemeDocument, type HueScale, type ResolvedTheme } from "@opencode/theme/tui"
|
||||
import { terminalPalette } from "../../src/component/terminal-pane"
|
||||
import { getOpenCodeTheme } from "../../src/theme"
|
||||
|
||||
test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic colors", (mode) => {
|
||||
const theme = resolveThemeDocument(getOpenCodeTheme(), mode)
|
||||
const hues = allHues(theme)
|
||||
const black = theme.hue.neutral[mode === "dark" ? 800 : 200]
|
||||
const white = theme.hue.neutral[mode === "dark" ? 200 : 800]
|
||||
const brightWhite = theme.hue.neutral[mode === "dark" ? 100 : 900]
|
||||
@@ -12,8 +13,8 @@ test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic
|
||||
theme.text.feedback.error.base,
|
||||
theme.text.feedback.success.base,
|
||||
theme.text.feedback.warning.base,
|
||||
theme.hue.blue[200],
|
||||
mode === "dark" ? theme.hue.purple[200] : theme.hue.accent[200],
|
||||
hues.blue[200],
|
||||
mode === "dark" ? hues.purple[200] : theme.hue.accent[200],
|
||||
theme.text.feedback.info.base,
|
||||
white,
|
||||
]
|
||||
@@ -38,9 +39,10 @@ test.each(["light", "dark"] as const)("builds the %s ANSI palette from semantic
|
||||
|
||||
test("falls back to accent when no hue is confidently blue or magenta", () => {
|
||||
const resolved = resolveThemeDocument(getOpenCodeTheme(), "dark")
|
||||
const hues = allHues(resolved)
|
||||
const hue = {
|
||||
...Object.fromEntries(Object.keys(resolved.hue).map((name) => [name, resolved.hue.red])),
|
||||
accent: resolved.hue.green,
|
||||
...Object.fromEntries(Object.keys(hues).map((name) => [name, hues.red])),
|
||||
accent: hues.green,
|
||||
} as typeof resolved.hue
|
||||
const theme = { ...resolved, hue }
|
||||
const palette = [
|
||||
@@ -60,3 +62,7 @@ function byte(value: number) {
|
||||
function hex(color: { toInts(): [number, number, number, number] }) {
|
||||
return `#${color.toInts().slice(0, 3).map(byte).join("")}`
|
||||
}
|
||||
|
||||
function allHues(theme: ResolvedTheme) {
|
||||
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ test("provides reactive properties, states, surfaces, and color operations", ()
|
||||
expect(theme.text.base).toBe(resolved().text.base)
|
||||
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
|
||||
expect(theme.hue.interactive[500]).toBe(resolved().hue.interactive[500])
|
||||
expect(theme.hue.gray[200]).toBe(resolved().hue.gray[200])
|
||||
expect(theme.hue.neutral[200]).toBe(resolved().hue.neutral[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[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.decrease(theme.hue.accent[300], 2)).toBe(resolved().hue.accent[100])
|
||||
expect(theme.increase(theme.hue.accent[900], 3)).toBe(resolved().hue.accent[900])
|
||||
expect(theme.decrease(theme.hue.accent[100], 3)).toBe(resolved().hue.accent[100])
|
||||
expect(theme.source(theme.background.raised.base)).toEqual({ hue: "neutral", step: 700 })
|
||||
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
|
||||
const equivalent = RGBA.fromInts(...resolved().categorical[0][500].toInts())
|
||||
expect(theme.source(equivalent)).toBeUndefined()
|
||||
expect(theme.increase(equivalent, 1)).toBe(equivalent)
|
||||
const unmatched = RGBA.fromInts(1, 2, 3)
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
BaseHue,
|
||||
generateSyntax,
|
||||
resolveTheme,
|
||||
resolveThemeDocument,
|
||||
selectTheme,
|
||||
type HueScale,
|
||||
type Mode,
|
||||
type ResolvedTheme,
|
||||
type ThemeDefinition,
|
||||
} from "@opencode/theme/tui"
|
||||
import { getOpenCodeTheme, parseTheme, type ThemeDocumentSource } from "../../../src/theme"
|
||||
|
||||
const opencodeLight = selectTheme(getOpenCodeTheme(), "light")
|
||||
const opencodeDark = selectTheme(getOpenCodeTheme(), "dark")
|
||||
const light = {
|
||||
const chromaticHues = ["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"] as const
|
||||
const light: ThemeDefinition = {
|
||||
...opencodeLight,
|
||||
categorical: ["blue", "purple"],
|
||||
hue: { ...opencodeLight.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
|
||||
} satisfies ThemeDefinition
|
||||
const dark = {
|
||||
}
|
||||
const dark: ThemeDefinition = {
|
||||
...opencodeDark,
|
||||
categorical: ["blue", "purple"],
|
||||
hue: { ...opencodeDark.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
|
||||
} satisfies ThemeDefinition
|
||||
}
|
||||
|
||||
test("orders light hues dark-to-light and dark hues light-to-dark", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
const lightHues = allHues(lightTheme)
|
||||
const darkHues = allHues(darkTheme)
|
||||
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]))
|
||||
chromaticHues.forEach((name) => {
|
||||
expect(luminance(lightHues[name][100])).toBeLessThan(luminance(lightHues[name][900]))
|
||||
expect(luminance(darkHues[name][100])).toBeGreaterThan(luminance(darkHues[name][900]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,9 +65,10 @@ test("rejects theme documents without a mode", () => {
|
||||
|
||||
test("validates and resolves categorical hues in configured order", () => {
|
||||
const theme = resolveSource(complete("light", { categorical: ["accent", "red", "interactive"] }), "light")
|
||||
const hues = allHues(theme)
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.accent)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.red)
|
||||
expect(theme.categorical[1]).toBe(hues.red)
|
||||
expect(theme.categorical[2]).toBe(theme.hue.interactive)
|
||||
expect(theme.surface("dialog").categorical).toBe(theme.categorical)
|
||||
expect(() => resolveSource(complete("light", { categorical: [] }), "light")).toThrow("Invalid theme")
|
||||
@@ -72,6 +77,36 @@ test("validates and resolves categorical hues in configured order", () => {
|
||||
).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("resolves arbitrary hue names across aliases, categorical colors, and token references", () => {
|
||||
const ocean = light.hue.blue
|
||||
if (typeof ocean !== "object") throw new Error("Expected a concrete blue scale")
|
||||
const theme = resolveTheme({
|
||||
...light,
|
||||
hue: { ...light.hue, "brand.ocean": ocean, accent: "$hue.brand.ocean" },
|
||||
categorical: ["brand.ocean"],
|
||||
syntax: { ...light.syntax, keyword: "$hue.brand.ocean.200" },
|
||||
})
|
||||
const hues = allHues(theme)
|
||||
|
||||
expect(theme.hue.accent[200].equals(hues["brand.ocean"][200])).toBeTrue()
|
||||
expect(theme.categorical[0]).toBe(hues["brand.ocean"])
|
||||
expect(theme.syntax.keyword).toBe(hues["brand.ocean"][200])
|
||||
expect(theme.source(theme.syntax.keyword)).toEqual({ hue: "brand.ocean", step: 200 })
|
||||
})
|
||||
|
||||
test("validates hue relationships in every provided mode while parsing", () => {
|
||||
const source = structuredClone(getOpenCodeTheme()) as ThemeDocumentSource
|
||||
const darkMode = source.dark as Record<string, unknown>
|
||||
darkMode.hue = { ...(darkMode.hue as Record<string, unknown>), accent: "$hue.missing" }
|
||||
const missingSemantic = structuredClone(getOpenCodeTheme()) as ThemeDocumentSource
|
||||
const lightMode = missingSemantic.light as Record<string, unknown>
|
||||
const lightHues = lightMode.hue as Record<string, unknown>
|
||||
delete lightHues.neutral
|
||||
|
||||
expect(() => parseTheme(source, "broken-dark")).toThrow("Invalid theme: broken-dark")
|
||||
expect(() => parseTheme(missingSemantic, "missing-semantic")).toThrow("Invalid theme: missing-semantic")
|
||||
})
|
||||
|
||||
test("generates syntax with one categorical hue", () => {
|
||||
const theme = resolveSource(complete("light", { categorical: ["red"] }), "light")
|
||||
const syntax = generateSyntax(theme)
|
||||
@@ -90,22 +125,24 @@ test("rejects incomplete themes instead of merging defaults", () => {
|
||||
test("resolves independent definitions and hue aliases", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
const lightHues = allHues(lightTheme)
|
||||
const darkHues = allHues(darkTheme)
|
||||
|
||||
expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.interactive).not.toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.hue.interactive[500].equals(lightTheme.hue.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.neutral).not.toBe(lightTheme.hue.gray)
|
||||
expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
|
||||
expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(lightTheme.hue.accent).not.toBe(lightHues.blue)
|
||||
expect(lightTheme.hue.accent[500].equals(lightHues.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.interactive).not.toBe(lightHues.blue)
|
||||
expect(lightTheme.hue.interactive[500].equals(lightHues.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.neutral).not.toBe(lightHues.gray)
|
||||
expect(lightTheme.hue.neutral[500].equals(lightHues.gray[500])).toBeTrue()
|
||||
expect(lightTheme.categorical[0]).toBe(lightHues.blue)
|
||||
expect(lightTheme.source(lightHues.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: 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.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
|
||||
expect(darkTheme.decrease(darkTheme.hue.red[200])).toBe(darkTheme.hue.red[100])
|
||||
expect(lightTheme.increase(lightHues.red[100])).toBe(lightHues.red[200])
|
||||
expect(lightTheme.decrease(lightHues.red[200])).toBe(lightHues.red[100])
|
||||
expect(lightTheme.surface("dialog").increase(lightHues.red[100])).toBe(lightHues.red[200])
|
||||
expect(lightTheme.decrease(lightHues.red[200])).toBe(lightHues.red[100])
|
||||
expect(darkTheme.decrease(darkHues.red[200])).toBe(darkHues.red[100])
|
||||
expect(lightTheme.text.base).toBeInstanceOf(RGBA)
|
||||
expect(darkTheme.background.base).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[700])
|
||||
@@ -136,16 +173,18 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||
complete("light", { hue: { ...light.hue, blue: "$hue.red" } }),
|
||||
"light",
|
||||
)
|
||||
const aliasedHues = allHues(aliased)
|
||||
const overriddenHues = allHues(overridden)
|
||||
|
||||
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
|
||||
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
|
||||
expect(aliased.hue.purple).not.toBe(aliased.hue.blue)
|
||||
expect(aliased.hue.purple[500].equals(aliased.hue.red[500])).toBeTrue()
|
||||
expect(overridden.hue.blue).not.toBe(overridden.hue.red)
|
||||
expect(overridden.hue.blue[500].equals(overridden.hue.red[500])).toBeTrue()
|
||||
expect(aliased.source(aliased.hue.red[500])).toEqual({ hue: "red", step: 500 })
|
||||
expect(aliased.source(aliased.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(aliased.source(aliased.hue.purple[500])).toEqual({ hue: "purple", step: 500 })
|
||||
expect(aliasedHues.blue).not.toBe(aliasedHues.red)
|
||||
expect(aliasedHues.blue[500].equals(aliasedHues.red[500])).toBeTrue()
|
||||
expect(aliasedHues.purple).not.toBe(aliasedHues.blue)
|
||||
expect(aliasedHues.purple[500].equals(aliasedHues.red[500])).toBeTrue()
|
||||
expect(overriddenHues.blue).not.toBe(overriddenHues.red)
|
||||
expect(overriddenHues.blue[500].equals(overriddenHues.red[500])).toBeTrue()
|
||||
expect(aliased.source(aliasedHues.red[500])).toEqual({ hue: "red", step: 500 })
|
||||
expect(aliased.source(aliasedHues.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(aliased.source(aliasedHues.purple[500])).toEqual({ hue: "purple", step: 500 })
|
||||
expect(() =>
|
||||
resolveTheme(
|
||||
{
|
||||
@@ -320,6 +359,10 @@ function override(base: ThemeDefinition, value: unknown) {
|
||||
return merge(base, value) as ThemeDefinition
|
||||
}
|
||||
|
||||
function allHues(theme: ResolvedTheme) {
|
||||
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
|
||||
}
|
||||
|
||||
function merge(...values: unknown[]): Record<string, unknown> {
|
||||
return values.reduce<Record<string, unknown>>((result, value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return result
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
DEFAULT_CATEGORICAL,
|
||||
migrateV1,
|
||||
resolveThemeDocument,
|
||||
selectThemeMode,
|
||||
themeModes,
|
||||
type HueScale,
|
||||
type ResolvedTheme,
|
||||
} from "@opencode/theme/tui"
|
||||
import { DEFAULT_THEMES, getOpenCodeTheme, resolveTheme as resolveV1 } from "../../../src/theme"
|
||||
import opencodeSource from "../../../src/theme/assets/opencode.json" with { type: "json" }
|
||||
@@ -13,6 +14,7 @@ import type { ThemeV1Json } from "@opencode/theme/tui/v1"
|
||||
const opencodeV1 = opencodeSource as ThemeV1Json
|
||||
const opencodeLight = resolveThemeDocument(getOpenCodeTheme(), "light")
|
||||
const opencodeDark = resolveThemeDocument(getOpenCodeTheme(), "dark")
|
||||
const opencodeLightHues = allHues(opencodeLight)
|
||||
|
||||
test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
const migrated = migrateV1(opencodeV1)
|
||||
@@ -72,7 +74,7 @@ test("references generated hues from matching token colors", () => {
|
||||
expect(migrated.base.markdown?.emphasis).toBe("#123456")
|
||||
})
|
||||
|
||||
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
|
||||
test("infers and emits only chromatic hues represented by V1 colors", () => {
|
||||
const source = structuredClone(opencodeV1)
|
||||
const ambiguous = { light: "#808080", dark: "#808080" }
|
||||
source.theme.accent = ambiguous
|
||||
@@ -93,12 +95,12 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
||||
expect(darkRed[200]).toBe("#450000")
|
||||
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")
|
||||
expect(migrated.light.hue?.green).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.cyan).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.blue).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.purple).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.orange).toBeUndefined()
|
||||
expect(migrated.light.hue?.yellow).toBeUndefined()
|
||||
expect(migrated.light.hue?.green).toBeUndefined()
|
||||
expect(migrated.light.hue?.cyan).toBeUndefined()
|
||||
expect(migrated.light.hue?.blue).toBeUndefined()
|
||||
expect(migrated.light.hue?.purple).toBeUndefined()
|
||||
expect(migrated.light.hue?.accent).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.interactive).toBe("$hue.gray")
|
||||
expect(() => resolveThemeDocument(migrated, "light")).not.toThrow()
|
||||
@@ -135,21 +137,22 @@ test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
source.theme.warning = mapped("yellow")
|
||||
source.theme.primary = mapped("blue")
|
||||
source.theme.error = mapped("red")
|
||||
source.theme.info = { light: "#67e8f9", dark: "#0e7490" }
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.base.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
expect(migrated.base.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red", "cyan"])
|
||||
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red", "cyan"])
|
||||
|
||||
source.theme.accent = source.theme.secondary
|
||||
expect(migrateV1(source).base.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
|
||||
expect(migrateV1(source).base.categorical).toEqual(["purple", "green", "yellow", "blue", "red", "cyan"])
|
||||
})
|
||||
|
||||
test("gives accent and primary ownership of their inferred hues", () => {
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.success = hex(opencodeLight.hue.orange[700])
|
||||
source.theme.accent = hex(opencodeLight.hue.orange[600])
|
||||
source.theme.info = hex(opencodeLight.hue.blue[700])
|
||||
source.theme.primary = hex(opencodeLight.hue.blue[600])
|
||||
source.theme.success = hex(opencodeLightHues.orange[700])
|
||||
source.theme.accent = hex(opencodeLightHues.orange[600])
|
||||
source.theme.info = hex(opencodeLightHues.blue[700])
|
||||
source.theme.primary = hex(opencodeLightHues.blue[600])
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light) throw new Error("Expected light mode")
|
||||
@@ -162,7 +165,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
|
||||
expect(migrated.light.hue?.accent).toBe("$hue.orange")
|
||||
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
|
||||
|
||||
source.theme.primary = hex(opencodeLight.hue.orange[500])
|
||||
source.theme.primary = hex(opencodeLightHues.orange[500])
|
||||
const collisionMode = migrateV1(source).light
|
||||
const collision = collisionMode?.hue?.orange
|
||||
if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
|
||||
@@ -171,7 +174,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
|
||||
expect(collisionMode?.hue?.interactive).toBe("$hue.orange")
|
||||
})
|
||||
|
||||
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
|
||||
test("uses the semantic neutral hue when V1 categorical colors are ambiguous", () => {
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.secondary = "transparent"
|
||||
source.theme.accent = "transparent"
|
||||
@@ -179,10 +182,11 @@ test("uses default categorical hues when V1 semantic colors are ambiguous", () =
|
||||
source.theme.warning = "transparent"
|
||||
source.theme.primary = "transparent"
|
||||
source.theme.error = "transparent"
|
||||
source.theme.info = "transparent"
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.base.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
expect(migrated.base.categorical).toEqual(["neutral"])
|
||||
expect(migrated.dark?.categorical).toEqual(["neutral"])
|
||||
})
|
||||
|
||||
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
|
||||
@@ -282,6 +286,10 @@ test("keeps both modes when a shared background has different contrast", () => {
|
||||
expect(themeModes(migrated)).toEqual(["light", "dark"])
|
||||
})
|
||||
|
||||
function allHues(theme: ResolvedTheme) {
|
||||
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
|
||||
}
|
||||
|
||||
function hex(color: { toInts(): [number, number, number, number] }) {
|
||||
const [r, g, b, a] = color.toInts()
|
||||
const byte = (value: number) => value.toString(16).padStart(2, "0")
|
||||
|
||||
Reference in New Issue
Block a user