Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7102f537d9 |
@@ -5,21 +5,20 @@ import {
|
||||
addTheme,
|
||||
allThemes,
|
||||
hasTheme,
|
||||
isTheme,
|
||||
normalizeTheme,
|
||||
selectedForeground,
|
||||
setCustomThemes,
|
||||
setSystemTheme,
|
||||
subscribeThemes,
|
||||
upsertTheme,
|
||||
type Theme,
|
||||
type ThemeJson,
|
||||
type ThemeDocument,
|
||||
} from "../theme"
|
||||
import { generateSyntax } from "../theme/v2/syntax"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||
import { resolveThemeFile } from "../theme/v2/resolve"
|
||||
import { migrateV1 } from "../theme/v2/v1-migrate"
|
||||
import { resolveDecodedThemeFile } from "../theme/v2/resolve"
|
||||
import { themeModes } from "../theme/v2/select"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
@@ -61,7 +60,7 @@ export {
|
||||
const THEME_REFRESH_DELAYS = [250, 1000] as const
|
||||
|
||||
type State = {
|
||||
themes: Record<string, ThemeJson>
|
||||
themes: Record<string, ThemeDocument>
|
||||
mode: "dark" | "light"
|
||||
lock: "dark" | "light" | undefined
|
||||
active: string
|
||||
@@ -139,12 +138,7 @@ const themeContext = createSimpleContext({
|
||||
return themes
|
||||
.discover()
|
||||
.then((themes) => {
|
||||
setCustomThemes(
|
||||
Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => {
|
||||
if (isTheme(theme)) result[name] = theme
|
||||
return result
|
||||
}, {}),
|
||||
)
|
||||
setCustomThemes(themes)
|
||||
})
|
||||
.catch(() => setStore("active", "opencode"))
|
||||
}
|
||||
@@ -269,16 +263,23 @@ const themeContext = createSimpleContext({
|
||||
})
|
||||
|
||||
const initStarted = performance.now()
|
||||
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
|
||||
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
|
||||
const file = createMemo(() => migrateV1(source()))
|
||||
const file = createMemo(() => {
|
||||
const name = store.themes[store.active] ? store.active : "opencode"
|
||||
try {
|
||||
return normalizeTheme(store.themes[name], name)
|
||||
} catch (error) {
|
||||
if (name === "opencode") throw error
|
||||
setStore("active", "opencode")
|
||||
return normalizeTheme(store.themes.opencode, "opencode")
|
||||
}
|
||||
})
|
||||
const modes = createMemo(() => themeModes(file()))
|
||||
const mode = () => {
|
||||
const supported = modes()
|
||||
if (supported.includes(store.mode)) return store.mode
|
||||
return supported[0] ?? store.mode
|
||||
}
|
||||
const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName()))
|
||||
const valuesV2 = createMemo(() => resolveDecodedThemeFile(file(), mode()))
|
||||
valuesV2()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
import { ansiToRgba } from "../theme/color"
|
||||
import { resolveThemeColors } from "../theme/resolve"
|
||||
import { terminalMode } from "../theme/system"
|
||||
import type { ThemeJson } from "../theme/v1"
|
||||
import type { ThemeV1Json } from "../theme/v1"
|
||||
import type { EntryKind, RunTuiConfig } from "./types"
|
||||
|
||||
type Tone = {
|
||||
@@ -184,7 +184,7 @@ function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number)
|
||||
return nearestIndexed(indexed, mixed)
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
|
||||
export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): TuiThemeCurrent {
|
||||
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
|
||||
return {
|
||||
...resolved.theme,
|
||||
@@ -246,7 +246,7 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) =>
|
||||
return map(RGBA.fromInts(gray, gray, gray))
|
||||
}
|
||||
|
||||
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson {
|
||||
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeV1Json {
|
||||
const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const bg = RGBA.defaultBackground(bg_snapshot)
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
|
||||
import { decodeThemeFile } from "./v2/resolve"
|
||||
import type { ThemeFile } from "./v2/schema"
|
||||
import { migrateV1 } from "./v2/v1-migrate"
|
||||
|
||||
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1"
|
||||
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
|
||||
|
||||
const pluginThemes: Record<string, ThemeJson> = {}
|
||||
let customThemes: Record<string, ThemeJson> = {}
|
||||
let systemTheme: ThemeJson | undefined
|
||||
const listeners = new Set<(themes: Record<string, ThemeJson>) => void>()
|
||||
export type ThemeDocument = ThemeV1Json | ThemeFile
|
||||
|
||||
const pluginThemes: Record<string, ThemeDocument> = {}
|
||||
let customThemes: Record<string, ThemeDocument> = {}
|
||||
let systemTheme: ThemeDocument | undefined
|
||||
const listeners = new Set<(themes: Record<string, ThemeDocument>) => void>()
|
||||
const normalized = new WeakMap<object, ThemeFile>()
|
||||
|
||||
function listThemes() {
|
||||
// Priority: defaults < plugin installs < custom files < generated system.
|
||||
const themes = {
|
||||
const themes: Record<string, ThemeDocument> = {
|
||||
...DEFAULT_THEMES,
|
||||
...pluginThemes,
|
||||
...customThemes,
|
||||
@@ -31,23 +37,39 @@ export function allThemes() {
|
||||
return listThemes()
|
||||
}
|
||||
|
||||
export function isTheme(theme: unknown): theme is ThemeJson {
|
||||
if (typeof theme !== "object" || theme === null || Array.isArray(theme)) return false
|
||||
const value = Reflect.get(theme, "theme")
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
export function isTheme(theme: unknown): theme is ThemeDocument {
|
||||
if (!isRecord(theme)) return false
|
||||
const version = themeVersion(theme)
|
||||
if (version === 2) return true
|
||||
if (version !== 1) return false
|
||||
return isRecord(theme.theme)
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: (themes: Record<string, ThemeJson>) => void) {
|
||||
export function normalizeTheme(theme: ThemeDocument, name = "theme") {
|
||||
const cached = normalized.get(theme)
|
||||
if (cached) return cached
|
||||
const version = themeVersion(theme)
|
||||
if (version !== 1 && version !== 2) throw new Error(`Unsupported theme version: ${String(version)}`)
|
||||
const file = version === 1 ? migrateV1(theme as ThemeV1Json) : decodeThemeFile(theme, name)
|
||||
normalized.set(theme, file)
|
||||
return file
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: (themes: Record<string, ThemeDocument>) => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function setCustomThemes(themes: Record<string, ThemeJson>) {
|
||||
customThemes = themes
|
||||
export function setCustomThemes(themes: Record<string, unknown>) {
|
||||
customThemes = Object.fromEntries(
|
||||
Object.entries(themes).filter((entry): entry is [string, ThemeDocument] => isTheme(entry[1])),
|
||||
)
|
||||
for (const theme of Object.values(customThemes)) normalized.delete(theme)
|
||||
syncThemes()
|
||||
}
|
||||
|
||||
export function setSystemTheme(theme: ThemeJson | undefined) {
|
||||
export function setSystemTheme(theme: ThemeDocument | undefined) {
|
||||
if (theme) normalized.delete(theme)
|
||||
systemTheme = theme
|
||||
syncThemes()
|
||||
}
|
||||
@@ -61,6 +83,7 @@ export function addTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (hasTheme(name)) return false
|
||||
normalized.delete(theme)
|
||||
pluginThemes[name] = theme
|
||||
syncThemes()
|
||||
return true
|
||||
@@ -69,6 +92,7 @@ export function addTheme(name: string, theme: unknown) {
|
||||
export function upsertTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
normalized.delete(theme)
|
||||
if (customThemes[name] !== undefined) {
|
||||
customThemes[name] = theme
|
||||
} else {
|
||||
@@ -78,7 +102,7 @@ export function upsertTheme(name: string, theme: unknown) {
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
export function resolveTheme(theme: ThemeV1Json, mode: "dark" | "light"): Theme {
|
||||
const resolved = resolveThemeColors(theme, mode)
|
||||
return {
|
||||
...resolved.theme,
|
||||
@@ -86,3 +110,11 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function themeVersion(theme: object) {
|
||||
return "version" in theme ? theme.version : 1
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { ansiToRgba } from "./color"
|
||||
import type { ColorValue, Theme, ThemeColor, ThemeJson } from "./v1"
|
||||
import type { ColorValue, Theme, ThemeColor, ThemeV1Json } from "./v1"
|
||||
|
||||
export function resolveThemeColors(
|
||||
theme: ThemeJson,
|
||||
theme: ThemeV1Json,
|
||||
mode: "dark" | "light",
|
||||
resolveAnsi: (code: number) => RGBA = ansiToRgba,
|
||||
) {
|
||||
@@ -43,7 +43,9 @@ export function resolveThemeColors(
|
||||
? resolveColor(theme.theme.selectedListItemText!)
|
||||
: resolved.background!,
|
||||
backgroundMenu:
|
||||
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
|
||||
theme.theme.backgroundMenu === undefined
|
||||
? resolved.backgroundElement!
|
||||
: resolveColor(theme.theme.backgroundMenu),
|
||||
} satisfies Omit<Theme, "_hasSelectedListItemText" | "thinkingOpacity">,
|
||||
hasSelectedListItemText,
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
|
||||
@@ -98,7 +98,7 @@ export type Variant = {
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeJson = {
|
||||
export type ThemeV1Json = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
@@ -108,7 +108,7 @@ export type ThemeJson = {
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||
export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
|
||||
aura,
|
||||
ayu,
|
||||
catppuccin,
|
||||
|
||||
@@ -36,7 +36,7 @@ function decodeThemeDefinition(input: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeThemeFile(input: unknown, name: string) {
|
||||
export function decodeThemeFile(input: unknown, name = "theme") {
|
||||
try {
|
||||
return decodeThemeFileSchema(input)
|
||||
} catch (error) {
|
||||
@@ -51,12 +51,15 @@ function themeDecodeError(error: unknown, name: string) {
|
||||
}
|
||||
|
||||
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") {
|
||||
const decoded = decodeThemeFile(file, name)
|
||||
const selected = selectThemeMode(decoded, mode)
|
||||
return resolveDecodedThemeFile(decodeThemeFile(file, name), mode)
|
||||
}
|
||||
|
||||
export function resolveDecodedThemeFile(file: ThemeFile, mode?: "light" | "dark") {
|
||||
const selected = selectThemeMode(file, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const merged = decoded.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
const merged = file.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||
import type { Theme, ThemeJson } from "../index"
|
||||
import type { Theme, ThemeV1Json } from "../v1"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import type { FileThemeDefinition, Mode, ThemeFile } from "./index"
|
||||
import { HueStep } from "./schema"
|
||||
@@ -14,7 +14,7 @@ const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "succes
|
||||
const minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
|
||||
export function migrateV1(theme: ThemeJson): ThemeFile {
|
||||
export function migrateV1(theme: ThemeV1Json): 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)) {
|
||||
@@ -234,7 +234,7 @@ function ambiguous(color: RGBA, chroma = toOklch(color).c) {
|
||||
return color.toInts()[3] === 0 || chroma < minimumChroma
|
||||
}
|
||||
|
||||
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
function resolveV1(theme: ThemeV1Json, mode: "dark" | "light"): Theme {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
function resolveColor(value: unknown, chain: string[] = []): RGBA {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
@@ -24,6 +25,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
|
||||
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||
darkOnly.theme.background = "#111111"
|
||||
darkOnly.theme.text = "#eeeeee"
|
||||
const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
|
||||
function Probe() {
|
||||
@@ -42,7 +44,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "light-only", mode: "dark" } })}>
|
||||
<ThemeProvider
|
||||
mode="dark"
|
||||
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }}
|
||||
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual, native }) }}
|
||||
>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
@@ -66,6 +68,41 @@ test("uses an available mode while retaining the pinned preference", async () =>
|
||||
expect(current().set("dual")).toBeTrue()
|
||||
await wait(() => current().mode() === "dark")
|
||||
expect(current().modes()).toEqual(["light", "dark"])
|
||||
expect(current().set("native")).toBeTrue()
|
||||
await wait(() => current().selected === "native")
|
||||
expect(current().modes()).toEqual(["dark"])
|
||||
expect(current().themeV2.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("falls back to OpenCode when the configured V2 theme is invalid", async () => {
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
|
||||
function Probe() {
|
||||
theme = useTheme()
|
||||
return <text>{theme.selected}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
|
||||
<ThemeProvider
|
||||
mode="dark"
|
||||
source={{ discover: () => Promise.resolve({ invalid: { version: 2, light: { categorical: [] } } }) }}
|
||||
>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await wait(() => theme?.ready === true)
|
||||
expect(theme?.selected).toBe("opencode")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import { expect, test } from "bun:test"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { TerminalColors } from "@opentui/core"
|
||||
import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme } from "../src/theme"
|
||||
import {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
allThemes,
|
||||
hasTheme,
|
||||
normalizeTheme,
|
||||
resolveTheme,
|
||||
setCustomThemes,
|
||||
upsertTheme,
|
||||
} from "../src/theme"
|
||||
import { discoverThemes, themeDirectories } from "../src/theme/discovery"
|
||||
import { terminalMode } from "../src/theme/system"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
@@ -10,7 +19,7 @@ import { tmpdir } from "./fixture/fixture"
|
||||
test("addTheme writes into module theme store", () => {
|
||||
const name = `plugin-theme-${Date.now()}`
|
||||
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||
expect(allThemes()[name]).toBeDefined()
|
||||
expect(allThemes()[name]).toBe(DEFAULT_THEMES.opencode)
|
||||
})
|
||||
|
||||
test("addTheme keeps first theme for duplicate names", () => {
|
||||
@@ -22,15 +31,92 @@ test("addTheme keeps first theme for duplicate names", () => {
|
||||
|
||||
expect(addTheme(name, one)).toBe(true)
|
||||
expect(addTheme(name, two)).toBe(false)
|
||||
expect(allThemes()[name]!.theme.primary).toBe("#101010")
|
||||
expect(allThemes()[name]).toBe(one)
|
||||
})
|
||||
|
||||
test("addTheme ignores entries without a theme object", () => {
|
||||
test("addTheme ignores invalid envelopes and unknown versions", () => {
|
||||
const name = `plugin-theme-invalid-${Date.now()}`
|
||||
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
|
||||
expect(addTheme(name, { version: 3, light: {} })).toBe(false)
|
||||
expect(allThemes()[name]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes unversioned and explicit V1 themes lazily once", () => {
|
||||
const unversioned = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 }
|
||||
const first = normalizeTheme(unversioned, "unversioned")
|
||||
const second = normalizeTheme(explicit, "explicit")
|
||||
|
||||
expect(first.version).toBe(2)
|
||||
expect(second.version).toBe(2)
|
||||
expect(normalizeTheme(unversioned, "unversioned")).toBe(first)
|
||||
expect(normalizeTheme(explicit, "explicit")).toBe(second)
|
||||
})
|
||||
|
||||
test("decodes native V2 themes lazily once", () => {
|
||||
const name = `plugin-theme-v2-${Date.now()}`
|
||||
const source = { version: 2, light: { categorical: ["red"] } } as const
|
||||
|
||||
expect(addTheme(name, source)).toBe(true)
|
||||
expect(allThemes()[name]).toBe(source)
|
||||
const file = normalizeTheme(allThemes()[name]!, name)
|
||||
expect(file.light?.categorical).toEqual(["red"])
|
||||
expect(normalizeTheme(allThemes()[name]!, name)).toBe(file)
|
||||
})
|
||||
|
||||
test("defers invalid V2 errors until normalization", () => {
|
||||
const name = `plugin-theme-invalid-v2-${Date.now()}`
|
||||
expect(addTheme(name, { version: 2, light: { categorical: [] } })).toBe(true)
|
||||
expect(() => normalizeTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`)
|
||||
})
|
||||
|
||||
test("defers invalid V1 errors until normalization", () => {
|
||||
const name = `plugin-theme-invalid-v1-${Date.now()}`
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.defs = { ...source.defs, one: "two", two: "one" }
|
||||
source.theme.primary = "one"
|
||||
|
||||
expect(addTheme(name, source)).toBe(true)
|
||||
expect(() => normalizeTheme(allThemes()[name]!, name)).toThrow("Circular color reference")
|
||||
})
|
||||
|
||||
test("replacement sources receive independent normalization caches", () => {
|
||||
const name = `plugin-theme-replace-${Date.now()}`
|
||||
const first = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const second = structuredClone(DEFAULT_THEMES.opencode)
|
||||
second.theme.primary = "#123456"
|
||||
|
||||
expect(addTheme(name, first)).toBe(true)
|
||||
const previous = normalizeTheme(allThemes()[name]!, name)
|
||||
expect(upsertTheme(name, second)).toBe(true)
|
||||
const next = normalizeTheme(allThemes()[name]!, name)
|
||||
expect(next).not.toBe(previous)
|
||||
expect(normalizeTheme(allThemes()[name]!, name)).toBe(next)
|
||||
})
|
||||
|
||||
test("upsert invalidates a mutated source object's normalization cache", () => {
|
||||
const name = `plugin-theme-mutate-${Date.now()}`
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
|
||||
expect(addTheme(name, source)).toBe(true)
|
||||
const previous = normalizeTheme(allThemes()[name]!, name)
|
||||
source.theme.primary = "#123456"
|
||||
expect(upsertTheme(name, source)).toBe(true)
|
||||
expect(normalizeTheme(allThemes()[name]!, name)).not.toBe(previous)
|
||||
})
|
||||
|
||||
test("custom themes retain precedence over plugin themes", () => {
|
||||
const name = `plugin-theme-precedence-${Date.now()}`
|
||||
const plugin = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const custom = structuredClone(DEFAULT_THEMES.opencode)
|
||||
|
||||
expect(addTheme(name, plugin)).toBe(true)
|
||||
setCustomThemes({ [name]: custom })
|
||||
expect(allThemes()[name]).toBe(custom)
|
||||
setCustomThemes({})
|
||||
expect(allThemes()[name]).toBe(plugin)
|
||||
})
|
||||
|
||||
test("hasTheme checks theme presence", () => {
|
||||
const name = `plugin-theme-has-${Date.now()}`
|
||||
expect(hasTheme(name)).toBe(false)
|
||||
|
||||
Reference in New Issue
Block a user