feat(tui): support single-mode themes (#37930)
This commit is contained in:
@@ -420,7 +420,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const themeState = useTheme()
|
||||||
const { themeV2, mode, setMode, locked, lock, unlock } = themeState
|
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
@@ -818,6 +818,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||||||
name: "theme.switch_mode",
|
name: "theme.switch_mode",
|
||||||
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
|
enabled: () => supports(mode() === "dark" ? "light" : "dark"),
|
||||||
run: () => {
|
run: () => {
|
||||||
setMode(mode() === "dark" ? "light" : "dark")
|
setMode(mode() === "dark" ? "light" : "dark")
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useTheme } from "../context/theme"
|
|||||||
import { DevTools } from "../devtools"
|
import { DevTools } from "../devtools"
|
||||||
|
|
||||||
export function DevToolsSidebar() {
|
export function DevToolsSidebar() {
|
||||||
const { themeV2, mode, setMode } = useTheme().contextual("elevated")
|
const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated")
|
||||||
const [modeHovered, setModeHovered] = createSignal(false)
|
const [modeHovered, setModeHovered] = createSignal(false)
|
||||||
|
const nextMode = () => (mode() === "dark" ? "light" : "dark")
|
||||||
|
const canSwitchMode = () => supports(nextMode())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -29,12 +31,12 @@ export function DevToolsSidebar() {
|
|||||||
<box
|
<box
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={modeHovered() ? themeV2.background.action("hovered") : undefined}
|
backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action("hovered") : undefined}
|
||||||
onMouseOver={() => setModeHovered(true)}
|
onMouseOver={() => setModeHovered(canSwitchMode())}
|
||||||
onMouseOut={() => setModeHovered(false)}
|
onMouseOut={() => setModeHovered(false)}
|
||||||
onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}
|
onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined}
|
||||||
>
|
>
|
||||||
<text fg={themeV2.text()}>{mode()}</text>
|
<text fg={canSwitchMode() ? themeV2.text() : themeV2.text.subdued()}>{mode()}</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { discoverThemes, themeDirectories } from "../theme/discovery"
|
|||||||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||||
import { resolveThemeFile } from "../theme/v2/resolve"
|
import { resolveThemeFile } from "../theme/v2/resolve"
|
||||||
import { migrateV1 } from "../theme/v2/v1-migrate"
|
import { migrateV1 } from "../theme/v2/v1-migrate"
|
||||||
|
import { themeModes } from "../theme/v2/select"
|
||||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
@@ -78,10 +79,12 @@ type ThemeService = {
|
|||||||
has: typeof hasTheme
|
has: typeof hasTheme
|
||||||
syntax: Accessor<SyntaxStyle>
|
syntax: Accessor<SyntaxStyle>
|
||||||
mode: Accessor<"dark" | "light">
|
mode: Accessor<"dark" | "light">
|
||||||
|
modes: Accessor<readonly ("dark" | "light")[]>
|
||||||
|
supports(mode: "dark" | "light"): boolean
|
||||||
locked: Accessor<boolean>
|
locked: Accessor<boolean>
|
||||||
lock(): void
|
lock(): void
|
||||||
unlock(): void
|
unlock(): void
|
||||||
setMode(mode?: "dark" | "light", persist?: boolean): void
|
setMode(mode?: "dark" | "light", persist?: boolean): boolean
|
||||||
set(theme: string): boolean
|
set(theme: string): boolean
|
||||||
readonly ready: boolean
|
readonly ready: boolean
|
||||||
}
|
}
|
||||||
@@ -271,17 +274,25 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
|
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
|
||||||
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
|
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
|
||||||
const values = createMemo(() => resolveTheme(source(), store.mode))
|
const file = createMemo(() => {
|
||||||
const valuesV2 = createMemo(() => {
|
|
||||||
const started = performance.now()
|
const started = performance.now()
|
||||||
const file = migrateV1(source())
|
const result = migrateV1(source())
|
||||||
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
|
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
const modes = createMemo(() => themeModes(file()))
|
||||||
|
const mode = () => {
|
||||||
|
const supported = modes()
|
||||||
|
if (supported.includes(store.mode)) return store.mode
|
||||||
|
return supported[0] ?? store.mode
|
||||||
|
}
|
||||||
|
const values = createMemo(() => resolveTheme(source(), mode()))
|
||||||
|
const valuesV2 = createMemo(() => {
|
||||||
const resolveStarted = performance.now()
|
const resolveStarted = performance.now()
|
||||||
const result = resolveThemeFile(file, store.mode, sourceName())
|
const result = resolveThemeFile(file(), mode(), sourceName())
|
||||||
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
|
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
const mode = () => store.mode
|
|
||||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||||
const contextsV2 = {
|
const contextsV2 = {
|
||||||
elevated: createComponentTheme(() => {
|
elevated: createComponentTheme(() => {
|
||||||
@@ -319,11 +330,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
all: allThemes,
|
all: allThemes,
|
||||||
has: hasTheme,
|
has: hasTheme,
|
||||||
syntax,
|
syntax,
|
||||||
mode: () => store.mode,
|
mode,
|
||||||
|
modes,
|
||||||
|
supports: (requested) => modes().includes(requested),
|
||||||
locked: () => store.lock !== undefined,
|
locked: () => store.lock !== undefined,
|
||||||
lock: () => pin(store.mode),
|
lock: () => pin(mode()),
|
||||||
unlock: free,
|
unlock: free,
|
||||||
setMode: pin,
|
setMode(requested = mode(), persist = true) {
|
||||||
|
if (!modes().includes(requested)) return false
|
||||||
|
pin(requested, persist)
|
||||||
|
return true
|
||||||
|
},
|
||||||
set(theme: string) {
|
set(theme: string) {
|
||||||
if (!hasTheme(theme)) return false
|
if (!hasTheme(theme)) return false
|
||||||
setStore("active", theme)
|
setStore("active", theme)
|
||||||
|
|||||||
@@ -41,3 +41,4 @@ export type {
|
|||||||
StatefulColor,
|
StatefulColor,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
export { migrateV1 } from "./v1-migrate"
|
export { migrateV1 } from "./v1-migrate"
|
||||||
|
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
|
||||||
|
|||||||
@@ -137,15 +137,35 @@ const BackgroundDefinition = Schema.Struct({
|
|||||||
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
|
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
|
||||||
|
|
||||||
export const SyntaxToken = Schema.Literals([
|
export const SyntaxToken = Schema.Literals([
|
||||||
"comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation",
|
"comment",
|
||||||
|
"keyword",
|
||||||
|
"function",
|
||||||
|
"variable",
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"type",
|
||||||
|
"operator",
|
||||||
|
"punctuation",
|
||||||
])
|
])
|
||||||
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
|
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
|
||||||
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
|
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
|
||||||
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
|
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
|
||||||
|
|
||||||
export const MarkdownToken = Schema.Literals([
|
export const MarkdownToken = Schema.Literals([
|
||||||
"text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem",
|
"text",
|
||||||
"listEnumeration", "image", "imageText", "codeBlock",
|
"heading",
|
||||||
|
"link",
|
||||||
|
"linkText",
|
||||||
|
"code",
|
||||||
|
"blockQuote",
|
||||||
|
"emphasis",
|
||||||
|
"strong",
|
||||||
|
"horizontalRule",
|
||||||
|
"listItem",
|
||||||
|
"listEnumeration",
|
||||||
|
"image",
|
||||||
|
"imageText",
|
||||||
|
"codeBlock",
|
||||||
])
|
])
|
||||||
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
|
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
|
||||||
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
|
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
|
||||||
@@ -225,5 +245,8 @@ const FileMetadata = {
|
|||||||
version: Schema.Literal(2),
|
version: Schema.Literal(2),
|
||||||
standalone: Schema.optional(Schema.Boolean),
|
standalone: Schema.optional(Schema.Boolean),
|
||||||
}
|
}
|
||||||
export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition })
|
export const ThemeFile = Schema.Union([
|
||||||
|
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
|
||||||
|
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
|
||||||
|
])
|
||||||
export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>
|
export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
} from "./index"
|
} from "./index"
|
||||||
|
|
||||||
export function selectTheme(
|
export function selectTheme(
|
||||||
file: Omit<ThemeFile, "light" | "dark"> & { light: ThemeDefinition; dark: ThemeDefinition },
|
file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||||
mode?: Mode,
|
mode?: Mode,
|
||||||
): ThemeDefinition
|
): ThemeDefinition
|
||||||
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
|
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
|
||||||
@@ -21,17 +21,31 @@ export function selectThemeMode(
|
|||||||
file: ThemeFile,
|
file: ThemeFile,
|
||||||
mode: Mode = "light",
|
mode: Mode = "light",
|
||||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||||
|
const modes = themeModes(file)
|
||||||
|
const selectedMode = modes.includes(mode) ? mode : modes[0]
|
||||||
|
const selected = file[selectedMode]
|
||||||
|
if (!selected) throw new Error("Theme must provide at least one mode")
|
||||||
if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||||
const selected = file[mode]
|
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
|
||||||
if (!merges(selected)) return { theme: selected, mode, expanded: false }
|
|
||||||
|
|
||||||
const otherMode = mode === "light" ? "dark" : "light"
|
const otherMode = selectedMode === "light" ? "dark" : "light"
|
||||||
const other = file[otherMode]
|
const other = file[otherMode]
|
||||||
|
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
|
||||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`)
|
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
|
||||||
return { theme: merged as FileThemeDefinition, mode, expanded: true }
|
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
function merges(definition: ModeDefinition): definition is MergeModeDefinition {
|
export function themeModes(file: ThemeFile): readonly Mode[] {
|
||||||
return "mergeMode" in definition && definition.mergeMode === true
|
if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||||
|
if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||||
|
return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsThemeMode(file: ThemeFile, mode: Mode) {
|
||||||
|
return themeModes(file).includes(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
|
||||||
|
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { RGBA } from "@opentui/core"
|
|||||||
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||||
import type { Theme, ThemeJson } from "../index"
|
import type { Theme, ThemeJson } from "../index"
|
||||||
import { DEFAULT_THEME } from "./defaults"
|
import { DEFAULT_THEME } from "./defaults"
|
||||||
import type { ThemeFile } from "./index"
|
import type { FileThemeDefinition, Mode, ThemeFile } from "./index"
|
||||||
import { HueStep } from "./schema"
|
import { HueStep } from "./schema"
|
||||||
|
|
||||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||||
@@ -13,15 +13,33 @@ const minimumChroma = 0.03
|
|||||||
const lightThreshold = 0.6
|
const lightThreshold = 0.6
|
||||||
|
|
||||||
export function migrateV1(theme: ThemeJson): ThemeFile {
|
export function migrateV1(theme: ThemeJson): ThemeFile {
|
||||||
|
const light = resolveV1(theme, "light")
|
||||||
|
const dark = resolveV1(theme, "dark")
|
||||||
|
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
|
||||||
|
const lightMode = detectMode(light)
|
||||||
|
const darkMode = detectMode(dark)
|
||||||
|
if (lightMode === darkMode) {
|
||||||
|
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
|
||||||
|
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
light: migrateMode(resolveV1(theme, "light"), "light"),
|
light: migrateMode(light, "light"),
|
||||||
dark: migrateMode(resolveV1(theme, "dark"), "dark"),
|
dark: migrateMode(dark, "dark"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
function detectMode(theme: Theme): Mode {
|
||||||
|
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(color: RGBA) {
|
||||||
|
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||||
const color = (key: ThemeColor) => hex(theme[key])
|
const color = (key: ThemeColor) => hex(theme[key])
|
||||||
const selected = hex(selectedForeground(theme, theme.primary))
|
const selected = hex(selectedForeground(theme, theme.primary))
|
||||||
const destructive = hex(selectedForeground(theme, theme.error))
|
const destructive = hex(selectedForeground(theme, theme.error))
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||||
|
import { ConfigProvider } from "../../../src/config"
|
||||||
|
import { ThemeProvider, useTheme } from "../../../src/context/theme"
|
||||||
|
|
||||||
|
async function wait(fn: () => boolean) {
|
||||||
|
const started = Date.now()
|
||||||
|
while (!fn()) {
|
||||||
|
if (Date.now() - started > 2000) throw new Error("timed out waiting for theme mode")
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("uses an available mode while retaining the pinned preference", async () => {
|
||||||
|
const lightOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
lightOnly.theme.background = "#eeeeee"
|
||||||
|
lightOnly.theme.text = "#111111"
|
||||||
|
const dual = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
dual.theme.background = { light: "#eeeeee", dark: "#111111" }
|
||||||
|
dual.theme.text = { light: "#111111", dark: "#eeeeee" }
|
||||||
|
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
darkOnly.theme.background = "#111111"
|
||||||
|
darkOnly.theme.text = "#eeeeee"
|
||||||
|
let theme: ReturnType<typeof useTheme> | undefined
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const value = useTheme()
|
||||||
|
theme = value
|
||||||
|
return <text>{value.mode()}</text>
|
||||||
|
}
|
||||||
|
|
||||||
|
function current() {
|
||||||
|
if (!theme) throw new Error("Theme provider is not mounted")
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(
|
||||||
|
() => (
|
||||||
|
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "light-only", mode: "dark" } })}>
|
||||||
|
<ThemeProvider
|
||||||
|
mode="dark"
|
||||||
|
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }}
|
||||||
|
>
|
||||||
|
<Probe />
|
||||||
|
</ThemeProvider>
|
||||||
|
</ConfigProvider>
|
||||||
|
),
|
||||||
|
{ width: 20, height: 2 },
|
||||||
|
)
|
||||||
|
app.renderer.start()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => theme?.ready === true)
|
||||||
|
expect(current().mode()).toBe("light")
|
||||||
|
expect(current().modes()).toEqual(["light"])
|
||||||
|
expect(current().supports("dark")).toBeFalse()
|
||||||
|
expect(current().setMode("dark")).toBeFalse()
|
||||||
|
expect(current().set("dark-only")).toBeTrue()
|
||||||
|
await wait(() => current().mode() === "dark")
|
||||||
|
expect(current().modes()).toEqual(["dark"])
|
||||||
|
expect(current().set("light-only")).toBeTrue()
|
||||||
|
await wait(() => current().mode() === "light")
|
||||||
|
expect(current().set("dual")).toBeTrue()
|
||||||
|
await wait(() => current().mode() === "dark")
|
||||||
|
expect(current().modes()).toEqual(["light", "dark"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -20,12 +20,12 @@ test("provides reactive property, variant, state, and context accessors", () =>
|
|||||||
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
|
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
|
||||||
expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
|
expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
|
||||||
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
|
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
|
||||||
expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[300])
|
expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[400])
|
||||||
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[300])
|
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])
|
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 })
|
expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 300 })
|
||||||
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
|
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
|
||||||
expect(theme.source(equivalent)).toBeUndefined()
|
expect(theme.source(equivalent)).toBeUndefined()
|
||||||
expect(theme.increase(equivalent, 1)).toBe(equivalent)
|
expect(theme.increase(equivalent, 1)).toBe(equivalent)
|
||||||
@@ -87,6 +87,6 @@ test("provides reactive property, variant, state, and context accessors", () =>
|
|||||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||||
setMode("dark")
|
setMode("dark")
|
||||||
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
||||||
expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[700])
|
expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[600])
|
||||||
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[700])
|
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[600])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ import { selectTheme } from "../../../src/theme/v2/select"
|
|||||||
const light = selectTheme(DEFAULT_THEME, "light")
|
const light = selectTheme(DEFAULT_THEME, "light")
|
||||||
const dark = selectTheme(DEFAULT_THEME, "dark")
|
const dark = selectTheme(DEFAULT_THEME, "dark")
|
||||||
|
|
||||||
|
test("resolves one-mode files with defaults for the available mode", () => {
|
||||||
|
const resolvedLight = resolveThemeFile({ version: 2, light: {} }, "dark")
|
||||||
|
const resolvedDark = resolveThemeFile({ version: 2, dark: {} }, "light")
|
||||||
|
|
||||||
|
expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
|
||||||
|
expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects theme files without a mode", () => {
|
||||||
|
// @ts-expect-error Runtime decoding also enforces the at-least-one-mode invariant.
|
||||||
|
expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme")
|
||||||
|
})
|
||||||
|
|
||||||
test("resolves independent definitions and hue aliases", () => {
|
test("resolves independent definitions and hue aliases", () => {
|
||||||
const lightTheme = resolveTheme(light)
|
const lightTheme = resolveTheme(light)
|
||||||
const darkTheme = resolveTheme(dark)
|
const darkTheme = resolveTheme(dark)
|
||||||
@@ -20,44 +33,32 @@ test("resolves independent definitions and hue aliases", () => {
|
|||||||
expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
|
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.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||||
expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
|
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.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
|
||||||
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(lightTheme.hue.red[200])
|
||||||
lightTheme.hue.red[200],
|
|
||||||
)
|
|
||||||
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
|
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
|
||||||
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
||||||
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[200])
|
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300])
|
||||||
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[300])
|
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400])
|
||||||
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
||||||
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
|
||||||
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
||||||
lightTheme.hue.interactive[500],
|
lightTheme.hue.interactive[500],
|
||||||
)
|
)
|
||||||
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
|
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
|
||||||
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
|
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||||
lightTheme.hue.neutral[100],
|
|
||||||
)
|
|
||||||
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
||||||
lightTheme.hue.interactive[500],
|
lightTheme.hue.interactive[500],
|
||||||
)
|
)
|
||||||
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
|
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
|
||||||
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
|
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||||
lightTheme.hue.neutral[100],
|
|
||||||
)
|
|
||||||
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
||||||
darkTheme.hue.interactive[400],
|
darkTheme.hue.interactive[400],
|
||||||
)
|
)
|
||||||
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
|
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
|
||||||
darkTheme.hue.neutral[100],
|
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
|
||||||
)
|
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
|
||||||
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
|
||||||
darkTheme.hue.interactive[400],
|
|
||||||
)
|
|
||||||
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
|
|
||||||
darkTheme.hue.neutral[900],
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves base hue aliases and rejects circular hue aliases", () => {
|
test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||||
@@ -65,10 +66,7 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
|||||||
...light,
|
...light,
|
||||||
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
||||||
})
|
})
|
||||||
const overridden = resolveThemeFile(
|
const overridden = resolveThemeFile({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
|
||||||
{ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} },
|
|
||||||
"light",
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
|
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
|
||||||
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
|
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
|
||||||
@@ -195,9 +193,7 @@ test("resolves elevated hover surfaces from direct colors", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255])
|
expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255])
|
||||||
expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([
|
expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
|
||||||
35, 69, 103, 255,
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves transparent colors", () => {
|
test("resolves transparent colors", () => {
|
||||||
@@ -268,12 +264,10 @@ test("rejects missing, base, and contextual reference cycles", () => {
|
|||||||
|
|
||||||
test("validates complete hues, resolved groups, and hue-only syntax", () => {
|
test("validates complete hues, resolved groups, and hue-only syntax", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveTheme(
|
resolveTheme({
|
||||||
{
|
...light,
|
||||||
...light,
|
hue: { ...light.hue, accent: "$hue.missing" },
|
||||||
hue: { ...light.hue, accent: "$hue.missing" },
|
} as unknown as ThemeDefinition),
|
||||||
} as unknown as ThemeDefinition,
|
|
||||||
),
|
|
||||||
).toThrow("$hue.missing")
|
).toThrow("$hue.missing")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveTheme({
|
resolveTheme({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
|
import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
|
||||||
import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select"
|
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
|
||||||
|
|
||||||
const hue = {} as HueDefinition
|
const hue = {} as HueDefinition
|
||||||
const light = { hue, text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
|
const light = { hue, text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
|
||||||
@@ -27,6 +27,27 @@ test("merges an expanded mode override over the other mode", () => {
|
|||||||
expect(selected.text?.subdued).toBe("$text.default")
|
expect(selected.text?.subdued).toBe("$text.default")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
expect(themeModes(lightOnly)).toEqual(["light"])
|
||||||
|
expect(themeModes(darkOnly)).toEqual(["dark"])
|
||||||
|
expect(supportsThemeMode(lightOnly, "light")).toBeTrue()
|
||||||
|
expect(supportsThemeMode(lightOnly, "dark")).toBeFalse()
|
||||||
|
expect(selectThemeMode(lightOnly, "dark")).toEqual({ theme: light, mode: "light", expanded: false })
|
||||||
|
expect(selectThemeMode(darkOnly, "light")).toEqual({ theme: dark, mode: "dark", expanded: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects a merge mode without its base mode", () => {
|
||||||
|
expect(() => selectThemeMode({ version: 2, light: { mergeMode: true } })).toThrow(
|
||||||
|
"light theme cannot merge without a dark theme",
|
||||||
|
)
|
||||||
|
expect(() => selectThemeMode({ version: 2, dark: { mergeMode: true } })).toThrow(
|
||||||
|
"dark theme cannot merge without a light theme",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("rejects mutual mode merging", () => {
|
test("rejects mutual mode merging", () => {
|
||||||
const file = {
|
const file = {
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const definition = {
|
|||||||
} satisfies ThemeDefinition
|
} satisfies ThemeDefinition
|
||||||
|
|
||||||
const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
|
const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
|
||||||
|
const lightOnly = { version: 2, light: definition } satisfies ThemeFile
|
||||||
|
const darkOnly = { version: 2, dark: definition } satisfies ThemeFile
|
||||||
|
// @ts-expect-error A theme file must provide at least one mode.
|
||||||
|
const empty = { version: 2 } satisfies ThemeFile
|
||||||
|
|
||||||
test("supports property-first definitions, variants, states, and contexts", () => {
|
test("supports property-first definitions, variants, states, and contexts", () => {
|
||||||
expect(text.action.primary.$hovered).toBe("$hue.neutral.200")
|
expect(text.action.primary.$hovered).toBe("$hue.neutral.200")
|
||||||
@@ -63,4 +67,7 @@ test("supports property-first definitions, variants, states, and contexts", () =
|
|||||||
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
|
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
|
||||||
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
|
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
|
||||||
expect(file.light).toBe(definition)
|
expect(file.light).toBe(definition)
|
||||||
|
expect(lightOnly.light).toBe(definition)
|
||||||
|
expect(darkOnly.dark).toBe(definition)
|
||||||
|
expect(empty.version).toBe(2)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
|
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
|
||||||
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
|
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
|
||||||
|
import { selectThemeMode, themeModes } from "../../../src/theme/v2/select"
|
||||||
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
|
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
|
||||||
|
|
||||||
test("migrates resolved V1 modes into literal V2 tokens", () => {
|
test("migrates resolved V1 modes into literal V2 tokens", () => {
|
||||||
const migrated = migrateV1(DEFAULT_THEMES.opencode)
|
const migrated = migrateV1(DEFAULT_THEMES.opencode)
|
||||||
|
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||||
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
|
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
|
||||||
const resolved = resolveThemeFile(migrated, "light")
|
const resolved = resolveThemeFile(migrated, "light")
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
|||||||
source.theme.success = { light: "#ff6666", dark: "#450000" }
|
source.theme.success = { light: "#ff6666", dark: "#450000" }
|
||||||
|
|
||||||
const migrated = migrateV1(source)
|
const migrated = migrateV1(source)
|
||||||
|
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||||
const lightRed = migrated.light.hue?.red
|
const lightRed = migrated.light.hue?.red
|
||||||
const darkRed = migrated.dark.hue?.red
|
const darkRed = migrated.dark.hue?.red
|
||||||
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
|
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
|
||||||
@@ -92,6 +95,7 @@ test("builds and extrapolates gray from V1 surfaces and text without using menus
|
|||||||
const light = resolveV1(source, "light")
|
const light = resolveV1(source, "light")
|
||||||
const dark = resolveV1(source, "dark")
|
const dark = resolveV1(source, "dark")
|
||||||
const migrated = migrateV1(source)
|
const migrated = migrateV1(source)
|
||||||
|
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||||
const lightGray = migrated.light.hue?.gray
|
const lightGray = migrated.light.hue?.gray
|
||||||
const darkGray = migrated.dark.hue?.gray
|
const darkGray = migrated.dark.hue?.gray
|
||||||
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
|
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
|
||||||
@@ -114,8 +118,9 @@ test("builds and extrapolates gray from V1 surfaces and text without using menus
|
|||||||
source.theme.borderSubtle = "#ff00ff"
|
source.theme.borderSubtle = "#ff00ff"
|
||||||
source.theme.border = "#00ff00"
|
source.theme.border = "#00ff00"
|
||||||
source.theme.borderActive = "#00ffff"
|
source.theme.borderActive = "#00ffff"
|
||||||
expect(migrateV1(source).light.hue?.gray).toEqual(lightGray)
|
const withBorders = migrateV1(source)
|
||||||
expect(migrateV1(source).dark.hue?.gray).toEqual(darkGray)
|
expect(withBorders.light?.hue?.gray).toEqual(lightGray)
|
||||||
|
expect(withBorders.dark?.hue?.gray).toEqual(darkGray)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses the default text reference for primary actions on transparent backgrounds", () => {
|
test("uses the default text reference for primary actions on transparent backgrounds", () => {
|
||||||
@@ -124,6 +129,7 @@ test("uses the default text reference for primary actions on transparent backgro
|
|||||||
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
||||||
delete source.theme.selectedListItemText
|
delete source.theme.selectedListItemText
|
||||||
const migrated = migrateV1(source)
|
const migrated = migrateV1(source)
|
||||||
|
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||||
|
|
||||||
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||||
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
|
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
|
||||||
@@ -137,14 +143,44 @@ test("retains V1 circular reference errors", () => {
|
|||||||
expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
|
expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("migrates every built-in V1 theme in both modes", () => {
|
test("migrates every built-in V1 theme in its supported modes", () => {
|
||||||
for (const source of Object.values(DEFAULT_THEMES)) {
|
for (const source of Object.values(DEFAULT_THEMES)) {
|
||||||
const migrated = migrateV1(source)
|
const migrated = migrateV1(source)
|
||||||
expect(resolveThemeFile(migrated, "light").text.default).toBeDefined()
|
for (const mode of themeModes(migrated)) {
|
||||||
expect(resolveThemeFile(migrated, "dark").text.default).toBeDefined()
|
expect(resolveThemeFile(migrated, mode).text.default).toBeDefined()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("collapses identical V1 backgrounds when both variants infer one mode", () => {
|
||||||
|
const dark = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
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(DEFAULT_THEMES.opencode)
|
||||||
|
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("keeps both modes when a shared background has different contrast", () => {
|
||||||
|
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||||
|
source.theme.background = "#808080"
|
||||||
|
source.theme.text = { light: "#111111", dark: "#eeeeee" }
|
||||||
|
const migrated = migrateV1(source)
|
||||||
|
|
||||||
|
expect(themeModes(migrated)).toEqual(["light", "dark"])
|
||||||
|
})
|
||||||
|
|
||||||
function hex(color: { toInts(): [number, number, number, number] }) {
|
function hex(color: { toInts(): [number, number, number, number] }) {
|
||||||
const [r, g, b, a] = color.toInts()
|
const [r, g, b, a] = color.toInts()
|
||||||
const byte = (value: number) => value.toString(16).padStart(2, "0")
|
const byte = (value: number) => value.toString(16).padStart(2, "0")
|
||||||
|
|||||||
Reference in New Issue
Block a user