diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts index 4fdea0d135e..ac81ee4f5c5 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -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>> | undefined + if (!definitions) return theme + const scales = new Map>>() + + function resolve(name: string, chain: string[] = []): Partial> | 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() + 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> byToken: Partial> }>( @@ -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) { diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index ced1ae4800a..fed2c9ba79b 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -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"