refactor(tui): load native V2 themes (#38430)
This commit is contained in:
parent
466b75b19d
commit
8f3465c951
20 changed files with 410 additions and 161 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { Schema, SchemaAST } from "effect"
|
||||
import { format } from "prettier"
|
||||
import { ThemeDefinition, ThemeFile } from "../../tui/src/theme/v2/schema"
|
||||
import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema"
|
||||
|
||||
const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
|
||||
const root = requireObject(ThemeDefinition.ast)
|
||||
|
|
@ -52,8 +52,8 @@ const example = {
|
|||
default: "#101014",
|
||||
},
|
||||
},
|
||||
} satisfies ThemeFile
|
||||
Schema.decodeUnknownSync(ThemeFile)(example)
|
||||
} satisfies ThemeDocument
|
||||
Schema.decodeUnknownSync(ThemeDocument)(example)
|
||||
const output = await format(
|
||||
`{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
|||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
|
|
@ -337,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
|
|
|
|||
20
packages/tui/src/component/theme-error-toast.tsx
Normal file
20
packages/tui/src/component/theme-error-toast.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { onCleanup } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function ThemeErrorToast() {
|
||||
const theme = useTheme()
|
||||
const toast = useToast()
|
||||
|
||||
onCleanup(
|
||||
theme.onError(({ name, error }) =>
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: `Failed to load theme: ${name}`,
|
||||
message: error.message,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -5,21 +5,20 @@ import {
|
|||
addTheme,
|
||||
allThemes,
|
||||
hasTheme,
|
||||
isTheme,
|
||||
parseTheme,
|
||||
selectedForeground,
|
||||
setCustomThemes,
|
||||
setSystemTheme,
|
||||
subscribeThemes,
|
||||
upsertTheme,
|
||||
type Theme,
|
||||
type ThemeJson,
|
||||
type ThemeDocumentSource,
|
||||
} 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 { resolveThemeDocument } 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"
|
||||
|
|
@ -29,6 +28,36 @@ import { Global } from "@opencode-ai/util/global"
|
|||
import { DevTools } from "../devtools"
|
||||
|
||||
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
|
||||
export type ThemeError = { name: string; error: Error }
|
||||
type ThemeErrorHandler = (event: ThemeError) => void
|
||||
|
||||
function createThemeErrors() {
|
||||
let handler: ThemeErrorHandler | undefined
|
||||
let pending: ThemeError | undefined
|
||||
|
||||
return {
|
||||
emit(name: string, cause: unknown) {
|
||||
const event = { name, error: cause instanceof Error ? cause : new Error(String(cause)) }
|
||||
if (handler) {
|
||||
handler(event)
|
||||
return
|
||||
}
|
||||
pending = event
|
||||
},
|
||||
onError(next: ThemeErrorHandler) {
|
||||
handler = next
|
||||
if (pending) {
|
||||
next(pending)
|
||||
pending = undefined
|
||||
}
|
||||
return () => {
|
||||
if (handler === next) handler = undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const themeErrors = createThemeErrors()
|
||||
|
||||
export type ThemeSource = Readonly<{
|
||||
discover(): Promise<Record<string, unknown>>
|
||||
|
|
@ -61,7 +90,7 @@ export {
|
|||
const THEME_REFRESH_DELAYS = [250, 1000] as const
|
||||
|
||||
type State = {
|
||||
themes: Record<string, ThemeJson>
|
||||
themes: Record<string, ThemeDocumentSource>
|
||||
mode: "dark" | "light"
|
||||
lock: "dark" | "light" | undefined
|
||||
active: string
|
||||
|
|
@ -84,6 +113,7 @@ type ThemeService = {
|
|||
unlock(): void
|
||||
setMode(mode?: "dark" | "light", persist?: boolean): boolean
|
||||
set(theme: string): boolean
|
||||
onError(handler: ThemeErrorHandler): () => void
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
||||
|
|
@ -139,12 +169,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,30 +294,26 @@ 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 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 selected = createMemo(() => {
|
||||
const name = store.themes[store.active] ? store.active : "opencode"
|
||||
try {
|
||||
return loadTheme(store.themes[name], name, store.mode)
|
||||
} catch (error) {
|
||||
if (name === "opencode") throw error
|
||||
themeErrors.emit(name, error)
|
||||
setStore("active", "opencode")
|
||||
return loadTheme(store.themes.opencode, "opencode", store.mode)
|
||||
}
|
||||
})
|
||||
const modes = () => selected().modes
|
||||
const mode = () => selected().mode
|
||||
const valuesV2 = () => selected().theme
|
||||
valuesV2()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||
const contextsV2 = {
|
||||
elevated: createComponentTheme(() => {
|
||||
const theme = valuesV2().contexts["@context:elevated"]
|
||||
if (!theme) throw new Error("Theme context is not defined: elevated")
|
||||
return theme
|
||||
}, mode),
|
||||
overlay: createComponentTheme(() => {
|
||||
const theme = valuesV2().contexts["@context:overlay"]
|
||||
if (!theme) throw new Error("Theme context is not defined: overlay")
|
||||
return theme
|
||||
}, mode),
|
||||
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
|
||||
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
|
||||
}
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||
|
|
@ -331,6 +352,7 @@ const themeContext = createSimpleContext({
|
|||
.catch(() => {})
|
||||
return true
|
||||
},
|
||||
onError: themeErrors.onError,
|
||||
get ready() {
|
||||
return store.ready
|
||||
},
|
||||
|
|
@ -354,6 +376,14 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName }
|
|||
</themeContext.context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") {
|
||||
const document = parseTheme(source, name)
|
||||
const modes = themeModes(document)
|
||||
const mode = modes.includes(requested) ? requested : (modes[0] ?? requested)
|
||||
return { modes, mode, theme: resolveThemeDocument(document, mode) }
|
||||
}
|
||||
|
||||
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
|
||||
const renderer = useRenderer()
|
||||
const retained = new Set<SyntaxStyle>()
|
||||
|
|
|
|||
|
|
@ -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,25 @@
|
|||
import { Schema } from "effect"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
|
||||
import { resolveThemeDocument, themeDecodeError } from "./v2/resolve"
|
||||
import { ThemeDocument } 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"
|
||||
export { resolveThemeDocument, type ThemeDocument }
|
||||
|
||||
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 ThemeDocumentSource = Record<string, unknown>
|
||||
|
||||
const pluginThemes: Record<string, ThemeDocumentSource> = {}
|
||||
let customThemes: Record<string, ThemeDocumentSource> = {}
|
||||
let systemTheme: ThemeDocumentSource | undefined
|
||||
const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>()
|
||||
const parsed = new WeakMap<object, ThemeDocument>()
|
||||
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument)
|
||||
|
||||
function listThemes() {
|
||||
// Priority: defaults < plugin installs < custom files < generated system.
|
||||
const themes = {
|
||||
const themes: Record<string, ThemeDocumentSource> = {
|
||||
...DEFAULT_THEMES,
|
||||
...pluginThemes,
|
||||
...customThemes,
|
||||
|
|
@ -31,23 +40,40 @@ 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 isThemeSource(source: unknown): source is ThemeDocumentSource {
|
||||
if (typeof source !== "object" || source === null || Array.isArray(source)) return false
|
||||
return "theme" in source || "version" in source
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: (themes: Record<string, ThemeJson>) => void) {
|
||||
export function parseTheme(source: ThemeDocumentSource, name = "theme") {
|
||||
const cached = parsed.get(source)
|
||||
if (cached) return cached
|
||||
|
||||
const version = source.version ?? 1
|
||||
const document =
|
||||
version === 1
|
||||
? migrateV1(source as ThemeV1Json)
|
||||
: version === 2
|
||||
? decodeV2Theme(source, name)
|
||||
: unsupportedThemeVersion(version)
|
||||
|
||||
parsed.set(source, document)
|
||||
return document
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: (themes: Record<string, ThemeDocumentSource>) => 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, ThemeDocumentSource] => isThemeSource(entry[1])),
|
||||
)
|
||||
syncThemes()
|
||||
}
|
||||
|
||||
export function setSystemTheme(theme: ThemeJson | undefined) {
|
||||
export function setSystemTheme(theme: ThemeDocumentSource | undefined) {
|
||||
systemTheme = theme
|
||||
syncThemes()
|
||||
}
|
||||
|
|
@ -59,7 +85,7 @@ export function hasTheme(name: string) {
|
|||
|
||||
export function addTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (!isThemeSource(theme)) return false
|
||||
if (hasTheme(name)) return false
|
||||
pluginThemes[name] = theme
|
||||
syncThemes()
|
||||
|
|
@ -68,7 +94,7 @@ export function addTheme(name: string, theme: unknown) {
|
|||
|
||||
export function upsertTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (!isThemeSource(theme)) return false
|
||||
if (customThemes[name] !== undefined) {
|
||||
customThemes[name] = theme
|
||||
} else {
|
||||
|
|
@ -78,7 +104,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 +112,15 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
|||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function decodeV2Theme(source: ThemeDocumentSource, name: string) {
|
||||
try {
|
||||
return decodeThemeDocument(source)
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, name)
|
||||
}
|
||||
}
|
||||
|
||||
function unsupportedThemeVersion(version: unknown): never {
|
||||
throw new Error(`Unsupported theme version: ${String(version)}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { HueName, ThemeFile } from "./schema"
|
||||
import type { HueName, ThemeDocument } from "./schema"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
|
|
@ -437,4 +437,4 @@ export const DEFAULT_THEME = {
|
|||
},
|
||||
},
|
||||
},
|
||||
} satisfies ThemeFile
|
||||
} satisfies ThemeDocument
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export {
|
|||
SyntaxDefinition,
|
||||
SyntaxToken,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
ThemeDocument,
|
||||
type BackgroundDefinition,
|
||||
type DiffDefinition,
|
||||
type FileThemeDefinition,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
HueAlias,
|
||||
HueStep,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
ThemeDocument,
|
||||
} from "./schema"
|
||||
import type {
|
||||
ActionStateKey,
|
||||
|
|
@ -26,7 +26,6 @@ import type {
|
|||
import { selectTheme, selectThemeMode } from "./select"
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
|
||||
const decodeThemeFileSchema = Schema.decodeUnknownSync(ThemeFile)
|
||||
|
||||
function decodeThemeDefinition(input: unknown) {
|
||||
try {
|
||||
|
|
@ -36,27 +35,18 @@ function decodeThemeDefinition(input: unknown) {
|
|||
}
|
||||
}
|
||||
|
||||
function decodeThemeFile(input: unknown, name: string) {
|
||||
try {
|
||||
return decodeThemeFileSchema(input)
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, name)
|
||||
}
|
||||
}
|
||||
|
||||
function themeDecodeError(error: unknown, name: string) {
|
||||
export function themeDecodeError(error: unknown, name: string) {
|
||||
const message = Schema.isSchemaError(error) ? error.message : String(error)
|
||||
const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value"
|
||||
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
|
||||
}
|
||||
|
||||
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") {
|
||||
const decoded = decodeThemeFile(file, name)
|
||||
const selected = selectThemeMode(decoded, mode)
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
|
||||
const selected = selectThemeMode(document, 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 = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
|
|
|
|||
|
|
@ -251,8 +251,8 @@ const FileMetadata = {
|
|||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeFile = Schema.Union([
|
||||
export const ThemeDocument = 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 ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>
|
||||
|
|
|
|||
|
|
@ -5,45 +5,45 @@ import type {
|
|||
Mode,
|
||||
ModeDefinition,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
ThemeDocument,
|
||||
} from "./index"
|
||||
|
||||
export function selectTheme(
|
||||
file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
mode?: Mode,
|
||||
): ThemeDefinition
|
||||
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
|
||||
export function selectTheme(file: ThemeFile, mode?: Mode) {
|
||||
return selectThemeMode(file, mode).theme
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode) {
|
||||
return selectThemeMode(document, mode).theme
|
||||
}
|
||||
|
||||
export function selectThemeMode(
|
||||
file: ThemeFile,
|
||||
document: ThemeDocument,
|
||||
mode: Mode = "light",
|
||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||
const modes = themeModes(file)
|
||||
const modes = themeModes(document)
|
||||
const selectedMode = modes.includes(mode) ? mode : modes[0]
|
||||
const selected = file[selectedMode]
|
||||
const selected = document[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(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
|
||||
|
||||
const otherMode = selectedMode === "light" ? "dark" : "light"
|
||||
const other = file[otherMode]
|
||||
const other = document[otherMode]
|
||||
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
|
||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
|
||||
}
|
||||
|
||||
export function themeModes(file: ThemeFile): readonly Mode[] {
|
||||
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 themeModes(document: ThemeDocument): readonly Mode[] {
|
||||
if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||
if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||
return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined)
|
||||
}
|
||||
|
||||
export function supportsThemeMode(file: ThemeFile, mode: Mode) {
|
||||
return themeModes(file).includes(mode)
|
||||
export function supportsThemeMode(document: ThemeDocument, mode: Mode) {
|
||||
return themeModes(document).includes(mode)
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 type { FileThemeDefinition, Mode, ThemeDocument } from "./index"
|
||||
import { HueStep } from "./schema"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
|
|
@ -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): ThemeDocument {
|
||||
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,10 +1,13 @@
|
|||
/** @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 { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
|
||||
import { selectTheme } from "../../../src/theme/v2/select"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ThemeProvider, useTheme } from "../../../src/context/theme"
|
||||
import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme"
|
||||
|
||||
async function wait(fn: () => boolean) {
|
||||
const started = Date.now()
|
||||
|
|
@ -24,6 +27,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 +46,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 +70,85 @@ 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.each([
|
||||
["schema", { version: 2, light: { categorical: [] } }],
|
||||
["mode merging", { version: 2, light: { mergeMode: true } }],
|
||||
["token reference", { version: 2, light: { text: { default: "$missing" } } }],
|
||||
] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => {
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
let failure: ThemeError | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
function Probe() {
|
||||
const value = useTheme()
|
||||
theme = value
|
||||
unsubscribe = value.onError((error) => (failure = error))
|
||||
return <text>{value.selected}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ invalid: source }) }}>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await wait(() => theme?.ready === true)
|
||||
expect(theme?.selected).toBe("opencode")
|
||||
expect(failure?.name).toBe("invalid")
|
||||
expect(failure?.error).toBeInstanceOf(Error)
|
||||
expect(failure?.error.message.length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
unsubscribe?.()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("contextual themes fall back to a standalone theme's base view", async () => {
|
||||
const standalone = {
|
||||
version: 2,
|
||||
standalone: true,
|
||||
dark: { hue: selectTheme(DEFAULT_THEME, "dark").hue },
|
||||
} as const
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
|
||||
function Probe() {
|
||||
theme = useTheme()
|
||||
return <text>{theme.selected}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "standalone", mode: "dark" } })}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ standalone }) }}>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await wait(() => theme?.ready === true)
|
||||
if (!theme) throw new Error("Theme provider is not mounted")
|
||||
expect(theme.contextual("elevated").themeV2.text.default).toBe(theme.themeV2.text.default)
|
||||
expect(theme.contextual("overlay").themeV2.background.default).toBe(theme.themeV2.background.default)
|
||||
} 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,
|
||||
parseTheme,
|
||||
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 values without a V1 theme or version", () => {
|
||||
const name = `plugin-theme-invalid-${Date.now()}`
|
||||
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
|
||||
expect(addTheme(name, { light: {} })).toBe(false)
|
||||
expect(allThemes()[name]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("addTheme defers validation of versioned sources", () => {
|
||||
const name = `plugin-theme-versioned-${Date.now()}`
|
||||
expect(addTheme(name, { version: 2 })).toBe(true)
|
||||
expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`)
|
||||
})
|
||||
|
||||
test("parseTheme delegates malformed V1 sources and rejects unknown versions", () => {
|
||||
expect(() => parseTheme({})).toThrow()
|
||||
expect(() => parseTheme({ version: 3 })).toThrow("Unsupported theme version: 3")
|
||||
})
|
||||
|
||||
test("parses unversioned and explicit V1 themes lazily once", () => {
|
||||
const unversioned = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 }
|
||||
const first = parseTheme(unversioned, "unversioned")
|
||||
const second = parseTheme(explicit, "explicit")
|
||||
|
||||
expect(first.version).toBe(2)
|
||||
expect(second.version).toBe(2)
|
||||
expect(parseTheme(unversioned, "unversioned")).toBe(first)
|
||||
expect(parseTheme(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 document = parseTheme(allThemes()[name]!, name)
|
||||
expect(document.light?.categorical).toEqual(["red"])
|
||||
expect(parseTheme(allThemes()[name]!, name)).toBe(document)
|
||||
})
|
||||
|
||||
test("defers invalid V2 errors until parsing", () => {
|
||||
const name = `plugin-theme-invalid-v2-${Date.now()}`
|
||||
expect(addTheme(name, { version: 2, light: { categorical: [] } })).toBe(true)
|
||||
expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`)
|
||||
})
|
||||
|
||||
test("defers invalid V1 errors until parsing", () => {
|
||||
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(() => parseTheme(allThemes()[name]!, name)).toThrow("Circular color reference")
|
||||
})
|
||||
|
||||
test("replacement sources receive independent parse 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 = parseTheme(allThemes()[name]!, name)
|
||||
expect(upsertTheme(name, second)).toBe(true)
|
||||
const next = parseTheme(allThemes()[name]!, name)
|
||||
expect(next).not.toBe(previous)
|
||||
expect(parseTheme(allThemes()[name]!, name)).toBe(next)
|
||||
})
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { parseTheme, type ThemeDocumentSource } from "../../../src/theme"
|
||||
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
|
||||
import type { ThemeDefinition } from "../../../src/theme/v2"
|
||||
import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve"
|
||||
import type { Mode, ThemeDefinition } from "../../../src/theme/v2"
|
||||
import { resolveTheme, resolveThemeDocument } from "../../../src/theme/v2/resolve"
|
||||
import { selectTheme } from "../../../src/theme/v2/select"
|
||||
|
||||
const light = selectTheme(DEFAULT_THEME, "light")
|
||||
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")
|
||||
function resolveSource(source: ThemeDocumentSource, mode?: Mode, name?: string) {
|
||||
return resolveThemeDocument(parseTheme(source, name), mode)
|
||||
}
|
||||
|
||||
test("resolves one-mode documents with defaults for the available mode", () => {
|
||||
const resolvedLight = resolveSource({ version: 2, light: {} }, "dark")
|
||||
const resolvedDark = resolveSource({ version: 2, dark: {} }, "light")
|
||||
|
||||
expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
|
||||
expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
|
||||
|
|
@ -18,26 +23,19 @@ test("resolves one-mode files with defaults for the available mode", () => {
|
|||
expect(resolvedDark.categorical.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
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("rejects theme documents without a mode", () => {
|
||||
expect(() => resolveSource({ version: 2 })).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("validates and resolves categorical hues in configured order", () => {
|
||||
const theme = resolveThemeFile({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light")
|
||||
const theme = resolveSource({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light")
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.accent)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.red)
|
||||
expect(theme.categorical[2]).toBe(theme.hue.interactive)
|
||||
expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical)
|
||||
expect(() => resolveThemeFile({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
|
||||
expect(() =>
|
||||
resolveThemeFile(
|
||||
// @ts-expect-error Runtime decoding rejects unknown categorical hue names.
|
||||
{ version: 2, light: { categorical: ["magenta"] } },
|
||||
"light",
|
||||
),
|
||||
).toThrow("Invalid theme")
|
||||
expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
|
||||
expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("uses the default categorical order for direct definitions", () => {
|
||||
|
|
@ -93,7 +91,7 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
|||
...light,
|
||||
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
||||
})
|
||||
const overridden = resolveThemeFile({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
|
||||
const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
|
||||
|
||||
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
|
||||
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
|
||||
|
|
@ -131,8 +129,8 @@ test("steps by hue source when adjacent colors have equal values", () => {
|
|||
expect(theme.increase(theme.hue.neutral[300])).toBe(theme.hue.neutral[400])
|
||||
})
|
||||
|
||||
test("merges partial files with the selected OpenCode defaults", () => {
|
||||
const theme = resolveThemeFile(
|
||||
test("merges partial documents with the selected OpenCode defaults", () => {
|
||||
const theme = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
|
|
@ -150,7 +148,7 @@ test("merges partial files with the selected OpenCode defaults", () => {
|
|||
})
|
||||
|
||||
test("expands user structural fallbacks before merging defaults", () => {
|
||||
const expanded = resolveThemeFile(
|
||||
const expanded = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
|
|
@ -161,7 +159,7 @@ test("expands user structural fallbacks before merging defaults", () => {
|
|||
},
|
||||
"light",
|
||||
)
|
||||
const isolatedState = resolveThemeFile(
|
||||
const isolatedState = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: {
|
||||
|
|
@ -181,9 +179,9 @@ test("expands user structural fallbacks before merging defaults", () => {
|
|||
})
|
||||
|
||||
test("standalone themes skip OpenCode defaults and use the red core fallback", () => {
|
||||
const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const
|
||||
const lightTheme = resolveThemeFile(file, "light")
|
||||
const darkTheme = resolveThemeFile(file, "dark")
|
||||
const document = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const
|
||||
const lightTheme = resolveSource(document, "light")
|
||||
const darkTheme = resolveSource(document, "dark")
|
||||
|
||||
expect(lightTheme.text.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
expect(lightTheme.background.default.toInts()).toEqual([255, 0, 0, 255])
|
||||
|
|
@ -192,7 +190,7 @@ test("standalone themes skip OpenCode defaults and use the red core fallback", (
|
|||
})
|
||||
|
||||
test("uses defaults for the selected mode when it merges the other mode", () => {
|
||||
const theme = resolveThemeFile(
|
||||
const theme = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: { hue: light.hue, background: { default: "#123456" } },
|
||||
|
|
@ -217,7 +215,7 @@ test("resolves matched action variants and states", () => {
|
|||
})
|
||||
|
||||
test("resolves elevated hover surfaces from direct colors", () => {
|
||||
const theme = resolveThemeFile(
|
||||
const theme = resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: { background: { surface: { offset: "#123456", overlay: "#234567" } } },
|
||||
|
|
@ -231,7 +229,7 @@ test("resolves elevated hover surfaces from direct colors", () => {
|
|||
})
|
||||
|
||||
test("resolves transparent colors", () => {
|
||||
const theme = resolveThemeFile({
|
||||
const theme = resolveSource({
|
||||
version: 2,
|
||||
light: { background: { formfield: { default: "transparent" } } },
|
||||
dark: { background: { formfield: { default: "transparent" } } },
|
||||
|
|
@ -241,7 +239,7 @@ test("resolves transparent colors", () => {
|
|||
|
||||
test("reports theme decoding failures as native errors", () => {
|
||||
expect(() =>
|
||||
resolveThemeFile(
|
||||
resolveSource(
|
||||
{
|
||||
version: 2,
|
||||
light: { text: { default: "opaque" } },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
|
||||
import type { HueDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2"
|
||||
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
|
||||
|
||||
const hue = {} as HueDefinition
|
||||
|
|
@ -11,20 +11,20 @@ const dark = {
|
|||
} satisfies ThemeDefinition
|
||||
|
||||
test("requires and selects independent light and dark themes", () => {
|
||||
const file = { version: 2, light, dark } satisfies ThemeFile
|
||||
expect(selectTheme(file)).toBe(light)
|
||||
expect(selectTheme(file, "light")).toBe(light)
|
||||
expect(selectTheme(file, "dark")).toBe(dark)
|
||||
expect(selectThemeMode(file, "dark").mode).toBe("dark")
|
||||
const document = { version: 2, light, dark } satisfies ThemeDocument
|
||||
expect(selectTheme(document)).toBe(light)
|
||||
expect(selectTheme(document, "light")).toBe(light)
|
||||
expect(selectTheme(document, "dark")).toBe(dark)
|
||||
expect(selectThemeMode(document, "dark").mode).toBe("dark")
|
||||
})
|
||||
|
||||
test("merges an expanded mode override over the other mode", () => {
|
||||
const file = {
|
||||
const document = {
|
||||
version: 2,
|
||||
light,
|
||||
dark: { mergeMode: true, text: { default: "#ffffff" } },
|
||||
} satisfies ThemeFile
|
||||
const selected = selectTheme(file, "dark")
|
||||
} satisfies ThemeDocument
|
||||
const selected = selectTheme(document, "dark")
|
||||
|
||||
expect(selected.hue).toBeDefined()
|
||||
expect(selected.text?.default).toBe("#ffffff")
|
||||
|
|
@ -41,8 +41,8 @@ test("replaces categorical order in a merge mode", () => {
|
|||
})
|
||||
|
||||
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
|
||||
const lightOnly = { version: 2, light } satisfies ThemeDocument
|
||||
const darkOnly = { version: 2, dark } satisfies ThemeDocument
|
||||
|
||||
expect(themeModes(lightOnly)).toEqual(["light"])
|
||||
expect(themeModes(darkOnly)).toEqual(["dark"])
|
||||
|
|
@ -62,10 +62,10 @@ test("rejects a merge mode without its base mode", () => {
|
|||
})
|
||||
|
||||
test("rejects mutual mode merging", () => {
|
||||
const file = {
|
||||
const document = {
|
||||
version: 2,
|
||||
light: { mergeMode: true },
|
||||
dark: { mergeMode: true },
|
||||
} satisfies ThemeFile
|
||||
expect(() => selectTheme(file)).toThrow("cannot both merge")
|
||||
} satisfies ThemeDocument
|
||||
expect(() => selectTheme(document)).toThrow("cannot both merge")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
|
||||
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2"
|
||||
|
||||
const text = {
|
||||
default: "$hue.neutral.900",
|
||||
|
|
@ -51,11 +51,11 @@ const definition = {
|
|||
"@context:overlay": { background: { default: "$hue.neutral.300" } },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
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
|
||||
const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument
|
||||
const lightOnly = { version: 2, light: definition } satisfies ThemeDocument
|
||||
const darkOnly = { version: 2, dark: definition } satisfies ThemeDocument
|
||||
// @ts-expect-error A theme document must provide at least one mode.
|
||||
const empty = { version: 2 } satisfies ThemeDocument
|
||||
|
||||
test("supports property-first definitions, variants, states, and contexts", () => {
|
||||
expect(text.action.primary.$hovered).toBe("$hue.neutral.200")
|
||||
|
|
@ -68,7 +68,7 @@ test("supports property-first definitions, variants, states, and contexts", () =
|
|||
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
|
||||
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
|
||||
expect(definition.categorical).toEqual(["blue", "accent"])
|
||||
expect(file.light).toBe(definition)
|
||||
expect(document.light).toBe(definition)
|
||||
expect(lightOnly.light).toBe(definition)
|
||||
expect(darkOnly.dark).toBe(definition)
|
||||
expect(empty.version).toBe(2)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
|
||||
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
|
||||
import { resolveThemeDocument } from "../../../src/theme/v2/resolve"
|
||||
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"
|
||||
|
|
@ -9,7 +9,7 @@ test("migrates resolved V1 modes into literal 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")
|
||||
const resolved = resolveThemeFile(migrated, "light")
|
||||
const resolved = resolveThemeDocument(migrated, "light")
|
||||
|
||||
expect(migrated.standalone).toBeTrue()
|
||||
expect(migrated.light.categorical?.length).toBeGreaterThan(0)
|
||||
|
|
@ -83,8 +83,8 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
|||
expect(migrated.light.hue?.purple).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.accent).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.interactive).toBe("$hue.gray")
|
||||
expect(() => resolveThemeFile(migrated, "light")).not.toThrow()
|
||||
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
|
||||
expect(() => resolveThemeDocument(migrated, "light")).not.toThrow()
|
||||
expect(() => resolveThemeDocument(migrated, "dark")).not.toThrow()
|
||||
})
|
||||
|
||||
test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
|
|
@ -185,7 +185,7 @@ 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(resolveThemeFile(migrated, mode).text.default).toBeDefined()
|
||||
expect(resolveThemeDocument(migrated, mode).text.default).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue