feat(tui): add categorical theme hues (#37985)
This commit is contained in:
parent
0aa4031dd0
commit
79d0077f63
13 changed files with 202 additions and 91 deletions
|
|
@ -41,10 +41,7 @@ export function parseModel(model: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function recentModels(
|
||||
model: ModelPreferenceModel,
|
||||
recent: ModelPreferenceModel[],
|
||||
) {
|
||||
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
|
||||
const seen = new Set<string>()
|
||||
return [model, ...recent]
|
||||
.filter((item) => {
|
||||
|
|
@ -94,17 +91,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
})
|
||||
const colors = createMemo(() => {
|
||||
const step = mode() === "light" ? 800 : 200
|
||||
return dedupeWith(
|
||||
[
|
||||
themeV2.hue.blue(step),
|
||||
themeV2.hue.purple(step),
|
||||
themeV2.hue.green(step),
|
||||
themeV2.hue.orange(step),
|
||||
themeV2.hue.red(step),
|
||||
themeV2.hue.cyan(step),
|
||||
],
|
||||
(first, second) => first.equals(second),
|
||||
)
|
||||
return dedupeWith(themeV2.categorical(step), (first, second) => first.equals(second))
|
||||
})
|
||||
return {
|
||||
list() {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type {
|
||||
ActionVariant,
|
||||
Mode,
|
||||
ResolvedActionState,
|
||||
ResolvedThemeView,
|
||||
} from "./index"
|
||||
import type { ActionVariant, Mode, ResolvedActionState, ResolvedThemeView } from "./index"
|
||||
import { ActionState, HueStep } from "./schema"
|
||||
|
||||
type StateFlags = Partial<Record<ActionState, boolean>>
|
||||
|
|
@ -77,6 +72,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode:
|
|||
|
||||
return {
|
||||
hue,
|
||||
categorical: (step: HueStep) => current().categorical.map((scale) => scale[step]),
|
||||
source: (color: RGBA) => current().source(color),
|
||||
increase: (color: RGBA, amount = 1) => current().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
|
||||
|
|
@ -86,29 +82,29 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode:
|
|||
border: () => current().border.default,
|
||||
scrollbar: () => current().scrollbar.default,
|
||||
diff: {
|
||||
text: {
|
||||
added: () => current().diff.text.added,
|
||||
removed: () => current().diff.text.removed,
|
||||
context: () => current().diff.text.context,
|
||||
hunkHeader: () => current().diff.text.hunkHeader,
|
||||
},
|
||||
text: {
|
||||
added: () => current().diff.text.added,
|
||||
removed: () => current().diff.text.removed,
|
||||
context: () => current().diff.text.context,
|
||||
hunkHeader: () => current().diff.text.hunkHeader,
|
||||
},
|
||||
background: {
|
||||
added: () => current().diff.background.added,
|
||||
removed: () => current().diff.background.removed,
|
||||
context: () => current().diff.background.context,
|
||||
},
|
||||
highlight: {
|
||||
added: () => current().diff.highlight.added,
|
||||
removed: () => current().diff.highlight.removed,
|
||||
},
|
||||
lineNumber: {
|
||||
text: () => current().diff.lineNumber.text,
|
||||
background: {
|
||||
added: () => current().diff.background.added,
|
||||
removed: () => current().diff.background.removed,
|
||||
context: () => current().diff.background.context,
|
||||
},
|
||||
highlight: {
|
||||
added: () => current().diff.highlight.added,
|
||||
removed: () => current().diff.highlight.removed,
|
||||
},
|
||||
lineNumber: {
|
||||
text: () => current().diff.lineNumber.text,
|
||||
background: {
|
||||
added: () => current().diff.lineNumber.background.added,
|
||||
removed: () => current().diff.lineNumber.background.removed,
|
||||
},
|
||||
added: () => current().diff.lineNumber.background.added,
|
||||
removed: () => current().diff.lineNumber.background.removed,
|
||||
},
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: () => current().syntax.comment,
|
||||
keyword: () => current().syntax.keyword,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import type { ThemeFile } from "./index"
|
||||
import type { HueName, ThemeFile } from "./schema"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
"purple",
|
||||
"green",
|
||||
"orange",
|
||||
"red",
|
||||
"cyan",
|
||||
] as const satisfies readonly HueName[]
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
|
|
@ -96,6 +105,7 @@ export const DEFAULT_THEME = {
|
|||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.800",
|
||||
subdued: "$hue.neutral.600",
|
||||
|
|
@ -308,6 +318,7 @@ export const DEFAULT_THEME = {
|
|||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.200",
|
||||
subdued: "$hue.neutral.400",
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ export {
|
|||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
CategoricalDefinition,
|
||||
FeedbackKind,
|
||||
FormfieldState,
|
||||
type FormfieldStateKey,
|
||||
HueAlias,
|
||||
HueName,
|
||||
HueStep,
|
||||
MarkdownDefinition,
|
||||
MarkdownToken,
|
||||
|
|
@ -30,6 +32,7 @@ export {
|
|||
} from "./schema"
|
||||
|
||||
export type {
|
||||
Categorical,
|
||||
FormfieldColor,
|
||||
Hue,
|
||||
HueSource,
|
||||
|
|
@ -40,5 +43,6 @@ export type {
|
|||
ResolvedThemeView,
|
||||
StatefulColor,
|
||||
} from "./types"
|
||||
export { DEFAULT_CATEGORICAL } from "./defaults"
|
||||
export { migrateV1 } from "./v1-migrate"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_THEME } from "./defaults"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand"
|
||||
import { fallback } from "./fallback"
|
||||
import {
|
||||
|
|
@ -56,11 +56,12 @@ export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name
|
|||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const merged = decoded.standalone
|
||||
? mergeTheme(core, definition)
|
||||
: mergeTheme(core, defaults, definition)
|
||||
const merged = decoded.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme(merged as ThemeDefinition)
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
|
||||
} as ThemeDefinition)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
|
|
@ -69,15 +70,16 @@ export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
|||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const base = tokens(definition)
|
||||
const resolved = resolveView(base, hue, hueSteps)
|
||||
const resolved = resolveView(base, hue, categorical, hueSteps)
|
||||
const contexts = Object.fromEntries(
|
||||
Object.entries(definition)
|
||||
.filter(([key]) => key.startsWith("@context:"))
|
||||
.map(([key, override]) => {
|
||||
const contextual = contextualize(base, override as ThemeTokensDefinition)
|
||||
return [key, resolveView(contextual, hue, hueSteps)]
|
||||
return [key, resolveView(contextual, hue, categorical, hueSteps)]
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -141,15 +143,14 @@ function contextualActions(
|
|||
function resolveView(
|
||||
definition: ThemeTokensDefinition,
|
||||
hue: ResolvedThemeView["hue"],
|
||||
categorical: ResolvedThemeView["categorical"],
|
||||
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
|
||||
): ResolvedThemeView {
|
||||
const source: Record<string, unknown> = { hue, ...definition }
|
||||
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, ...hueSteps }
|
||||
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, categorical, ...hueSteps }
|
||||
}
|
||||
|
||||
function compileHueSteps(
|
||||
hue: ResolvedThemeView["hue"],
|
||||
): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
|
||||
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "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][]) {
|
||||
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
|
||||
|
|
@ -201,7 +202,8 @@ function resolveHue(definition: HueDefinition) {
|
|||
}),
|
||||
) as HueScale
|
||||
for (const step of Object.keys(value)) {
|
||||
if (!HueStep.literals.includes(Number(step) as HueStep)) throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
|
||||
if (!HueStep.literals.includes(Number(step) as HueStep))
|
||||
throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
|
||||
}
|
||||
cache.set(name, result)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ const ColorValue = Schema.Union([
|
|||
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
|
||||
])
|
||||
|
||||
const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
export const HueName = Schema.Union([BaseHue, HueAlias])
|
||||
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 ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
|
||||
|
|
@ -214,6 +217,7 @@ export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinit
|
|||
|
||||
const ThemeDefinitionFields = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
|
|
@ -223,6 +227,7 @@ export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
|
|||
|
||||
const FileThemeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
|
|
@ -232,6 +237,7 @@ export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
|
|||
const MergeModeDefinition = Schema.Struct({
|
||||
mergeMode: Schema.Literal(true),
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@context:elevated": Schema.optional(ThemeTokensDefinition),
|
||||
"@context:overlay": Schema.optional(ThemeTokensDefinition),
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ 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 Categorical = readonly HueScale[]
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
|
||||
export type FormfieldColor = StatefulColor
|
||||
|
||||
export type ResolvedThemeView = {
|
||||
readonly hue: Hue
|
||||
readonly categorical: Categorical
|
||||
readonly source: (color: RGBA) => HueSource | undefined
|
||||
readonly increase: (color: RGBA, amount?: number) => RGBA
|
||||
readonly decrease: (color: RGBA, amount?: number) => RGBA
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||
import type { Theme, ThemeJson } from "../index"
|
||||
import { DEFAULT_THEME } from "./defaults"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import type { FileThemeDefinition, Mode, ThemeFile } from "./index"
|
||||
import { HueStep } from "./schema"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
||||
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 minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
|
||||
|
|
@ -44,6 +46,11 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
|||
const selected = hex(selectedForeground(theme, theme.primary))
|
||||
const destructive = hex(selectedForeground(theme, theme.error))
|
||||
const hues = inferHues(theme, mode)
|
||||
const categorical = categoricalTokens.flatMap((token) => {
|
||||
const hue = hues.byToken[token]
|
||||
return hue ? [hue] : []
|
||||
})
|
||||
const uniqueCategorical = categorical.filter((hue, index) => categorical.indexOf(hue) === index)
|
||||
const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
|
||||
const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
|
||||
const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
|
||||
|
|
@ -56,7 +63,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
|||
gray: neutralScale(theme, mode),
|
||||
...Object.fromEntries(
|
||||
chromaticHues.map((name) => {
|
||||
const match = hues[name]
|
||||
const match = hues.byHue[name]
|
||||
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
||||
}),
|
||||
),
|
||||
|
|
@ -64,6 +71,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
|||
interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: text,
|
||||
subdued: textMuted,
|
||||
|
|
@ -172,22 +180,45 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
|||
}
|
||||
|
||||
function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
return [theme.accent, theme.success, theme.warning, theme.primary, theme.error, theme.info, theme.secondary].reduce<
|
||||
Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
||||
>((result, color) => {
|
||||
const value = toOklch(color)
|
||||
if (ambiguous(color, value.c)) return result
|
||||
const anchor = inferenceAnchor(value.l)
|
||||
const nearest = chromaticHues
|
||||
.map((name) => ({
|
||||
name,
|
||||
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
||||
}))
|
||||
.sort((first, second) => first.distance - second.distance)[0]
|
||||
const current = result[nearest.name]
|
||||
if (current && current.distance <= nearest.distance) return result
|
||||
return { ...result, [nearest.name]: { color, distance: nearest.distance } }
|
||||
}, {})
|
||||
const colors: readonly [V1HueToken, RGBA][] = [
|
||||
["accent", theme.accent],
|
||||
["success", theme.success],
|
||||
["warning", theme.warning],
|
||||
["primary", theme.primary],
|
||||
["error", theme.error],
|
||||
["info", theme.info],
|
||||
["secondary", theme.secondary],
|
||||
]
|
||||
return colors.reduce<{
|
||||
byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
||||
byToken: Partial<Record<V1HueToken, ChromaticHue>>
|
||||
}>(
|
||||
(result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
if (!nearest) return result
|
||||
const current = result.byHue[nearest.name]
|
||||
return {
|
||||
byHue:
|
||||
current && current.distance <= nearest.distance
|
||||
? result.byHue
|
||||
: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
||||
byToken: { ...result.byToken, [token]: nearest.name },
|
||||
}
|
||||
},
|
||||
{ byHue: {}, byToken: {} },
|
||||
)
|
||||
}
|
||||
|
||||
function inferHue(color: RGBA, mode: Mode) {
|
||||
const value = toOklch(color)
|
||||
if (ambiguous(color, value.c)) return
|
||||
const anchor = inferenceAnchor(value.l)
|
||||
return chromaticHues
|
||||
.map((name) => ({
|
||||
name,
|
||||
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
||||
}))
|
||||
.sort((first, second) => first.distance - second.distance)[0]
|
||||
}
|
||||
|
||||
function inferenceAnchor(lightness: number): HueStep {
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@ test("provides reactive property, variant, state, and context accessors", () =>
|
|||
const [context, setContext] = createSignal<ContextKey>()
|
||||
const theme = createComponentTheme(() => {
|
||||
const key = context()
|
||||
return key ? resolved().contexts[key] ?? resolved() : resolved()
|
||||
return key ? (resolved().contexts[key] ?? resolved()) : resolved()
|
||||
}, mode)
|
||||
|
||||
expect(theme.text()).toBe(resolved().text.default)
|
||||
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.categorical(500)).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100])
|
||||
|
|
@ -39,30 +40,20 @@ test("provides reactive property, variant, state, and context accessors", () =>
|
|||
expect(theme.background.action("selected")).toBe(resolved().background.action.primary.selected)
|
||||
expect(theme.background.action("hovered")).toBe(resolved().background.action.primary.hovered)
|
||||
expect(theme.background.action({ selected: true })).toBe(resolved().background.action.primary.selected)
|
||||
expect(theme.background.action({ selected: true, hovered: true })).toBe(
|
||||
resolved().background.action.primary.selected,
|
||||
)
|
||||
expect(theme.background.action({ focused: true, selected: true })).toBe(
|
||||
resolved().background.action.primary.focused,
|
||||
)
|
||||
expect(theme.background.action({ selected: true, hovered: true })).toBe(resolved().background.action.primary.selected)
|
||||
expect(theme.background.action({ focused: true, selected: true })).toBe(resolved().background.action.primary.focused)
|
||||
expect(theme.background.action({ pressed: true, focused: true, selected: true })).toBe(
|
||||
resolved().background.action.primary.pressed,
|
||||
)
|
||||
expect(
|
||||
theme.background.action({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }),
|
||||
).toBe(
|
||||
expect(theme.background.action({ disabled: true, pressed: true, focused: true, selected: true, hovered: true })).toBe(
|
||||
resolved().background.action.primary.disabled,
|
||||
)
|
||||
expect(theme.background.action({ disabled: false, selected: false })).toBe(
|
||||
resolved().background.action.primary.default,
|
||||
)
|
||||
expect(theme.background.action.destructive("disabled")).toBe(
|
||||
resolved().background.action.destructive.disabled,
|
||||
)
|
||||
expect(theme.background.action.destructive("disabled")).toBe(resolved().background.action.destructive.disabled)
|
||||
expect(theme.background.formfield("hovered")).toBe(resolved().background.formfield.hovered)
|
||||
expect(theme.background.formfield({ selected: true, hovered: true })).toBe(
|
||||
resolved().background.formfield.selected,
|
||||
)
|
||||
expect(theme.background.formfield({ selected: true, hovered: true })).toBe(resolved().background.formfield.selected)
|
||||
expect(theme.background.formfield({ focused: true, selected: true, hovered: true })).toBe(
|
||||
resolved().background.formfield.focused,
|
||||
)
|
||||
|
|
@ -75,6 +66,7 @@ test("provides reactive property, variant, state, and context accessors", () =>
|
|||
expect(theme.diff.text.added()).toBe(resolved().diff.text.added)
|
||||
|
||||
setContext("@context:elevated")
|
||||
expect(theme.categorical(500)).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
||||
expect(theme.background.action("focused")).toBe(
|
||||
resolved().contexts["@context:elevated"]!.background.action.primary.focused,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ test("resolves one-mode files with defaults for the available mode", () => {
|
|||
|
||||
expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
|
||||
expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
|
||||
expect(resolvedLight.categorical.length).toBeGreaterThan(0)
|
||||
expect(resolvedDark.categorical.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("rejects theme files without a mode", () => {
|
||||
|
|
@ -21,6 +23,30 @@ test("rejects theme files without a mode", () => {
|
|||
expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("validates and resolves categorical hues in configured order", () => {
|
||||
const theme = resolveThemeFile({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light")
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.accent)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.red)
|
||||
expect(theme.categorical[2]).toBe(theme.hue.interactive)
|
||||
expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical)
|
||||
expect(() => resolveThemeFile({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
|
||||
expect(() =>
|
||||
resolveThemeFile(
|
||||
// @ts-expect-error Runtime decoding rejects unknown categorical hue names.
|
||||
{ version: 2, light: { categorical: ["magenta"] } },
|
||||
"light",
|
||||
),
|
||||
).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("uses the default categorical order for direct definitions", () => {
|
||||
const theme = resolveTheme({ ...light, categorical: undefined })
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.blue)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.purple)
|
||||
})
|
||||
|
||||
test("resolves independent definitions and hue aliases", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
|
|
@ -31,6 +57,7 @@ test("resolves independent definitions and hue aliases", () => {
|
|||
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.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
|
||||
expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/the
|
|||
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
|
||||
|
||||
const hue = {} as HueDefinition
|
||||
const light = { hue, text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
|
||||
const dark = { hue, text: { default: "#eeeeee", subdued: "#dddddd" } } satisfies ThemeDefinition
|
||||
const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
|
||||
const dark = {
|
||||
hue,
|
||||
categorical: ["purple"],
|
||||
text: { default: "#eeeeee", subdued: "#dddddd" },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
test("requires and selects independent light and dark themes", () => {
|
||||
const file = { version: 2, light, dark } satisfies ThemeFile
|
||||
|
|
@ -27,6 +31,15 @@ test("merges an expanded mode override over the other mode", () => {
|
|||
expect(selected.text?.subdued).toBe("$text.default")
|
||||
})
|
||||
|
||||
test("replaces categorical order in a merge mode", () => {
|
||||
const selected = selectTheme(
|
||||
{ version: 2, light, dark: { mergeMode: true, categorical: ["accent", "cyan"] } },
|
||||
"dark",
|
||||
)
|
||||
|
||||
expect(selected.categorical).toEqual(["accent", "cyan"])
|
||||
})
|
||||
|
||||
test("selects the available mode when the requested mode is missing", () => {
|
||||
const lightOnly = { version: 2, light } satisfies ThemeFile
|
||||
const darkOnly = { version: 2, dark } satisfies ThemeFile
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const background = {
|
|||
|
||||
const definition = {
|
||||
hue: {} as ThemeDefinition["hue"],
|
||||
categorical: ["blue", "accent"],
|
||||
text,
|
||||
background,
|
||||
border: { default: "$hue.neutral.300" },
|
||||
|
|
@ -66,6 +67,7 @@ test("supports property-first definitions, variants, states, and contexts", () =
|
|||
expect(background.surface.offset).toBe("$hue.neutral.200")
|
||||
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
|
||||
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
|
||||
expect(definition.categorical).toEqual(["blue", "accent"])
|
||||
expect(file.light).toBe(definition)
|
||||
expect(lightOnly.light).toBe(definition)
|
||||
expect(darkOnly.dark).toBe(definition)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
|
|||
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
|
||||
import { selectThemeMode, themeModes } from "../../../src/theme/v2/select"
|
||||
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults"
|
||||
|
||||
test("migrates resolved V1 modes into literal V2 tokens", () => {
|
||||
const migrated = migrateV1(DEFAULT_THEMES.opencode)
|
||||
|
|
@ -11,6 +12,8 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
|
|||
const resolved = resolveThemeFile(migrated, "light")
|
||||
|
||||
expect(migrated.standalone).toBeTrue()
|
||||
expect(migrated.light.categorical?.length).toBeGreaterThan(0)
|
||||
expect(migrated.dark.categorical?.length).toBeGreaterThan(0)
|
||||
expect(migrated.light.hue?.accent).toBeObject()
|
||||
expect(migrated.light.hue?.interactive).toBeObject()
|
||||
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
|
||||
|
|
@ -84,6 +87,41 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
|||
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
|
||||
})
|
||||
|
||||
test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
|
||||
light: DEFAULT_THEME.light.hue[name][700],
|
||||
dark: DEFAULT_THEME.dark.hue[name][300],
|
||||
})
|
||||
source.theme.secondary = mapped("purple")
|
||||
source.theme.accent = mapped("orange")
|
||||
source.theme.success = mapped("green")
|
||||
source.theme.warning = mapped("yellow")
|
||||
source.theme.primary = mapped("blue")
|
||||
source.theme.error = mapped("red")
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.light?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
|
||||
source.theme.accent = source.theme.secondary
|
||||
expect(migrateV1(source).light?.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
|
||||
})
|
||||
|
||||
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.secondary = "transparent"
|
||||
source.theme.accent = "transparent"
|
||||
source.theme.success = "transparent"
|
||||
source.theme.warning = "transparent"
|
||||
source.theme.primary = "transparent"
|
||||
source.theme.error = "transparent"
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.light?.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
})
|
||||
|
||||
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.background = { light: "#eeeeee", dark: "#111111" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue