From 86834066901da4543902d4028fbd7d3d37b0e615 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 22 Sep 2026 17:34:38 -0400 Subject: [PATCH] feat(theme): support dynamic hue names (#50728) --- packages/theme/src/tui/categorical.ts | 3 - packages/theme/src/tui/index.ts | 6 +- packages/theme/src/tui/resolve.ts | 55 ++++++--- packages/theme/src/tui/schema.ts | 46 ++++---- packages/theme/src/tui/types.ts | 7 +- packages/theme/src/tui/v1-migrate.ts | 17 ++- packages/tui/src/theme/index.ts | 26 ++--- .../component/terminal-pane-theme.test.ts | 16 ++- packages/tui/test/theme/v2/component.test.ts | 10 +- packages/tui/test/theme/v2/resolve.test.ts | 105 ++++++++++++------ packages/tui/test/theme/v2/v1-migrate.test.ts | 46 ++++---- 11 files changed, 204 insertions(+), 133 deletions(-) delete mode 100644 packages/theme/src/tui/categorical.ts diff --git a/packages/theme/src/tui/categorical.ts b/packages/theme/src/tui/categorical.ts deleted file mode 100644 index f0c90947659..00000000000 --- a/packages/theme/src/tui/categorical.ts +++ /dev/null @@ -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[] diff --git a/packages/theme/src/tui/index.ts b/packages/theme/src/tui/index.ts index 6fb827ed5bf..fd100a4ee90 100644 --- a/packages/theme/src/tui/index.ts +++ b/packages/theme/src/tui/index.ts @@ -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" diff --git a/packages/theme/src/tui/resolve.ts b/packages/theme/src/tui/resolve.ts index 73bee9ae463..62708675691 100644 --- a/packages/theme/src/tui/resolve.ts +++ b/packages/theme/src/tui/resolve.ts @@ -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> 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 @@ -156,10 +170,10 @@ function statefulColor(color: StatefulColor): StatefulColor { } function compileHueSteps( - hue: ResolvedThemeTokens["hue"], + hue: ResolvedHue, ): Pick { - const index = new WeakMap() - for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) { + const index = new WeakMap() + 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 const cache = new Map() - const expected = new Set([...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) { @@ -242,7 +251,7 @@ function createResolver(source: Record) { 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) { return (value: unknown, path: string) => resolve(value, path) } +function readColor(source: Record, 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) diff --git a/packages/theme/src/tui/schema.ts b/packages/theme/src/tui/schema.ts index 136ff70e494..727d1ef54cd 100644 --- a/packages/theme/src/tui/schema.ts +++ b/packages/theme/src/tui/schema.ts @@ -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 - -export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"]) -export type BaseHue = Schema.Schema.Type - -export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"]) -export type HueAlias = Schema.Schema.Type +export type SemanticHue = "accent" | "interactive" | "neutral" export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"]) export type ActionVariant = Schema.Schema.Type @@ -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 export const CategoricalDefinition = Schema.Array(HueName).check(Schema.isMinLength(1)) export type CategoricalDefinition = Schema.Schema.Type -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 +const HueValueDefinition = Schema.Union([ + Schema.String.check(Schema.isPattern(/^\$hue\..+$/)), + HueScaleDefinition, +]) +type HueValueDefinition = Schema.Schema.Type +const HueRecord = Schema.Record(HueName, HueValueDefinition) +const HueDefinition = HueRecord.check( + Schema.makeFilter>((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 & + Readonly> const StatefulColorDefinition = Schema.Struct({ base: Schema.optional(ColorValue), diff --git a/packages/theme/src/tui/types.ts b/packages/theme/src/tui/types.ts index 480711018ab..87d76bd5cda 100644 --- a/packages/theme/src/tui/types.ts +++ b/packages/theme/src/tui/types.ts @@ -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> -export type Hue = Readonly> -export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }> +export type Hue = Readonly> +export type HueSource = Readonly<{ hue: string; step: HueStep }> export type Categorical = readonly HueScale[] export type ActionStates = Readonly>> export type StatefulColor = Readonly> & { diff --git a/packages/theme/src/tui/v1-migrate.ts b/packages/theme/src/tui/v1-migrate.ts index bef0c6f0bc7..f99d36598f8 100644 --- a/packages/theme/src/tui/v1-migrate.ts +++ b/packages/theme/src/tui/v1-migrate.ts @@ -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, diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index 0c00a38e277..3c367afe0ad 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -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 = {} let systemTheme: ThemeDocumentSource | undefined const listeners = new Set<(themes: Record) => void>() const parsed = new WeakMap() -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 - opencodeTheme = decoded - return decoded + const document = parseThemeDocument(opencode, "opencode") as NonNullable + opencodeTheme = document + return document } function listThemes(): Record { @@ -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) - } -} diff --git a/packages/tui/test/component/terminal-pane-theme.test.ts b/packages/tui/test/component/terminal-pane-theme.test.ts index 17dc7758a6a..0e24a39b152 100644 --- a/packages/tui/test/component/terminal-pane-theme.test.ts +++ b/packages/tui/test/component/terminal-pane-theme.test.ts @@ -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> +} diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index b77a0bcaaa1..17f879a5c43 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -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) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index ead5568057c..2849451312c 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -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 + darkMode.hue = { ...(darkMode.hue as Record), accent: "$hue.missing" } + const missingSemantic = structuredClone(getOpenCodeTheme()) as ThemeDocumentSource + const lightMode = missingSemantic.light as Record + const lightHues = lightMode.hue as Record + 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> +} + function merge(...values: unknown[]): Record { return values.reduce>((result, value) => { if (!value || typeof value !== "object" || Array.isArray(value)) return result diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 4fef13e9b68..b79c53e1558 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -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> +} + 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")