feat(theme): support dynamic hue names (#50728)

This commit is contained in:
James Long
2026-09-22 17:34:38 -04:00
committed by GitHub
parent 126294a322
commit 8683406690
11 changed files with 204 additions and 133 deletions
-3
View File
@@ -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 -4
View File
@@ -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"
+37 -18
View File
@@ -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)
+22 -24
View File
@@ -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),
+3 -4
View File
@@ -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>> & {
+12 -5
View File
@@ -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,
+11 -15
View File
@@ -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>>
}
+5 -5
View File
@@ -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)
+74 -31
View File
@@ -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
+27 -19
View File
@@ -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")