mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
398 lines
18 KiB
TypeScript
398 lines
18 KiB
TypeScript
import { expect, test } from "bun:test"
|
|
import type { RGBA } from "@opentui/core"
|
|
import {
|
|
migrateV1,
|
|
migrateV1NewDesigns,
|
|
resolveThemeDocument,
|
|
rgbToOklch,
|
|
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" }
|
|
import type { ThemeV1Json } from "@opencode/theme/tui/v1"
|
|
|
|
const opencodeV1 = opencodeSource as ThemeV1Json
|
|
const opencodeLight = resolveThemeDocument(getOpenCodeTheme(), "light")
|
|
const opencodeLightHues = allHues(opencodeLight)
|
|
|
|
test("migrates resolved V1 modes into V2 tokens", () => {
|
|
const migrated = migrateV1(opencodeV1)
|
|
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
|
const legacy = resolveV1(opencodeV1, "light")
|
|
const resolved = resolveThemeDocument(migrated, "light")
|
|
|
|
expect(migrated.base.categorical?.length).toBeGreaterThan(0)
|
|
expect(migrated.dark.categorical?.length).toBeGreaterThan(0)
|
|
expect(migrated.light.hue?.accent).toMatch(/^\$hue\.[^.]+$/)
|
|
expect(migrated.light.hue?.interactive).toMatch(/^\$hue\.[^.]+$/)
|
|
expect(migrated.base.text?.base).toBe("$hue.neutral.200")
|
|
expect(migrated.base.text?.muted).toBe("$hue.neutral.400")
|
|
expect(migrated.base.background?.action?.primary?.base).toBe("transparent")
|
|
expect(migrated.base.background?.base).toBe("$hue.neutral.800")
|
|
expect(migrated.base.background?.raised?.base).toBe("$hue.neutral.700")
|
|
expect(migrated.base.background?.raised?.high).toBe("$hue.neutral.600")
|
|
expect(migrated.dark.background?.base).toBe("$hue.neutral.800")
|
|
expect(migrated.dark.background?.raised?.base).toBe("$hue.neutral.700")
|
|
expect(migrated.dark.background?.raised?.high).toBe("$hue.neutral.600")
|
|
expect(migrated.base.text?.action?.primary?.base).toBe("$text.base")
|
|
expect(migrated.base.text?.action?.secondary?.base).toBe("$text.muted")
|
|
expect(migrated.base.text?.action?.secondary?.$hovered).toBe("$text.base")
|
|
expect(migrated.base.background?.action?.primary?.$selected).toBe("transparent")
|
|
expect(resolved.background.raised.base.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
|
expect(resolved.background.raised.high.toInts()).toEqual(legacy.backgroundElement.toInts())
|
|
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
|
|
expect(resolved.background.formfield.focused.toInts()).toEqual(legacy.background.toInts())
|
|
expect(resolved.text.formfield.base.toInts()).toEqual(legacy.text.toInts())
|
|
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
|
|
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
|
|
expect(resolved.hue.accent[200].toInts()).toEqual(legacy.accent.toInts())
|
|
expect(resolved.hue.interactive[200].toInts()).toEqual(legacy.primary.toInts())
|
|
expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
|
|
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
|
|
expect(resolved.text.action.secondary.base.toInts()).toEqual(legacy.textMuted.toInts())
|
|
expect(resolved.text.action.secondary.hovered.toInts()).toEqual(legacy.text.toInts())
|
|
expect(resolved.background.feedback.error.base.toInts()).toEqual(legacy.background.toInts())
|
|
expect(resolved.surface("dialog").background.base.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
|
expect(resolved.surface("dialog").background.action.primary.base.toInts()).toEqual([0, 0, 0, 0])
|
|
expect(resolved.surface("dialog").text.action.primary.base.toInts()).toEqual(legacy.text.toInts())
|
|
})
|
|
|
|
test("references generated hues from matching token colors", () => {
|
|
const source = structuredClone(opencodeV1)
|
|
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.base.border?.base).toBe("$hue.interactive.200")
|
|
expect(migrated.base.scrollbar?.base).toBe("$hue.accent.200")
|
|
expect(migrated.base.syntax?.keyword).toMatch(/^\$hue\.[^.]+\.200$/)
|
|
expect(migrated.base.markdown?.emphasis).toBe("#123456")
|
|
})
|
|
|
|
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
|
|
source.theme.warning = ambiguous
|
|
source.theme.primary = ambiguous
|
|
source.theme.error = ambiguous
|
|
source.theme.info = ambiguous
|
|
source.theme.secondary = "transparent"
|
|
source.theme.success = { light: "#ff6666", dark: "#450000" }
|
|
|
|
const migrated = migrateV1(source)
|
|
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
|
const lightRed = migrated.light.hue?.red
|
|
const darkRed = migrated.dark.hue?.red
|
|
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
|
|
|
|
expect(lightRed[200]).toBe("#ff6666")
|
|
expect(darkRed[200]).toBe("#450000")
|
|
expect(lightRed[100]).not.toBe(lightRed[200])
|
|
expect(darkRed[100]).not.toBe(darkRed[200])
|
|
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()
|
|
expect(() => resolveThemeDocument(migrated, "dark")).not.toThrow()
|
|
})
|
|
|
|
test("orders categorical hues by V1 semantic color mapping", () => {
|
|
const source = structuredClone(opencodeV1)
|
|
const colors = {
|
|
light: {
|
|
red: "#fca5a5",
|
|
orange: "#fdba74",
|
|
yellow: "#fde047",
|
|
green: "#86efac",
|
|
blue: "#93c5fd",
|
|
purple: "#d8b4fe",
|
|
},
|
|
dark: {
|
|
red: "#b91c1c",
|
|
orange: "#c2410c",
|
|
yellow: "#a16207",
|
|
green: "#15803d",
|
|
blue: "#1d4ed8",
|
|
purple: "#7e22ce",
|
|
},
|
|
} as const
|
|
const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
|
|
light: colors.light[name],
|
|
dark: colors.dark[name],
|
|
})
|
|
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")
|
|
source.theme.info = { light: "#67e8f9", dark: "#0e7490" }
|
|
|
|
const migrated = migrateV1(source)
|
|
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", "cyan"])
|
|
})
|
|
|
|
test("gives accent and primary ownership of their inferred hues", () => {
|
|
const source = structuredClone(opencodeV1)
|
|
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")
|
|
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[200]).toBe(source.theme.accent)
|
|
expect(blue[200]).toBe(source.theme.primary)
|
|
expect(migrated.light.hue?.accent).toBe("$hue.orange")
|
|
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
|
|
|
|
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")
|
|
expect(collision[200]).toBe(source.theme.primary)
|
|
expect(collisionMode?.hue?.accent).toBe("$hue.orange")
|
|
expect(collisionMode?.hue?.interactive).toBe("$hue.orange")
|
|
})
|
|
|
|
test("uses the semantic neutral hue when V1 categorical colors are ambiguous", () => {
|
|
const source = structuredClone(opencodeV1)
|
|
source.theme.secondary = "transparent"
|
|
source.theme.accent = "transparent"
|
|
source.theme.success = "transparent"
|
|
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(["neutral"])
|
|
expect(migrated.dark?.categorical).toEqual(["neutral"])
|
|
})
|
|
|
|
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
|
|
const source = structuredClone(opencodeV1)
|
|
source.theme.background = { light: "#eeeeee", dark: "#111111" }
|
|
source.theme.backgroundPanel = { light: "#dddddd", dark: "#222222" }
|
|
source.theme.backgroundElement = { light: "#cccccc", dark: "#333333" }
|
|
source.theme.textMuted = { light: "#777777", dark: "#999999" }
|
|
source.theme.text = { light: "#333333", dark: "#dddddd" }
|
|
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
|
|
const light = resolveV1(source, "light")
|
|
const dark = resolveV1(source, "dark")
|
|
const migrated = migrateV1(source)
|
|
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
|
const lightGray = migrated.light.hue?.gray
|
|
const darkGray = migrated.dark.hue?.gray
|
|
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
|
|
|
|
expect(lightGray[100]).not.toBe(lightGray[200])
|
|
expect(lightGray[200]).toBe(hex(light.text))
|
|
expect(lightGray[400]).toBe(hex(light.textMuted))
|
|
expect(lightGray[600]).toBe(hex(light.backgroundElement))
|
|
expect(lightGray[700]).toBe(hex(light.backgroundPanel))
|
|
expect(lightGray[800]).toBe(hex(light.background))
|
|
expect(lightGray[900]).not.toBe(lightGray[800])
|
|
expect(darkGray[100]).not.toBe(darkGray[200])
|
|
expect(darkGray[200]).toBe(hex(dark.text))
|
|
expect(darkGray[400]).toBe(hex(dark.textMuted))
|
|
expect(darkGray[600]).toBe(hex(dark.backgroundElement))
|
|
expect(darkGray[700]).toBe(hex(dark.backgroundPanel))
|
|
expect(darkGray[800]).toBe(hex(dark.background))
|
|
expect(darkGray[900]).not.toBe(darkGray[800])
|
|
|
|
source.theme.borderSubtle = "#ff00ff"
|
|
source.theme.border = "#00ff00"
|
|
source.theme.borderActive = "#00ffff"
|
|
const withBorders = migrateV1(source)
|
|
expect(withBorders.light?.hue?.gray).toEqual(lightGray)
|
|
expect(withBorders.dark?.hue?.gray).toEqual(darkGray)
|
|
})
|
|
|
|
test("uses the base text reference for primary actions on transparent backgrounds", () => {
|
|
const source = structuredClone(opencodeV1)
|
|
source.theme.background = "transparent"
|
|
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
|
delete source.theme.selectedListItemText
|
|
const migrated = migrateV1(source)
|
|
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
|
|
|
expect(migrated.base.text?.action?.primary?.base).toBe("$text.base")
|
|
expect(migrated.dark.text?.action?.primary?.base).toBe("$text.base")
|
|
})
|
|
|
|
test("retains V1 circular reference errors", () => {
|
|
const source = structuredClone(opencodeV1)
|
|
source.defs = { ...source.defs, one: "two", two: "one" }
|
|
source.theme.primary = "one"
|
|
|
|
expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
|
|
})
|
|
|
|
test("migrates every built-in V1 theme in its supported modes", () => {
|
|
for (const source of Object.values(DEFAULT_THEMES)) {
|
|
const migrated = migrateV1(source)
|
|
for (const mode of themeModes(migrated)) {
|
|
expect(resolveThemeDocument(migrated, mode).text.base).toBeDefined()
|
|
}
|
|
}
|
|
})
|
|
|
|
test("collapses identical V1 backgrounds when both variants infer one mode", () => {
|
|
const dark = structuredClone(opencodeV1)
|
|
dark.theme.background = "#111111"
|
|
dark.theme.text = "#eeeeee"
|
|
const migratedDark = migrateV1(dark)
|
|
expect(migratedDark.light).toBeUndefined()
|
|
expect(migratedDark.dark).toBeDefined()
|
|
expect(themeModes(migratedDark)).toEqual(["dark"])
|
|
expect(selectThemeMode(migratedDark, "light").mode).toBe("dark")
|
|
|
|
const light = structuredClone(opencodeV1)
|
|
light.theme.background = "#eeeeee"
|
|
light.theme.text = "#111111"
|
|
const migratedLight = migrateV1(light)
|
|
expect(migratedLight.light).toBeDefined()
|
|
expect(migratedLight.dark).toBeUndefined()
|
|
expect(themeModes(migratedLight)).toEqual(["light"])
|
|
expect(selectThemeMode(migratedLight, "dark").mode).toBe("light")
|
|
})
|
|
|
|
test("gives dialogs an opaque background when V1 panels are see-through", () => {
|
|
const lucent = DEFAULT_THEMES["lucent-orng"]!
|
|
for (const mode of ["dark", "light"] as const) {
|
|
const legacy = resolveV1(lucent, mode)
|
|
const dialog = resolveThemeDocument(migrateV1(lucent), mode).surface("dialog")
|
|
expect(legacy.backgroundPanel.a).toBe(0)
|
|
const [red, green, blue] = legacy.backgroundMenu.toInts()
|
|
expect(legacy.backgroundMenu.a).toBeLessThan(1)
|
|
expect(dialog.background.base.toInts()).toEqual([red, green, blue, 255])
|
|
}
|
|
|
|
const source = structuredClone(opencodeV1)
|
|
source.theme.background = "transparent"
|
|
source.theme.backgroundPanel = "transparent"
|
|
source.theme.backgroundElement = "transparent"
|
|
source.theme.backgroundMenu = "transparent"
|
|
expect(hex(resolveThemeDocument(migrateV1(source), "dark").surface("dialog").background.base)).toBe("#000000")
|
|
expect(hex(resolveThemeDocument(migrateV1(source), "light").surface("dialog").background.base)).toBe("#ffffff")
|
|
|
|
const opaque = resolveThemeDocument(migrateV1(opencodeV1), "dark")
|
|
expect(hex(opaque.surface("dialog").background.base)).toBe(hex(opaque.background.raised.base))
|
|
})
|
|
|
|
test("gives new designs dialogs an opaque background when V1 panels are see-through", () => {
|
|
const lucent = DEFAULT_THEMES["lucent-orng"]!
|
|
for (const mode of ["dark", "light"] as const) {
|
|
const [red, green, blue] = resolveV1(lucent, mode).backgroundMenu.toInts()
|
|
const dialog = resolveThemeDocument(migrateV1NewDesigns(lucent), mode).surface("dialog")
|
|
expect(dialog.background.base.toInts()).toEqual([red, green, blue, 255])
|
|
}
|
|
|
|
const opaque = migrateV1NewDesigns(DEFAULT_THEMES.tokyonight!)
|
|
expect(opaque.base["@dialog"]?.background?.base).toBe("$background.raised.base")
|
|
expect(opaque.light?.["@dialog"]?.background?.base).toBe("$hue.neutral.800")
|
|
})
|
|
|
|
test("keeps both modes when a shared background has different contrast", () => {
|
|
const source = structuredClone(opencodeV1)
|
|
source.theme.background = "#808080"
|
|
source.theme.text = { light: "#111111", dark: "#eeeeee" }
|
|
const migrated = migrateV1(source)
|
|
|
|
expect(themeModes(migrated)).toEqual(["light", "dark"])
|
|
})
|
|
|
|
test("lays out V1 colors on the opencode2 steps under new designs", () => {
|
|
const migrated = migrateV1NewDesigns(DEFAULT_THEMES.tokyonight!)
|
|
expect(migrated.base.text?.base).toBe("$hue.neutral.100")
|
|
expect(migrated.base.text?.muted).toBe("$hue.neutral.200")
|
|
expect(migrated.base.text?.faint).toBe("$hue.neutral.300")
|
|
expect(migrated.base.background?.base).toBe("$hue.neutral.900")
|
|
expect(migrated.base.background?.action?.primary?.$focused).toBe("$hue.interactive.600")
|
|
|
|
for (const mode of ["dark", "light"] as const) {
|
|
const legacy = resolveV1(DEFAULT_THEMES.tokyonight!, mode)
|
|
const resolved = resolveThemeDocument(migrated, mode)
|
|
expect(hex(resolved.background.base)).toBe(hex(legacy.background))
|
|
expect(hex(resolved.text.base)).toBe(hex(legacy.text))
|
|
expect(hex(resolved.hue.interactive[400])).toBe(hex(legacy.primary))
|
|
expect(hex(resolved.text.action.primary.selected)).toBe(hex(legacy.primary))
|
|
|
|
// Raised surfaces step away from the page the way opencode2's do, and selection is a quiet tint.
|
|
const lightness = (color: RGBA) => rgbToOklch(color.r, color.g, color.b).l
|
|
const direction = mode === "dark" ? 1 : -1
|
|
const surfaces = [resolved.background.base, ...Object.values(resolved.background.raised)].map(lightness)
|
|
surfaces.slice(1).forEach((value, index) => expect((value - surfaces[index]!) * direction).toBeGreaterThan(0))
|
|
const focused = resolved.background.action.primary.focused
|
|
expect(contrast(resolved.text.base, focused)).toBeGreaterThan(contrast(legacy.text, legacy.background) * 0.6)
|
|
expect(rgbToOklch(focused.r, focused.g, focused.b).c).toBeLessThan(
|
|
rgbToOklch(legacy.primary.r, legacy.primary.g, legacy.primary.b).c / 2,
|
|
)
|
|
}
|
|
})
|
|
|
|
test("keeps transparent V1 surfaces transparent under new designs", () => {
|
|
const source = structuredClone(DEFAULT_THEMES.tokyonight!)
|
|
source.theme.background = "transparent"
|
|
source.theme.backgroundPanel = "none"
|
|
for (const mode of ["dark", "light"] as const) {
|
|
const resolved = resolveThemeDocument(migrateV1NewDesigns(source), mode)
|
|
expect(resolved.background.base.toInts()[3]).toBe(0)
|
|
expect(resolved.background.raised.base.toInts()[3]).toBe(0)
|
|
expect(resolved.background.raised.high.toInts()[3]).toBe(255)
|
|
expect(resolved.background.action.primary.focused.toInts()[3]).toBe(255)
|
|
}
|
|
})
|
|
|
|
test("migrates every built-in V1 theme under new designs", () => {
|
|
for (const source of Object.values(DEFAULT_THEMES)) {
|
|
const migrated = migrateV1NewDesigns(source)
|
|
for (const mode of themeModes(migrated)) {
|
|
const resolved = resolveThemeDocument(migrated, mode)
|
|
expect(resolved.text.faint).toBeDefined()
|
|
expect(resolved.surface("dialog").background.base).toBeDefined()
|
|
}
|
|
}
|
|
})
|
|
|
|
function contrast(first: RGBA, second: RGBA) {
|
|
const luminance = (color: RGBA) => {
|
|
const channel = (value: number) => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
|
|
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b)
|
|
}
|
|
const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a)
|
|
return (lighter! + 0.05) / (darker! + 0.05)
|
|
}
|
|
|
|
function allHues(theme: ResolvedTheme) {
|
|
return theme.hue as typeof theme.hue & Readonly<Record<string, HueScale>>
|
|
}
|
|
|
|
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")
|
|
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
|
}
|