mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-30 04:33:33 +00:00
fix(tui): reference inferred hues in migrated themes (#39183)
This commit is contained in:
parent
4e4cf9e25e
commit
4f622fa7cd
2 changed files with 112 additions and 15 deletions
|
|
@ -58,7 +58,7 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
|||
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
||||
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
|
||||
|
||||
return {
|
||||
return referenceHues({
|
||||
hue: {
|
||||
gray: neutralScale(theme, mode),
|
||||
...Object.fromEntries(
|
||||
|
|
@ -67,8 +67,8 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
|||
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
||||
}),
|
||||
),
|
||||
accent: ambiguous(theme.accent) ? "$hue.gray" : hueScale(theme.accent, mode),
|
||||
interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
|
||||
accent: hues.byToken.accent ? `$hue.${hues.byToken.accent}` : "$hue.gray",
|
||||
interactive: hues.byToken.primary ? `$hue.${hues.byToken.primary}` : "$hue.gray",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: uniqueCategorical.length ? uniqueCategorical : DEFAULT_CATEGORICAL,
|
||||
|
|
@ -176,7 +176,56 @@ function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
|||
},
|
||||
},
|
||||
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
||||
})
|
||||
}
|
||||
|
||||
function referenceHues(theme: FileThemeDefinition): FileThemeDefinition {
|
||||
const definitions = theme.hue as Record<string, string | Partial<Record<HueStep, string>>> | undefined
|
||||
if (!definitions) return theme
|
||||
const scales = new Map<string, Partial<Record<HueStep, string>>>()
|
||||
|
||||
function resolve(name: string, chain: string[] = []): Partial<Record<HueStep, string>> | undefined {
|
||||
const cached = scales.get(name)
|
||||
if (cached) return cached
|
||||
if (chain.includes(name)) return
|
||||
const value = definitions?.[name]
|
||||
if (!value) return
|
||||
if (typeof value !== "string") {
|
||||
scales.set(name, value)
|
||||
return value
|
||||
}
|
||||
const target = /^\$hue\.([^.]+)$/.exec(value)?.[1]
|
||||
if (!target) return
|
||||
const scale = resolve(target, [...chain, name])
|
||||
if (scale) scales.set(name, scale)
|
||||
return scale
|
||||
}
|
||||
|
||||
const references = new Map<string, string>()
|
||||
const index = (name: string, overwrite: boolean) => {
|
||||
const scale = resolve(name)
|
||||
if (!scale) return
|
||||
HueStep.literals.forEach((step) => {
|
||||
const color = scale[step]
|
||||
if (!color || (!overwrite && references.has(color.toLowerCase()))) return
|
||||
references.set(color.toLowerCase(), `$hue.${name}.${step}`)
|
||||
})
|
||||
}
|
||||
chromaticHues.forEach((name) => index(name, false))
|
||||
index("gray", false)
|
||||
index("accent", true)
|
||||
index("interactive", true)
|
||||
index("neutral", true)
|
||||
|
||||
function replace(value: unknown): unknown {
|
||||
if (typeof value === "string") return references.get(value.toLowerCase()) ?? value
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item)]))
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(theme).map(([key, value]) => [key, key === "hue" || key === "categorical" ? value : replace(value)]),
|
||||
) as FileThemeDefinition
|
||||
}
|
||||
|
||||
function inferHues(theme: Theme, mode: "light" | "dark") {
|
||||
|
|
@ -189,7 +238,7 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
|
|||
["info", theme.info],
|
||||
["secondary", theme.secondary],
|
||||
]
|
||||
return colors.reduce<{
|
||||
const inferred = colors.reduce<{
|
||||
byHue: Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
||||
byToken: Partial<Record<V1HueToken, ChromaticHue>>
|
||||
}>(
|
||||
|
|
@ -207,6 +256,19 @@ function inferHues(theme: Theme, mode: "light" | "dark") {
|
|||
},
|
||||
{ byHue: {}, byToken: {} },
|
||||
)
|
||||
return (
|
||||
[
|
||||
["accent", theme.accent],
|
||||
["primary", theme.primary],
|
||||
] as const
|
||||
).reduce((result, [token, color]) => {
|
||||
const nearest = inferHue(color, mode)
|
||||
if (!nearest) return result
|
||||
return {
|
||||
byHue: { ...result.byHue, [nearest.name]: { color, distance: nearest.distance } },
|
||||
byToken: { ...result.byToken, [token]: nearest.name },
|
||||
}
|
||||
}, inferred)
|
||||
}
|
||||
|
||||
function inferHue(color: RGBA, mode: Mode) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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", () => {
|
||||
test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
const migrated = migrateV1(DEFAULT_THEMES.opencode)
|
||||
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
|
||||
|
|
@ -14,13 +14,8 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
|
|||
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") {
|
||||
throw new Error("Expected concrete accent and interactive scales")
|
||||
}
|
||||
expect(migrated.light.hue.accent[800]).toBe(hex(legacy.accent))
|
||||
expect(migrated.light.hue.interactive[800]).toBe(hex(legacy.primary))
|
||||
expect(migrated.light.hue?.accent).toMatch(/^\$hue\.[^.]+$/)
|
||||
expect(migrated.light.hue?.interactive).toMatch(/^\$hue\.[^.]+$/)
|
||||
expect(migrated.light.text?.default).toBe("$hue.neutral.800")
|
||||
expect(migrated.light.text?.subdued).toBe("$hue.neutral.600")
|
||||
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
|
||||
|
|
@ -32,9 +27,6 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
|
|||
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
|
||||
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
|
||||
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
|
||||
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
|
||||
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
|
||||
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
||||
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
|
||||
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
|
||||
|
|
@ -54,6 +46,22 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
|
|||
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
})
|
||||
|
||||
test("references generated hues from matching token colors", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.border = source.theme.primary
|
||||
source.theme.borderActive = source.theme.accent
|
||||
source.theme.syntaxKeyword = source.theme.error
|
||||
source.theme.markdownEmph = "#123456"
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light) throw new Error("Expected light mode")
|
||||
|
||||
expect(migrated.light.border?.default).toBe("$hue.interactive.800")
|
||||
expect(migrated.light.scrollbar?.default).toBe("$hue.accent.800")
|
||||
expect(migrated.light.syntax?.keyword).toMatch(/^\$hue\.[^.]+\.800$/)
|
||||
expect(migrated.light.markdown?.emphasis).toBe("#123456")
|
||||
})
|
||||
|
||||
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const ambiguous = { light: "#808080", dark: "#808080" }
|
||||
|
|
@ -108,6 +116,33 @@ test("orders categorical hues by V1 semantic color mapping", () => {
|
|||
expect(migrateV1(source).light?.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
|
||||
})
|
||||
|
||||
test("gives accent and primary ownership of their inferred hues", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.success = DEFAULT_THEME.light.hue.orange[300]
|
||||
source.theme.accent = DEFAULT_THEME.light.hue.orange[400]
|
||||
source.theme.info = DEFAULT_THEME.light.hue.blue[300]
|
||||
source.theme.primary = DEFAULT_THEME.light.hue.blue[400]
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light) throw new Error("Expected light mode")
|
||||
const orange = migrated.light.hue?.orange
|
||||
const blue = migrated.light.hue?.blue
|
||||
if (typeof orange !== "object" || typeof blue !== "object") throw new Error("Expected concrete hue scales")
|
||||
|
||||
expect(orange[800]).toBe(source.theme.accent)
|
||||
expect(blue[800]).toBe(source.theme.primary)
|
||||
expect(migrated.light.hue?.accent).toBe("$hue.orange")
|
||||
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
|
||||
|
||||
source.theme.primary = DEFAULT_THEME.light.hue.orange[500]
|
||||
const collisionMode = migrateV1(source).light
|
||||
const collision = collisionMode?.hue?.orange
|
||||
if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
|
||||
expect(collision[800]).toBe(source.theme.primary)
|
||||
expect(collisionMode?.hue?.accent).toBe("$hue.orange")
|
||||
expect(collisionMode?.hue?.interactive).toBe("$hue.orange")
|
||||
})
|
||||
|
||||
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.secondary = "transparent"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue