feat(tui): track hue source identity (#37550)

This commit is contained in:
James Long 2026-07-17 15:38:22 -04:00 committed by GitHub
commit fafb04ed59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 60 additions and 21 deletions

View file

@ -77,6 +77,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode:
return { return {
hue, hue,
source: (color: RGBA) => current().source(color),
increase: (color: RGBA, amount = 1) => current().increase(color, amount), increase: (color: RGBA, amount = 1) => current().increase(color, amount),
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount), decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)), raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)),

View file

@ -32,6 +32,7 @@ export {
export type { export type {
FormfieldColor, FormfieldColor,
Hue, Hue,
HueSource,
HueScale, HueScale,
ResolvedActionState, ResolvedActionState,
ResolvedFormfieldState, ResolvedFormfieldState,

View file

@ -141,35 +141,36 @@ function contextualActions(
function resolveView( function resolveView(
definition: ThemeTokensDefinition, definition: ThemeTokensDefinition,
hue: ResolvedThemeView["hue"], hue: ResolvedThemeView["hue"],
hueSteps: Pick<ResolvedThemeView, "increase" | "decrease">, hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
): ResolvedThemeView { ): ResolvedThemeView {
const source: Record<string, unknown> = { hue, ...definition } const source: Record<string, unknown> = { hue, ...definition }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, ...hueSteps } return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, ...hueSteps }
} }
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "increase" | "decrease"> { function compileHueSteps(
const index = new Map( hue: ResolvedThemeView["hue"],
Object.values(hue).flatMap((scale) => ): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
HueStep.literals.map((step, position) => [colorKey(scale[step]), { position, scale }] as const), 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 }))
}
const shift = (color: RGBA, amount: number) => { const shift = (color: RGBA, amount: number) => {
const match = index.get(colorKey(color)) const match = index.get(color)
if (!match) return color if (!match) return color
const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0 const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0
const position = Math.max(0, Math.min(HueStep.literals.length - 1, match.position + offset)) const position = Math.max(0, Math.min(HueStep.literals.length - 1, match.position + offset))
return match.scale[HueStep.literals[position]] return hue[match.hue][HueStep.literals[position]]
} }
return { return {
source: (color) => {
const match = index.get(color)
return match ? { hue: match.hue, step: match.step } : undefined
},
increase: (color, amount = 1) => shift(color, amount), increase: (color, amount = 1) => shift(color, amount),
decrease: (color, amount = 1) => shift(color, -amount), decrease: (color, amount = 1) => shift(color, -amount),
} }
} }
function colorKey(color: RGBA) {
return color.toInts().join(":")
}
function resolveHue(definition: HueDefinition) { function resolveHue(definition: HueDefinition) {
const source = definition as Record<string, unknown> const source = definition as Record<string, unknown>
const cache = new Map<string, HueScale>() const cache = new Map<string, HueScale>()
@ -186,7 +187,8 @@ function resolveHue(definition: HueDefinition) {
if (typeof value === "string") { 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 (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
const result = resolve(match[1], [...stack, name]) 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) cache.set(name, result)
return result return result
} }

View file

@ -15,11 +15,13 @@ export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = ResolvedActionState export type ResolvedFormfieldState = ResolvedActionState
export type HueScale = Readonly<Record<HueStep, RGBA>> export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>> export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>> export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = StatefulColor export type FormfieldColor = StatefulColor
export type ResolvedThemeView = { export type ResolvedThemeView = {
readonly hue: Hue readonly hue: Hue
readonly source: (color: RGBA) => HueSource | undefined
readonly increase: (color: RGBA, amount?: number) => RGBA readonly increase: (color: RGBA, amount?: number) => RGBA
readonly decrease: (color: RGBA, amount?: number) => RGBA readonly decrease: (color: RGBA, amount?: number) => RGBA
readonly text: { readonly text: {

View file

@ -25,8 +25,10 @@ test("provides reactive property, variant, state, and context accessors", () =>
expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100]) 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.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.red(100), 3)).toBe(resolved().hue.red[100])
expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 200 })
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts()) const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
expect(theme.increase(equivalent, 1)).toBe(resolved().hue.green[600]) expect(theme.source(equivalent)).toBeUndefined()
expect(theme.increase(equivalent, 1)).toBe(equivalent)
const unmatched = RGBA.fromInts(1, 2, 3) const unmatched = RGBA.fromInts(1, 2, 3)
expect(theme.increase(unmatched, 1)).toBe(unmatched) expect(theme.increase(unmatched, 1)).toBe(unmatched)
expect(theme.text.subdued()).toBe(resolved().text.subdued) expect(theme.text.subdued()).toBe(resolved().text.subdued)

View file

@ -12,9 +12,15 @@ test("resolves independent definitions and hue aliases", () => {
const lightTheme = resolveTheme(light) const lightTheme = resolveTheme(light)
const darkTheme = resolveTheme(dark) const darkTheme = resolveTheme(dark)
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue) expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue)
expect(lightTheme.hue.interactive).toBe(lightTheme.hue.blue) expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue()
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray) 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.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: 200 })
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200]) 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.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe( expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(
@ -64,9 +70,15 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
"light", "light",
) )
expect(aliased.hue.blue).toBe(aliased.hue.red) expect(aliased.hue.blue).not.toBe(aliased.hue.red)
expect(aliased.hue.purple).toBe(aliased.hue.red) expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
expect(overridden.hue.blue).toBe(overridden.hue.red) 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(() => expect(() =>
resolveTheme({ resolveTheme({
...light, ...light,
@ -75,6 +87,25 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
).toThrow("Circular hue reference: red -> blue -> red") ).toThrow("Circular hue reference: red -> blue -> red")
}) })
test("steps by hue source when adjacent colors have equal values", () => {
if (typeof light.hue.gray !== "object") throw new Error("Expected a concrete gray scale")
const theme = resolveTheme({
...light,
hue: {
...light.hue,
gray: { ...light.hue.gray, 200: "#eee8d5", 300: "#eee8d5", 400: "#d3d7c6" },
neutral: "$hue.gray",
},
})
expect(theme.hue.neutral[200]).not.toBe(theme.hue.neutral[300])
expect(theme.hue.neutral[200].equals(theme.hue.neutral[300])).toBeTrue()
expect(theme.source(theme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
expect(theme.source(theme.hue.neutral[300])).toEqual({ hue: "neutral", step: 300 })
expect(theme.increase(theme.hue.neutral[200])).toBe(theme.hue.neutral[300])
expect(theme.increase(theme.hue.neutral[300])).toBe(theme.hue.neutral[400])
})
test("merges partial files with the selected OpenCode defaults", () => { test("merges partial files with the selected OpenCode defaults", () => {
const theme = resolveThemeFile( const theme = resolveThemeFile(
{ {