mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(theme): register native V2 opencode theme (#49846)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import type { HueName } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = ["blue", "purple", "green", "orange", "red", "cyan"] as const satisfies readonly HueName[]
|
||||
@@ -1,452 +0,0 @@
|
||||
import type { BaseThemeDefinition, HueName, Mode, ThemeDefinition, ThemeDocument } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
"purple",
|
||||
"green",
|
||||
"orange",
|
||||
"red",
|
||||
"cyan",
|
||||
] as const satisfies readonly HueName[]
|
||||
|
||||
const modes = {
|
||||
light: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#111827",
|
||||
200: "#1f2937",
|
||||
300: "#374151",
|
||||
400: "#4b5563",
|
||||
500: "#6b7280",
|
||||
600: "#9ca3af",
|
||||
700: "#d1d5db",
|
||||
800: "#e5e7eb",
|
||||
900: "#f3f4f6",
|
||||
},
|
||||
red: {
|
||||
100: "#7f1d1d",
|
||||
200: "#991b1b",
|
||||
300: "#b91c1c",
|
||||
400: "#dc2626",
|
||||
500: "#ef4444",
|
||||
600: "#f87171",
|
||||
700: "#fca5a5",
|
||||
800: "#fecaca",
|
||||
900: "#fee2e2",
|
||||
},
|
||||
orange: {
|
||||
100: "#7c2d12",
|
||||
200: "#9a3412",
|
||||
300: "#c2410c",
|
||||
400: "#ea580c",
|
||||
500: "#f97316",
|
||||
600: "#fb923c",
|
||||
700: "#fdba74",
|
||||
800: "#fed7aa",
|
||||
900: "#ffedd5",
|
||||
},
|
||||
yellow: {
|
||||
100: "#713f12",
|
||||
200: "#854d0e",
|
||||
300: "#a16207",
|
||||
400: "#ca8a04",
|
||||
500: "#eab308",
|
||||
600: "#facc15",
|
||||
700: "#fde047",
|
||||
800: "#fef08a",
|
||||
900: "#fef9c3",
|
||||
},
|
||||
green: {
|
||||
100: "#14532d",
|
||||
200: "#166534",
|
||||
300: "#15803d",
|
||||
400: "#16a34a",
|
||||
500: "#22c55e",
|
||||
600: "#4ade80",
|
||||
700: "#86efac",
|
||||
800: "#bbf7d0",
|
||||
900: "#dcfce7",
|
||||
},
|
||||
cyan: {
|
||||
100: "#164e63",
|
||||
200: "#155e75",
|
||||
300: "#0e7490",
|
||||
400: "#0891b2",
|
||||
500: "#06b6d4",
|
||||
600: "#22d3ee",
|
||||
700: "#67e8f9",
|
||||
800: "#a5f3fc",
|
||||
900: "#cffafe",
|
||||
},
|
||||
blue: {
|
||||
100: "#1e3a8a",
|
||||
200: "#1e40af",
|
||||
300: "#1d4ed8",
|
||||
400: "#2563eb",
|
||||
500: "#3b82f6",
|
||||
600: "#60a5fa",
|
||||
700: "#93c5fd",
|
||||
800: "#bfdbfe",
|
||||
900: "#dbeafe",
|
||||
},
|
||||
purple: {
|
||||
100: "#581c87",
|
||||
200: "#6b21a8",
|
||||
300: "#7e22ce",
|
||||
400: "#9333ea",
|
||||
500: "#a855f7",
|
||||
600: "#c084fc",
|
||||
700: "#d8b4fe",
|
||||
800: "#e9d5ff",
|
||||
900: "#f3e8ff",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
base: "$hue.neutral.200",
|
||||
muted: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { base: "$hue.neutral.800", $disabled: "$hue.neutral.500" },
|
||||
secondary: { base: "$text.muted", $hovered: "$text.base" },
|
||||
destructive: { base: "$hue.red.800", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
base: "$hue.neutral.200",
|
||||
$focused: "$text.action.primary.base",
|
||||
$pressed: "$hue.neutral.800",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.300",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
error: { base: "$hue.red.300", muted: "$hue.red.400" },
|
||||
warning: { base: "$hue.yellow.200", muted: "$hue.yellow.300" },
|
||||
success: { base: "$hue.green.300", muted: "$hue.green.400" },
|
||||
info: { base: "$hue.cyan.300", muted: "$hue.cyan.400" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
base: "$hue.neutral.800",
|
||||
raised: {
|
||||
base: "$hue.neutral.700",
|
||||
high: "$hue.neutral.600",
|
||||
max: "$hue.neutral.500",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
base: "$hue.interactive.400",
|
||||
$hovered: "$hue.interactive.300",
|
||||
$focused: "$hue.interactive.300",
|
||||
$pressed: "$hue.interactive.200",
|
||||
$selected: "$hue.interactive.300",
|
||||
$disabled: "$hue.neutral.700",
|
||||
},
|
||||
secondary: { base: "transparent" },
|
||||
destructive: {
|
||||
base: "$hue.red.400",
|
||||
$hovered: "$hue.red.300",
|
||||
$focused: "$hue.red.300",
|
||||
$pressed: "$hue.red.200",
|
||||
$selected: "$hue.red.300",
|
||||
$disabled: "$hue.neutral.700",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
base: "$background.base",
|
||||
$hovered: "$background.raised.base",
|
||||
$focused: "$background.action.primary.base",
|
||||
$pressed: "$hue.interactive.200",
|
||||
$disabled: "$background.base",
|
||||
$selected: "$background.formfield.base",
|
||||
},
|
||||
feedback: {
|
||||
error: { base: "$background.base" },
|
||||
warning: { base: "$background.base" },
|
||||
success: { base: "$background.base" },
|
||||
info: { base: "$background.base" },
|
||||
},
|
||||
},
|
||||
border: { base: "$hue.neutral.700" },
|
||||
scrollbar: { base: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.300",
|
||||
removed: "$hue.red.300",
|
||||
context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.400",
|
||||
keyword: "$hue.purple.400",
|
||||
function: "$hue.accent.400",
|
||||
variable: "$hue.neutral.100",
|
||||
string: "$hue.green.300",
|
||||
number: "$hue.yellow.200",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.100",
|
||||
heading: "$hue.purple.400",
|
||||
link: "$hue.accent.400",
|
||||
linkText: "$hue.cyan.400",
|
||||
code: "$hue.green.300",
|
||||
blockQuote: "$hue.neutral.400",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.100",
|
||||
horizontalRule: "$hue.neutral.700",
|
||||
listItem: "$hue.accent.400",
|
||||
listEnumeration: "$hue.cyan.400",
|
||||
image: "$hue.accent.400",
|
||||
imageText: "$hue.cyan.400",
|
||||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
"@dialog": {
|
||||
text: { action: { primary: { base: "$hue.neutral.900" } } },
|
||||
background: {
|
||||
base: "$background.raised.base",
|
||||
action: { primary: { base: "$hue.interactive.500", $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
base: "$hue.neutral.200",
|
||||
muted: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { base: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
secondary: { base: "$text.muted", $hovered: "$text.base" },
|
||||
destructive: { base: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
base: "$hue.neutral.200",
|
||||
$focused: "$text.action.primary.base",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
error: { base: "$hue.red.300", muted: "$hue.red.400" },
|
||||
warning: { base: "$hue.yellow.200", muted: "$hue.yellow.300" },
|
||||
success: { base: "$hue.green.300", muted: "$hue.green.400" },
|
||||
info: { base: "$hue.cyan.300", muted: "$hue.cyan.400" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
base: "$hue.neutral.800",
|
||||
raised: {
|
||||
base: "$hue.neutral.700",
|
||||
high: "$hue.neutral.600",
|
||||
max: "$hue.neutral.500",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
base: "$hue.interactive.500",
|
||||
$hovered: "$hue.interactive.600",
|
||||
$focused: "$hue.interactive.600",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$selected: "$hue.interactive.600",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
secondary: { base: "transparent" },
|
||||
destructive: {
|
||||
base: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
base: "$background.base",
|
||||
$hovered: "$background.raised.base",
|
||||
$focused: "$background.action.primary.base",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.base",
|
||||
$selected: "$background.formfield.base",
|
||||
},
|
||||
feedback: {
|
||||
error: { base: "$background.base" },
|
||||
warning: { base: "$background.base" },
|
||||
success: { base: "$background.base" },
|
||||
info: { base: "$background.base" },
|
||||
},
|
||||
},
|
||||
border: { base: "$hue.neutral.700" },
|
||||
scrollbar: { base: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.300",
|
||||
removed: "$hue.red.300",
|
||||
context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.400",
|
||||
keyword: "$hue.purple.400",
|
||||
function: "$hue.accent.400",
|
||||
variable: "$hue.neutral.100",
|
||||
string: "$hue.green.300",
|
||||
number: "$hue.yellow.200",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.100",
|
||||
heading: "$hue.purple.400",
|
||||
link: "$hue.accent.400",
|
||||
linkText: "$hue.cyan.400",
|
||||
code: "$hue.green.300",
|
||||
blockQuote: "$hue.neutral.400",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.100",
|
||||
horizontalRule: "$hue.neutral.700",
|
||||
listItem: "$hue.accent.400",
|
||||
listEnumeration: "$hue.cyan.400",
|
||||
image: "$hue.accent.400",
|
||||
imageText: "$hue.cyan.400",
|
||||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
"@dialog": {
|
||||
text: { action: { primary: { base: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
base: "$background.raised.base",
|
||||
action: { primary: { base: "$hue.interactive.400", $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<Mode, ThemeDefinition>
|
||||
|
||||
const { hue: _, ...base } = modes.light
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
base: base satisfies BaseThemeDefinition,
|
||||
light: { hue: modes.light.hue },
|
||||
dark: modes.dark,
|
||||
} satisfies ThemeDocument
|
||||
@@ -42,7 +42,7 @@ export type {
|
||||
ResolvedThemeTokens,
|
||||
StatefulColor,
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
export { DEFAULT_CATEGORICAL } from "./categorical.js"
|
||||
export { expandTheme } from "./expand.js"
|
||||
export { migrateV1 } from "./v1-migrate.js"
|
||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
|
||||
@@ -93,14 +93,6 @@ const TextDefinition = Schema.Struct({
|
||||
muted: Schema.optional(ColorValue),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
formfield: Schema.optional(StatefulColorDefinition),
|
||||
status: Schema.optional(
|
||||
Schema.Struct({
|
||||
running: Schema.optional(ColorValue),
|
||||
question: Schema.optional(ColorValue),
|
||||
permission: Schema.optional(ColorValue),
|
||||
unread: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(TextFeedbackDefinition),
|
||||
@@ -234,12 +226,6 @@ const CompleteThemeTokensDefinition = Schema.Struct({
|
||||
muted: ColorValue,
|
||||
action: CompleteActionColorDefinition,
|
||||
formfield: CompleteStatefulColorDefinition,
|
||||
status: Schema.Struct({
|
||||
running: ColorValue,
|
||||
question: ColorValue,
|
||||
permission: ColorValue,
|
||||
unread: ColorValue,
|
||||
}),
|
||||
feedback: Schema.Struct({
|
||||
error: CompleteTextFeedbackDefinition,
|
||||
warning: CompleteTextFeedbackDefinition,
|
||||
|
||||
@@ -34,12 +34,6 @@ export type ResolvedThemeTokens = {
|
||||
readonly muted: RGBA
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly formfield: FormfieldColor
|
||||
readonly status: {
|
||||
readonly running: RGBA
|
||||
readonly question: RGBA
|
||||
readonly permission: RGBA
|
||||
readonly unread: RGBA
|
||||
}
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly base: RGBA; readonly muted: RGBA }>>
|
||||
}
|
||||
readonly background: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "./color.js"
|
||||
import { DEFAULT_CATEGORICAL } from "./defaults.js"
|
||||
import { DEFAULT_CATEGORICAL } from "./categorical.js"
|
||||
import type { BaseThemeDefinition, HueDefinition, Mode, ThemeDefinition, ThemeDocument } from "./index.js"
|
||||
import { HueStep } from "./schema.js"
|
||||
import type { Theme, ThemeV1Json } from "./v1.js"
|
||||
@@ -130,12 +130,6 @@ function migrateMode(theme: Theme, mode: Mode): ThemeDefinition {
|
||||
$disabled: textMuted,
|
||||
$selected: primary,
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
error: { base: color("error") },
|
||||
warning: { base: color("warning") },
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument, selectTheme } from "../src/tui/index.js"
|
||||
import type { ThemeV1Json } from "../src/tui/v1.js"
|
||||
|
||||
test.each(["light", "dark"] as const)("built-in %s themes resolve status colors", async (mode) => {
|
||||
const source: ThemeV1Json = await Bun.file(
|
||||
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
|
||||
).json()
|
||||
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
|
||||
const theme = resolveThemeDocument(document, mode)
|
||||
expect(theme.text.status.running.equals(theme.hue.interactive[200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.accent[200])).toBeTrue()
|
||||
expect(theme.surface("dialog").text.status).toEqual(theme.text.status)
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("custom %s themes inherit the unread attention color", (mode) => {
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const definition = {
|
||||
...base,
|
||||
hue: { ...base.hue, accent: "$hue.purple" },
|
||||
text: {
|
||||
...base.text,
|
||||
status: { ...base.text.status, unread: "#abcdef" },
|
||||
},
|
||||
}
|
||||
const { hue, ...tokens } = definition
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
base: tokens,
|
||||
[mode]: { hue },
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.unread.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.status.unread)).toBeTrue()
|
||||
expect(theme.surface("dialog").text.status).toEqual(theme.text.status)
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const definition = {
|
||||
...base,
|
||||
hue: { ...base.hue, interactive: "$hue.purple", accent: "$hue.orange" },
|
||||
text: {
|
||||
...base.text,
|
||||
status: {
|
||||
...base.text.status,
|
||||
question: "#123456",
|
||||
permission: "#654321",
|
||||
},
|
||||
},
|
||||
}
|
||||
const { hue, ...tokens } = definition
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
base: tokens,
|
||||
[mode]: { hue },
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.running.equals(theme.hue.purple[200])).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.orange[200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(RGBA.fromHex("#654321"))).toBeTrue()
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { migrateV1, resolveThemeDocument } from "../src/tui/index.js"
|
||||
import type { ThemeV1Json } from "../src/tui/v1.js"
|
||||
|
||||
const source: ThemeV1Json = await Bun.file(
|
||||
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
|
||||
).json()
|
||||
const document = migrateV1(source)
|
||||
|
||||
test.each(["light", "dark"] as const)("resolves %s themes without status tokens", (mode) => {
|
||||
const theme = resolveThemeDocument(document, mode)
|
||||
expect("status" in theme.text).toBeFalse()
|
||||
expect(theme.hue.accent[800]).toBeDefined()
|
||||
expect(theme.hue.interactive[800]).toBeDefined()
|
||||
})
|
||||
@@ -120,7 +120,7 @@ const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, lev
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
|
||||
|
||||
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
|
||||
if (status.attention) return theme.text.status[status.attention]
|
||||
if (status.attention) return theme.hue.interactive[800]
|
||||
if (status.unread === "error") return theme.text.feedback.error.base
|
||||
return undefined
|
||||
}
|
||||
@@ -528,8 +528,8 @@ function VerticalSessionTabs(props: {
|
||||
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
|
||||
const tooltipWidth = () => Math.min(54, dimensions().width - width())
|
||||
const stride = () => (compact() ? 2 : 3)
|
||||
const unreadColor = () => theme.text.status.unread
|
||||
const activeNumber = () => theme.text.status.running
|
||||
const unreadColor = () => theme.hue.interactive[800]
|
||||
const activeNumber = () => theme.hue.accent[800]
|
||||
const idleNumber = () => tint(theme.text.formfield.base, background(), 0.55)
|
||||
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.base, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.base, 0.05))
|
||||
@@ -1291,8 +1291,8 @@ function HorizontalSessionTabs(props: {
|
||||
onCleanup(clearCloseHold)
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
const unreadColor = () => theme.text.status.unread
|
||||
const activeNumber = () => theme.text.status.running
|
||||
const unreadColor = () => theme.hue.interactive[800]
|
||||
const activeNumber = () => theme.hue.accent[800]
|
||||
const idleNumber = () => tint(theme.text.formfield.base, theme.background.base, 0.55)
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
|
||||
@@ -32,7 +32,7 @@ export default Plugin.define({
|
||||
return (
|
||||
<Show when={pending() > 0}>
|
||||
<box flexShrink={0}>
|
||||
<Spinner color={theme.text.status.running}>/btw</Spinner>
|
||||
<Spinner color={theme.hue.accent[800]}>/btw</Spinner>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -181,7 +181,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.text.status.running}
|
||||
color={theme.hue.accent[800]}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
@@ -236,7 +236,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.text.status.running}
|
||||
color={theme.hue.accent[800]}
|
||||
/>
|
||||
<text fg={theme.text.base}> esc stop</text>
|
||||
</box>
|
||||
|
||||
@@ -284,10 +284,10 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
{ text: "", color: theme.text.base },
|
||||
)
|
||||
if (status.attention === "question")
|
||||
lines.push({ text: "? Which approach should I take?", color: theme.text.status.question })
|
||||
lines.push({ text: "? Which approach should I take?", color: theme.hue.interactive[800] })
|
||||
else if (status.attention === "permission")
|
||||
lines.push({ text: "! Waiting for permission to run the command", color: theme.text.status.permission })
|
||||
else if (status.busy) lines.push({ text: "● Working…", color: theme.text.status.running })
|
||||
lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.interactive[800] })
|
||||
else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.accent[800] })
|
||||
else if (outcome === "failed")
|
||||
lines.push({
|
||||
text: `✗ bun run test failed — 3 tests failing in ${file}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
|
||||
import { generateSyntax, resolveThemeDocument, themeModes, type ResolvedTheme } from "@opencode/theme/tui"
|
||||
import { allThemes, DEFAULT_THEMES, isThemeSource, parseTheme, type ThemeDocumentSource } from "../theme"
|
||||
import { allThemes, getOpenCodeTheme, isThemeSource, parseTheme, type ThemeDocumentSource } from "../theme"
|
||||
import { ansiToRgba } from "../theme/color"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
@@ -142,9 +142,9 @@ function map(
|
||||
formfieldFocusedBg: exact(theme.background.formfield.focused),
|
||||
formfieldFocusedText: exact(theme.text.formfield.focused),
|
||||
selection: exact(theme.text.formfield.selected),
|
||||
running: exact(theme.text.status.running),
|
||||
question: exact(theme.text.status.question),
|
||||
permission: exact(theme.text.status.permission),
|
||||
running: exact(theme.hue.accent[800]),
|
||||
question: exact(theme.hue.interactive[800]),
|
||||
permission: exact(theme.hue.interactive[800]),
|
||||
success: exact(theme.text.feedback.success.base),
|
||||
link: exact(theme.markdown.link),
|
||||
categorical: dedupeWith(
|
||||
@@ -192,11 +192,11 @@ function map(
|
||||
}
|
||||
|
||||
export const RUN_THEME_FALLBACK = map(
|
||||
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "dark"),
|
||||
resolveThemeDocument(parseTheme(getOpenCodeTheme()), "dark"),
|
||||
ansiPalette,
|
||||
)
|
||||
export const RUN_THEME_FALLBACK_LIGHT = map(
|
||||
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "light"),
|
||||
resolveThemeDocument(parseTheme(getOpenCodeTheme()), "light"),
|
||||
ansiPalette,
|
||||
)
|
||||
|
||||
@@ -292,7 +292,7 @@ export async function resolveRunTheme(
|
||||
if (themeModes(document).includes(mode)) return resolveThemeDocument(document, mode)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const theme = resolved ?? resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
|
||||
const theme = resolved ?? resolveThemeDocument(parseTheme(getOpenCodeTheme()), mode)
|
||||
const indexed = colors
|
||||
? ansiPalette.map((color, index) => (colors.palette[index] ? RGBA.fromIndex(index, colors.palette[index]!) : color))
|
||||
: ansiPalette
|
||||
@@ -312,6 +312,6 @@ async function themeSource(
|
||||
const custom = await discoverThemes(
|
||||
configDirectories(process.env.OPENCODE_CONFIG_DIR ?? Global.Path.config, process.cwd()),
|
||||
)
|
||||
const source = custom[name] ?? allThemes()[name] ?? DEFAULT_THEMES.opencode
|
||||
return isThemeSource(source) ? source : DEFAULT_THEMES.opencode
|
||||
const source = custom[name] ?? allThemes()[name] ?? getOpenCodeTheme()
|
||||
return isThemeSource(source) ? source : getOpenCodeTheme()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/theme.json",
|
||||
"version": 2,
|
||||
"base": {
|
||||
"categorical": [
|
||||
"purple",
|
||||
"orange",
|
||||
"green",
|
||||
"blue",
|
||||
"red"
|
||||
],
|
||||
"text": {
|
||||
"base": "$hue.neutral.200",
|
||||
"muted": "$hue.neutral.400",
|
||||
"action": {
|
||||
"primary": {
|
||||
"base": "$text.base",
|
||||
"$disabled": "$hue.neutral.400",
|
||||
"$focused": "$hue.neutral.900",
|
||||
"$selected": "$hue.interactive.200"
|
||||
},
|
||||
"secondary": {
|
||||
"base": "$text.muted",
|
||||
"$hovered": "$text.base"
|
||||
},
|
||||
"destructive": {
|
||||
"base": "$hue.neutral.900",
|
||||
"$disabled": "$hue.neutral.400"
|
||||
}
|
||||
},
|
||||
"formfield": {
|
||||
"base": "$hue.neutral.200",
|
||||
"$hovered": "$hue.interactive.200",
|
||||
"$focused": "$hue.interactive.200",
|
||||
"$pressed": "$hue.interactive.200",
|
||||
"$disabled": "$hue.neutral.400",
|
||||
"$selected": "$hue.interactive.200"
|
||||
},
|
||||
"feedback": {
|
||||
"error": {
|
||||
"base": "$hue.red.200"
|
||||
},
|
||||
"warning": {
|
||||
"base": "$hue.accent.200"
|
||||
},
|
||||
"success": {
|
||||
"base": "$hue.green.200"
|
||||
},
|
||||
"info": {
|
||||
"base": "$hue.cyan.200"
|
||||
}
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"base": "$hue.neutral.800",
|
||||
"raised": {
|
||||
"base": "$hue.neutral.700",
|
||||
"high": "$hue.neutral.600",
|
||||
"max": "$hue.neutral.500"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"base": "transparent",
|
||||
"$hovered": "$hue.neutral.700",
|
||||
"$focused": "$hue.interactive.200",
|
||||
"$selected": "transparent"
|
||||
},
|
||||
"secondary": {
|
||||
"base": "transparent"
|
||||
},
|
||||
"destructive": {
|
||||
"base": "$hue.red.200"
|
||||
}
|
||||
},
|
||||
"formfield": {
|
||||
"base": "$background.base"
|
||||
},
|
||||
"feedback": {
|
||||
"error": {
|
||||
"base": "$background.base"
|
||||
},
|
||||
"warning": {
|
||||
"base": "$background.base"
|
||||
},
|
||||
"success": {
|
||||
"base": "$background.base"
|
||||
},
|
||||
"info": {
|
||||
"base": "$background.base"
|
||||
}
|
||||
}
|
||||
},
|
||||
"border": {
|
||||
"base": "#b8b8b8"
|
||||
},
|
||||
"scrollbar": {
|
||||
"base": "#a0a0a0"
|
||||
},
|
||||
"diff": {
|
||||
"text": {
|
||||
"added": "#1e725c",
|
||||
"removed": "#c53b53",
|
||||
"context": "#7086b5",
|
||||
"hunkHeader": "#7086b5"
|
||||
},
|
||||
"background": {
|
||||
"added": "#d5e5d5",
|
||||
"removed": "#f7d8db",
|
||||
"context": "$hue.neutral.700"
|
||||
},
|
||||
"highlight": {
|
||||
"added": "#4db380",
|
||||
"removed": "#f52a65"
|
||||
},
|
||||
"lineNumber": {
|
||||
"text": "#595959",
|
||||
"background": {
|
||||
"added": "#c5d5c5",
|
||||
"removed": "#e7c8cb"
|
||||
}
|
||||
}
|
||||
},
|
||||
"syntax": {
|
||||
"comment": "$hue.neutral.400",
|
||||
"keyword": "$hue.accent.200",
|
||||
"function": "$hue.interactive.200",
|
||||
"variable": "$hue.red.200",
|
||||
"string": "$hue.green.200",
|
||||
"number": "$hue.accent.200",
|
||||
"type": "#b0851f",
|
||||
"operator": "$hue.cyan.200",
|
||||
"punctuation": "$hue.neutral.200"
|
||||
},
|
||||
"markdown": {
|
||||
"text": "$hue.neutral.200",
|
||||
"heading": "$hue.accent.200",
|
||||
"link": "$hue.interactive.200",
|
||||
"linkText": "$hue.cyan.200",
|
||||
"code": "$hue.green.200",
|
||||
"blockQuote": "#b0851f",
|
||||
"emphasis": "#b0851f",
|
||||
"strong": "$hue.accent.200",
|
||||
"horizontalRule": "$hue.neutral.400",
|
||||
"listItem": "$hue.interactive.200",
|
||||
"listEnumeration": "$hue.cyan.200",
|
||||
"image": "$hue.interactive.200",
|
||||
"imageText": "$hue.cyan.200",
|
||||
"codeBlock": "$hue.neutral.200"
|
||||
},
|
||||
"@dialog": {
|
||||
"background": {
|
||||
"base": "$background.raised.base",
|
||||
"action": {
|
||||
"primary": {
|
||||
"$hovered": "$background.raised.high"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"hue": {
|
||||
"gray": {
|
||||
"100": "#000000",
|
||||
"200": "#1a1a1a",
|
||||
"300": "#4e4e4e",
|
||||
"400": "#8a8a8a",
|
||||
"500": "#bebebe",
|
||||
"600": "#f5f5f5",
|
||||
"700": "#fafafa",
|
||||
"800": "#ffffff",
|
||||
"900": "#ffffff"
|
||||
},
|
||||
"red": {
|
||||
"100": "#c20f26",
|
||||
"200": "#d1383d",
|
||||
"300": "#e05352",
|
||||
"400": "#ee6b68",
|
||||
"500": "#fc837d",
|
||||
"600": "#fda19b",
|
||||
"700": "#fcbeb9",
|
||||
"800": "#fed8d5",
|
||||
"900": "#fff2f0"
|
||||
},
|
||||
"orange": {
|
||||
"100": "#c7811e",
|
||||
"200": "#d68c27",
|
||||
"300": "#df9a44",
|
||||
"400": "#e8a85c",
|
||||
"500": "#f1b671",
|
||||
"600": "#fac386",
|
||||
"700": "#fcd4a8",
|
||||
"800": "#fee3c7",
|
||||
"900": "#fef3e7"
|
||||
},
|
||||
"yellow": "$hue.gray",
|
||||
"green": {
|
||||
"100": "#1f8c44",
|
||||
"200": "#3d9a57",
|
||||
"300": "#55a96a",
|
||||
"400": "#6cb77c",
|
||||
"500": "#81c68f",
|
||||
"600": "#96d5a3",
|
||||
"700": "#ace4b6",
|
||||
"800": "#c1f3ca",
|
||||
"900": "#e1fee6"
|
||||
},
|
||||
"cyan": {
|
||||
"100": "#077786",
|
||||
"200": "#318795",
|
||||
"300": "#4c97a4",
|
||||
"400": "#64a8b4",
|
||||
"500": "#7bb8c4",
|
||||
"600": "#92c9d4",
|
||||
"700": "#a8dbe4",
|
||||
"800": "#bfecf4",
|
||||
"900": "#e3fafe"
|
||||
},
|
||||
"blue": {
|
||||
"100": "#226bcc",
|
||||
"200": "#3b7dd8",
|
||||
"300": "#518ee4",
|
||||
"400": "#67a0f0",
|
||||
"500": "#7db1fc",
|
||||
"600": "#9cc3fa",
|
||||
"700": "#b7d4fd",
|
||||
"800": "#d4e5fd",
|
||||
"900": "#f0f6fe"
|
||||
},
|
||||
"purple": {
|
||||
"100": "#6b47a7",
|
||||
"200": "#7b5bb6",
|
||||
"300": "#8c6fc5",
|
||||
"400": "#9d83d4",
|
||||
"500": "#af98e3",
|
||||
"600": "#c1acf2",
|
||||
"700": "#d3c3fc",
|
||||
"800": "#e4dbfd",
|
||||
"900": "#f6f3ff"
|
||||
},
|
||||
"accent": "$hue.orange",
|
||||
"interactive": "$hue.blue",
|
||||
"neutral": "$hue.gray"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"hue": {
|
||||
"gray": {
|
||||
"100": "#ffffff",
|
||||
"200": "#eeeeee",
|
||||
"300": "#b5b5b5",
|
||||
"400": "#808080",
|
||||
"500": "#4c4c4c",
|
||||
"600": "#1e1e1e",
|
||||
"700": "#141414",
|
||||
"800": "#0a0a0a",
|
||||
"900": "#030303"
|
||||
},
|
||||
"red": {
|
||||
"100": "#fd7e87",
|
||||
"200": "#e06c75",
|
||||
"300": "#c35a63",
|
||||
"400": "#a74952",
|
||||
"500": "#8c3941",
|
||||
"600": "#722931",
|
||||
"700": "#591921",
|
||||
"800": "#410a13",
|
||||
"900": "#280207"
|
||||
},
|
||||
"orange": {
|
||||
"100": "#fddac5",
|
||||
"200": "#fab283",
|
||||
"300": "#d8976c",
|
||||
"400": "#b67c56",
|
||||
"500": "#966341",
|
||||
"600": "#774a2c",
|
||||
"700": "#593319",
|
||||
"800": "#3d1d06",
|
||||
"900": "#1f0b01"
|
||||
},
|
||||
"yellow": "$hue.gray",
|
||||
"green": {
|
||||
"100": "#96f7a7",
|
||||
"200": "#7fd88f",
|
||||
"300": "#68b977",
|
||||
"400": "#539c61",
|
||||
"500": "#3d7f4b",
|
||||
"600": "#296336",
|
||||
"700": "#144922",
|
||||
"800": "#00300f",
|
||||
"900": "#011705"
|
||||
},
|
||||
"cyan": {
|
||||
"100": "#68d0dd",
|
||||
"200": "#56b6c2",
|
||||
"300": "#449da7",
|
||||
"400": "#33848e",
|
||||
"500": "#226c75",
|
||||
"600": "#0f555d",
|
||||
"700": "#093e44",
|
||||
"800": "#02292e",
|
||||
"900": "#001518"
|
||||
},
|
||||
"blue": {
|
||||
"100": "#82b4fb",
|
||||
"200": "#5c9cf5",
|
||||
"300": "#4c86d6",
|
||||
"400": "#3c70b8",
|
||||
"500": "#2c5b9b",
|
||||
"600": "#1d477f",
|
||||
"700": "#0f3364",
|
||||
"800": "#02214a",
|
||||
"900": "#01112b"
|
||||
},
|
||||
"purple": {
|
||||
"100": "#b38ff4",
|
||||
"200": "#9d7cd8",
|
||||
"300": "#8869bd",
|
||||
"400": "#7357a2",
|
||||
"500": "#5f4688",
|
||||
"600": "#4b356f",
|
||||
"700": "#392557",
|
||||
"800": "#271640",
|
||||
"900": "#17072b"
|
||||
},
|
||||
"accent": "$hue.purple",
|
||||
"interactive": "$hue.orange",
|
||||
"neutral": "$hue.gray"
|
||||
},
|
||||
"categorical": [
|
||||
"blue",
|
||||
"purple",
|
||||
"green",
|
||||
"orange",
|
||||
"red"
|
||||
],
|
||||
"text": {
|
||||
"action": {
|
||||
"primary": {
|
||||
"$focused": "$hue.neutral.800"
|
||||
},
|
||||
"destructive": {
|
||||
"base": "$hue.neutral.800"
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"warning": {
|
||||
"base": "#f5a742"
|
||||
}
|
||||
}
|
||||
},
|
||||
"border": {
|
||||
"base": "#484848"
|
||||
},
|
||||
"scrollbar": {
|
||||
"base": "#606060"
|
||||
},
|
||||
"diff": {
|
||||
"text": {
|
||||
"added": "#4fd6be",
|
||||
"context": "#828bb8",
|
||||
"hunkHeader": "#828bb8"
|
||||
},
|
||||
"background": {
|
||||
"added": "#20303b",
|
||||
"removed": "#37222c"
|
||||
},
|
||||
"highlight": {
|
||||
"added": "#b8db87",
|
||||
"removed": "#e26a75"
|
||||
},
|
||||
"lineNumber": {
|
||||
"text": "#8f8f8f",
|
||||
"background": {
|
||||
"added": "#1b2b34",
|
||||
"removed": "#2d1f26"
|
||||
}
|
||||
}
|
||||
},
|
||||
"syntax": {
|
||||
"number": "#f5a742",
|
||||
"type": "#e5c07b"
|
||||
},
|
||||
"markdown": {
|
||||
"blockQuote": "#e5c07b",
|
||||
"emphasis": "#e5c07b",
|
||||
"strong": "#f5a742"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError } from "@opencode/theme/tui"
|
||||
import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError, type ModeDefinition } from "@opencode/theme/tui"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
|
||||
import opencode from "./assets/v2/opencode.json" with { type: "json" }
|
||||
|
||||
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
|
||||
export { resolveThemeDocument, type ThemeDocument }
|
||||
@@ -14,11 +15,23 @@ let systemTheme: ThemeDocumentSource | undefined
|
||||
const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>()
|
||||
const parsed = new WeakMap<object, ThemeDocument>()
|
||||
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
|
||||
let opencodeTheme: (ThemeDocument & {
|
||||
readonly light: ModeDefinition
|
||||
readonly dark: ModeDefinition
|
||||
}) | undefined
|
||||
|
||||
export function getOpenCodeTheme() {
|
||||
if (opencodeTheme) return opencodeTheme
|
||||
const decoded = decodeThemeDocument(opencode) as NonNullable<typeof opencodeTheme>
|
||||
opencodeTheme = decoded
|
||||
return decoded
|
||||
}
|
||||
|
||||
function listThemes(): Record<string, ThemeDocumentSource> {
|
||||
// Priority: defaults < plugin installs < custom files < generated system.
|
||||
const themes: Record<string, ThemeDocumentSource> = {
|
||||
...DEFAULT_THEMES,
|
||||
opencode: getOpenCodeTheme(),
|
||||
...pluginThemes,
|
||||
...customThemes,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import monokai from "./assets/monokai.json" with { type: "json" }
|
||||
import nightowl from "./assets/nightowl.json" with { type: "json" }
|
||||
import nord from "./assets/nord.json" with { type: "json" }
|
||||
import onedark from "./assets/one-dark.json" with { type: "json" }
|
||||
import opencode from "./assets/opencode.json" with { type: "json" }
|
||||
import orng from "./assets/orng.json" with { type: "json" }
|
||||
import osakaJade from "./assets/osaka-jade.json" with { type: "json" }
|
||||
import palenight from "./assets/palenight.json" with { type: "json" }
|
||||
@@ -58,7 +57,6 @@ export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
|
||||
nord,
|
||||
["one-dark"]: onedark,
|
||||
["osaka-jade"]: osakaJade,
|
||||
opencode,
|
||||
orng,
|
||||
["lucent-orng"]: lucentOrng,
|
||||
palenight,
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { DEFAULT_THEME } from "@opencode/theme/tui"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
import { getOpenCodeTheme } from "../../../src/theme"
|
||||
import opencodeSource from "../../../src/theme/assets/opencode.json" with { type: "json" }
|
||||
import type { ThemeV1Json } from "@opencode/theme/tui/v1"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ThemeContextProvider, ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme"
|
||||
|
||||
@@ -17,19 +18,20 @@ async function wait(fn: () => boolean) {
|
||||
}
|
||||
|
||||
test("uses an available mode while retaining the pinned preference", async () => {
|
||||
const lightOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const opencodeV1 = opencodeSource as ThemeV1Json
|
||||
const lightOnly = structuredClone(opencodeV1)
|
||||
lightOnly.theme.background = "#eeeeee"
|
||||
lightOnly.theme.text = "#111111"
|
||||
const dual = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const dual = structuredClone(opencodeV1)
|
||||
dual.theme.background = { light: "#eeeeee", dark: "#111111" }
|
||||
dual.theme.text = { light: "#111111", dark: "#eeeeee" }
|
||||
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const darkOnly = structuredClone(opencodeV1)
|
||||
darkOnly.theme.background = "#111111"
|
||||
darkOnly.theme.text = "#eeeeee"
|
||||
const native = {
|
||||
version: 2,
|
||||
base: { ...DEFAULT_THEME.base, text: { ...DEFAULT_THEME.base.text, base: "#abcdef" } },
|
||||
dark: { hue: DEFAULT_THEME.dark.hue },
|
||||
base: { ...getOpenCodeTheme().base, text: { ...getOpenCodeTheme().base.text, base: "#abcdef" } },
|
||||
dark: { hue: getOpenCodeTheme().dark.hue },
|
||||
} as const
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === (attention === "question" ? "?" : "!"))?.fg
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.text.status[attention].toInts())
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
|
||||
const glow = () => {
|
||||
const colors = app
|
||||
.captureSpans()
|
||||
@@ -156,7 +156,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
expect(full).toBeGreaterThan(0)
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.text.status[attention].toInts())
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
|
||||
const dim = glow()
|
||||
expect(dim).toBeGreaterThan(0)
|
||||
expect(dim).toBeLessThan(full)
|
||||
@@ -172,7 +172,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === glyph)?.fg
|
||||
expect(color?.toInts()).toEqual(
|
||||
(unread === "error" ? theme.text.feedback.error.base : theme.text.status.unread).toInts(),
|
||||
(unread === "error" ? theme.text.feedback.error.base : theme.hue.interactive[800]).toInts(),
|
||||
)
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
await app.renderOnce()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { CliRenderEvents, RGBA, TextRenderable } from "@opentui/core"
|
||||
import { DEFAULT_THEME } from "@opencode/theme/tui"
|
||||
import path from "node:path"
|
||||
import { Writable } from "node:stream"
|
||||
import { coalesceProgressCommit, resolveRunAgent, RunFooter } from "../../src/mini/footer"
|
||||
@@ -12,6 +11,7 @@ import type { MiniSettingChange, MiniSettings, RunAgent, RunTuiConfig, StreamCom
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { getOpenCodeTheme } from "../../src/theme"
|
||||
|
||||
function progress(input: Partial<StreamCommit> = {}): StreamCommit {
|
||||
return {
|
||||
@@ -373,8 +373,8 @@ test("explicit theme refresh reloads custom colors without a palette event", asy
|
||||
path.join(tmp.path, "themes", "mini-refresh.json"),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
base: { ...DEFAULT_THEME.base, text: { ...DEFAULT_THEME.base.text, base: color } },
|
||||
dark: { hue: DEFAULT_THEME.dark.hue },
|
||||
base: { ...getOpenCodeTheme().base, text: { ...getOpenCodeTheme().base.text, base: color } },
|
||||
dark: { hue: getOpenCodeTheme().dark.hue },
|
||||
}),
|
||||
)
|
||||
await app.footer.refreshTheme()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||
import { DEFAULT_THEME, resolveThemeDocument, selectTheme, type ResolvedTheme } from "@opencode/theme/tui"
|
||||
import { resolveThemeDocument, selectTheme, type ResolvedTheme } from "@opencode/theme/tui"
|
||||
import {
|
||||
RUN_THEME_MONO,
|
||||
RUN_THEME_FALLBACK,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
resolveRunTheme,
|
||||
type RunTheme,
|
||||
} from "../../src/mini/theme"
|
||||
import { DEFAULT_THEMES, parseTheme } from "../../src/theme"
|
||||
import { DEFAULT_THEMES, getOpenCodeTheme, parseTheme } from "../../src/theme"
|
||||
import { generateSystem } from "../../src/theme/system"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -90,9 +90,9 @@ function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
|
||||
formfieldFocusedBg: theme.background.formfield.focused,
|
||||
formfieldFocusedText: theme.text.formfield.focused,
|
||||
selection: theme.text.formfield.selected,
|
||||
running: theme.text.status.running,
|
||||
question: theme.text.status.question,
|
||||
permission: theme.text.status.permission,
|
||||
running: theme.hue.accent[800],
|
||||
question: theme.hue.interactive[800],
|
||||
permission: theme.hue.interactive[800],
|
||||
success: theme.text.feedback.success.base,
|
||||
link: theme.markdown.link,
|
||||
shade: theme.background.raised.base,
|
||||
@@ -136,7 +136,10 @@ test.each(["light", "dark"] as const)("uses shared %s defaults and named built-i
|
||||
const colors = terminalColors({}, mode)
|
||||
for (const name of [undefined, "opencode", "tokyonight"] as const) {
|
||||
const theme = await resolveRunTheme(renderer({ colors }), { name, mode })
|
||||
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES[name ?? "opencode"]), mode)
|
||||
const expected = resolveThemeDocument(
|
||||
name === undefined || name === "opencode" ? getOpenCodeTheme() : parseTheme(DEFAULT_THEMES[name]),
|
||||
mode,
|
||||
)
|
||||
try {
|
||||
expectFooter(theme, expected)
|
||||
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
|
||||
@@ -187,7 +190,7 @@ test.each(["light", "dark"] as const)("shares the %s system scheme while retaini
|
||||
test.each(["light", "dark"] as const)(
|
||||
"loads complete %s custom theme files",
|
||||
async (mode) => {
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const base = selectTheme(getOpenCodeTheme(), mode)
|
||||
const definition = {
|
||||
...base,
|
||||
text: {
|
||||
@@ -236,7 +239,7 @@ test.each(["light", "dark"] as const)(
|
||||
test.each(["light", "dark"] as const)(
|
||||
"falls back to shared %s defaults for unknown or invalid themes",
|
||||
async (mode) => {
|
||||
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
|
||||
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
|
||||
for (const source of [
|
||||
{ version: 2, [mode]: { categorical: [] } },
|
||||
{ version: 2, [mode]: { text: { base: "$missing" } } },
|
||||
@@ -263,7 +266,10 @@ test.each(["light", "dark"] as const)("resolves dark-only Aura on an automatic %
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer({ colors }), { name: "aura" })
|
||||
try {
|
||||
expectFooter(theme, resolveThemeDocument(parseTheme(DEFAULT_THEMES[mode === "light" ? "opencode" : "aura"]), mode))
|
||||
expectFooter(
|
||||
theme,
|
||||
resolveThemeDocument(mode === "light" ? getOpenCodeTheme() : parseTheme(DEFAULT_THEMES.aura), mode),
|
||||
)
|
||||
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
@@ -273,7 +279,7 @@ test.each(["light", "dark"] as const)("resolves dark-only Aura on an automatic %
|
||||
test.each(["light", "dark"] as const)(
|
||||
"falls back only for unsupported modes of a %s-only custom theme",
|
||||
async (mode) => {
|
||||
const base = selectTheme(DEFAULT_THEME, mode)
|
||||
const base = selectTheme(getOpenCodeTheme(), mode)
|
||||
const definition = { ...base, text: { ...base.text, base: "#123456" } }
|
||||
const { hue, ...tokens } = definition
|
||||
const source = { version: 2, base: tokens, [mode]: { hue } }
|
||||
@@ -286,7 +292,7 @@ test.each(["light", "dark"] as const)(
|
||||
try {
|
||||
expectFooter(
|
||||
theme,
|
||||
resolveThemeDocument(parseTheme(requested === mode ? source : DEFAULT_THEMES.opencode), requested),
|
||||
resolveThemeDocument(requested === mode ? parseTheme(source) : getOpenCodeTheme(), requested),
|
||||
)
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
@@ -296,7 +302,7 @@ test.each(["light", "dark"] as const)(
|
||||
)
|
||||
|
||||
test.each(["light", "dark"] as const)("handles unavailable palettes in %s mode", async (mode) => {
|
||||
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
|
||||
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
|
||||
for (const input of [
|
||||
{ fail: true },
|
||||
{ colors: emptyColors },
|
||||
@@ -332,7 +338,7 @@ test("uses refreshed background brightness rather than stale mode or ANSI slot z
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("follows physical %s mode over opposite configuration", async (mode) => {
|
||||
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
|
||||
const expected = resolveThemeDocument(getOpenCodeTheme(), mode)
|
||||
for (const fail of [false, true]) {
|
||||
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode), themeMode: mode, fail }), {
|
||||
mode: mode === "light" ? "dark" : "light",
|
||||
@@ -350,7 +356,7 @@ test.each(["system", "opencode"])(
|
||||
async (name) => {
|
||||
const colors = terminalColors()
|
||||
const expected = resolveThemeDocument(
|
||||
parseTheme(name === "system" ? generateSystem(colors, "light") : DEFAULT_THEMES.opencode),
|
||||
name === "system" ? parseTheme(generateSystem(colors, "light")) : getOpenCodeTheme(),
|
||||
"light",
|
||||
)
|
||||
const input = { colors, fail: false, themeMode: "dark" as "dark" | "light" }
|
||||
|
||||
@@ -2,21 +2,29 @@ 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_THEME } from "@opencode/theme/tui"
|
||||
import { DEFAULT_THEMES, hasTheme, parseTheme, resolveTheme } from "../src/theme"
|
||||
import { allThemes, hasTheme, getOpenCodeTheme, parseTheme, resolveTheme } from "../src/theme"
|
||||
import { discoverThemes } from "../src/theme/discovery"
|
||||
import { configDirectories } from "../src/util/config-directories"
|
||||
import { terminalMode } from "../src/theme/system"
|
||||
import opencodeSource from "../src/theme/assets/opencode.json" with { type: "json" }
|
||||
import type { ThemeV1Json } from "@opencode/theme/tui/v1"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
const opencodeV1 = opencodeSource as ThemeV1Json
|
||||
|
||||
test("parseTheme delegates malformed V1 sources and rejects unknown versions", () => {
|
||||
expect(() => parseTheme({})).toThrow()
|
||||
expect(() => parseTheme({ version: 3 })).toThrow("Unsupported theme version: 3")
|
||||
})
|
||||
|
||||
test("registers opencode as a native V2 theme", () => {
|
||||
expect(allThemes().opencode).toBe(getOpenCodeTheme())
|
||||
expect(parseTheme(getOpenCodeTheme()).version).toBe(2)
|
||||
})
|
||||
|
||||
test("parses unversioned and explicit V1 themes lazily once", () => {
|
||||
const unversioned = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 }
|
||||
const unversioned = structuredClone(opencodeV1)
|
||||
const explicit = { ...structuredClone(opencodeV1), version: 1 }
|
||||
const first = parseTheme(unversioned, "unversioned")
|
||||
const second = parseTheme(explicit, "explicit")
|
||||
|
||||
@@ -29,8 +37,8 @@ test("parses unversioned and explicit V1 themes lazily once", () => {
|
||||
test("decodes native V2 themes lazily once", () => {
|
||||
const source = {
|
||||
version: 2,
|
||||
base: DEFAULT_THEME.base,
|
||||
light: { ...DEFAULT_THEME.light, categorical: ["red"] },
|
||||
base: getOpenCodeTheme().base,
|
||||
light: { ...getOpenCodeTheme().light, categorical: ["red"] },
|
||||
} as const
|
||||
|
||||
const document = parseTheme(source)
|
||||
@@ -45,7 +53,7 @@ test("rejects invalid V2 themes when parsing", () => {
|
||||
})
|
||||
|
||||
test("rejects invalid V1 themes when parsing", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.defs = { ...source.defs, one: "two", two: "one" }
|
||||
source.theme.primary = "one"
|
||||
|
||||
@@ -53,8 +61,8 @@ test("rejects invalid V1 themes when parsing", () => {
|
||||
})
|
||||
|
||||
test("replacement sources receive independent parse caches", () => {
|
||||
const first = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const second = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const first = structuredClone(opencodeV1)
|
||||
const second = structuredClone(opencodeV1)
|
||||
second.theme.primary = "#123456"
|
||||
|
||||
const previous = parseTheme(first)
|
||||
@@ -69,14 +77,14 @@ test("hasTheme checks theme presence", () => {
|
||||
})
|
||||
|
||||
test("resolveTheme rejects circular color refs", () => {
|
||||
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const item = structuredClone(opencodeV1)
|
||||
item.defs = { ...item.defs, one: "two", two: "one" }
|
||||
item.theme.primary = "one"
|
||||
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
|
||||
})
|
||||
|
||||
test("resolveTheme preserves full theme numeric color and marker semantics", () => {
|
||||
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const item = structuredClone(opencodeV1)
|
||||
item.theme.primary = 6
|
||||
delete item.theme.selectedListItemText
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { DEFAULT_THEME, resolveTheme, selectTheme } from "@opencode/theme/tui"
|
||||
import { resolveTheme, selectTheme } from "@opencode/theme/tui"
|
||||
import { createComponentTheme } from "../../../src/theme/component"
|
||||
import { getOpenCodeTheme } from "../../../src/theme"
|
||||
|
||||
test("provides reactive properties, states, surfaces, and color operations", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(getOpenCodeTheme(), "light")))
|
||||
const theme = createComponentTheme(resolved)
|
||||
const current = theme.surface("dialog")
|
||||
|
||||
@@ -78,7 +79,7 @@ test("provides reactive properties, states, surfaces, and color operations", ()
|
||||
expect(current.background.action.primary.hovered).toBe(resolved().background.raised.high)
|
||||
expect(current.background.formfield.selected).toBe(resolved().surface("dialog").background.formfield.selected)
|
||||
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
setResolved(resolveTheme(selectTheme(getOpenCodeTheme(), "dark")))
|
||||
expect(current.text.base).toBe(resolved().surface("dialog").text.base)
|
||||
expect(current.background.base).toBe(resolved().background.raised.base)
|
||||
expect(current.decrease(current.background.raised.base, 1)).toBe(resolved().hue.neutral[600])
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
BaseHue,
|
||||
DEFAULT_THEME,
|
||||
generateSyntax,
|
||||
resolveTheme,
|
||||
resolveThemeDocument,
|
||||
@@ -10,10 +9,20 @@ import {
|
||||
type Mode,
|
||||
type ThemeDefinition,
|
||||
} from "@opencode/theme/tui"
|
||||
import { parseTheme, type ThemeDocumentSource } from "../../../src/theme"
|
||||
import { getOpenCodeTheme, parseTheme, type ThemeDocumentSource } from "../../../src/theme"
|
||||
|
||||
const light = selectTheme(DEFAULT_THEME, "light")
|
||||
const dark = selectTheme(DEFAULT_THEME, "dark")
|
||||
const opencodeLight = selectTheme(getOpenCodeTheme(), "light")
|
||||
const opencodeDark = selectTheme(getOpenCodeTheme(), "dark")
|
||||
const light = {
|
||||
...opencodeLight,
|
||||
categorical: ["blue", "purple"],
|
||||
hue: { ...opencodeLight.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
|
||||
} satisfies ThemeDefinition
|
||||
const dark = {
|
||||
...opencodeDark,
|
||||
categorical: ["blue", "purple"],
|
||||
hue: { ...opencodeDark.hue, accent: "$hue.blue", interactive: "$hue.blue", neutral: "$hue.gray" },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
test("orders light hues dark-to-light and dark hues light-to-dark", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
@@ -102,16 +111,16 @@ test("resolves independent definitions and hue aliases", () => {
|
||||
expect(lightTheme.background.raised.base).toBe(lightTheme.hue.neutral[700])
|
||||
expect(lightTheme.background.raised.high).toBe(lightTheme.hue.neutral[600])
|
||||
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.text.action.primary.base).toBe(lightTheme.hue.neutral[800])
|
||||
expect(lightTheme.text.action.primary.base).toBe(lightTheme.text.base)
|
||||
// Surfaces re-resolve the palette after applying their theme-provided overrides.
|
||||
const dialog = lightTheme.surface("dialog")
|
||||
expect(dialog.background.base).toBe(lightTheme.background.raised.base)
|
||||
expect(dialog.background.formfield.base).toBe(lightTheme.background.raised.base)
|
||||
expect(dialog.background.feedback.error.base).toBe(lightTheme.background.raised.base)
|
||||
expect(dialog.background.action.primary.hovered).toBe(lightTheme.background.raised.high)
|
||||
expect(dialog.background.action.primary.base).toBe(lightTheme.hue.interactive[500])
|
||||
expect(dialog.background.action.primary.focused).toBe(lightTheme.hue.interactive[500])
|
||||
expect(dialog.text.action.primary.base).toBe(lightTheme.hue.neutral[900])
|
||||
expect(dialog.background.action.primary.base.equals(lightTheme.background.action.primary.base)).toBeTrue()
|
||||
expect(dialog.background.action.primary.focused.equals(lightTheme.background.action.primary.focused)).toBeTrue()
|
||||
expect(dialog.text.action.primary.base.equals(lightTheme.text.action.primary.base)).toBeTrue()
|
||||
expect(dialog.surface("dialog")).toBe(dialog)
|
||||
expect(darkTheme.surface("dialog").background.base).toBe(darkTheme.background.raised.base)
|
||||
})
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { DEFAULT_THEME, selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "@opencode/theme/tui"
|
||||
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "@opencode/theme/tui"
|
||||
import { getOpenCodeTheme } from "../../../src/theme"
|
||||
|
||||
test("selects complete light and dark themes independently", () => {
|
||||
expect(selectTheme(DEFAULT_THEME)).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.light })
|
||||
expect(selectTheme(DEFAULT_THEME, "light")).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.light })
|
||||
expect(selectTheme(DEFAULT_THEME, "dark")).toEqual({ ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark })
|
||||
expect(selectThemeMode(DEFAULT_THEME, "dark")).toEqual({
|
||||
theme: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark },
|
||||
mode: "dark",
|
||||
})
|
||||
const light = selectTheme(getOpenCodeTheme(), "light")
|
||||
const dark = selectTheme(getOpenCodeTheme(), "dark")
|
||||
expect(selectTheme(getOpenCodeTheme())).toEqual(light)
|
||||
expect(light.hue).toEqual(getOpenCodeTheme().light.hue)
|
||||
expect(light.text).toEqual(getOpenCodeTheme().base.text)
|
||||
expect(dark.hue).toEqual(getOpenCodeTheme().dark.hue)
|
||||
expect(selectThemeMode(getOpenCodeTheme(), "dark")).toEqual({ theme: dark, mode: "dark" })
|
||||
})
|
||||
|
||||
test("selects the available mode when the requested mode is missing", () => {
|
||||
const lightOnly = { version: 2, base: DEFAULT_THEME.base, light: DEFAULT_THEME.light } as const
|
||||
const darkOnly = { version: 2, base: DEFAULT_THEME.base, dark: DEFAULT_THEME.dark } as const
|
||||
const lightOnly = { version: 2, base: getOpenCodeTheme().base, light: getOpenCodeTheme().light } as const
|
||||
const darkOnly = { version: 2, base: getOpenCodeTheme().base, dark: getOpenCodeTheme().dark } as const
|
||||
|
||||
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: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.light },
|
||||
mode: "light",
|
||||
})
|
||||
expect(selectThemeMode(darkOnly, "light")).toEqual({
|
||||
theme: { ...DEFAULT_THEME.base, ...DEFAULT_THEME.dark },
|
||||
mode: "dark",
|
||||
})
|
||||
expect(selectThemeMode(lightOnly, "dark")).toEqual({ theme: selectTheme(getOpenCodeTheme(), "light"), mode: "light" })
|
||||
expect(selectThemeMode(darkOnly, "light")).toEqual({ theme: selectTheme(getOpenCodeTheme(), "dark"), mode: "dark" })
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEFAULT_THEME, selectTheme } from "@opencode/theme/tui"
|
||||
import { selectTheme } from "@opencode/theme/tui"
|
||||
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode/theme/tui"
|
||||
import { getOpenCodeTheme } from "../../../src/theme"
|
||||
|
||||
const text = {
|
||||
base: "$hue.neutral.900",
|
||||
@@ -39,18 +40,18 @@ const background = {
|
||||
} satisfies BackgroundDefinition
|
||||
|
||||
const definition = {
|
||||
...selectTheme(DEFAULT_THEME, "light"),
|
||||
...selectTheme(getOpenCodeTheme(), "light"),
|
||||
"@dialog": { background: { base: "$background.raised.base" } },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
export const document = {
|
||||
version: 2,
|
||||
base: DEFAULT_THEME.base,
|
||||
base: getOpenCodeTheme().base,
|
||||
light: { hue: definition.hue },
|
||||
dark: definition,
|
||||
} satisfies ThemeDocument
|
||||
export const lightOnly = { version: 2, base: DEFAULT_THEME.base, light: { hue: definition.hue } } satisfies ThemeDocument
|
||||
export const darkOnly = { version: 2, base: DEFAULT_THEME.base, dark: definition } satisfies ThemeDocument
|
||||
export const lightOnly = { version: 2, base: getOpenCodeTheme().base, light: { hue: definition.hue } } satisfies ThemeDocument
|
||||
export const darkOnly = { version: 2, base: getOpenCodeTheme().base, dark: definition } satisfies ThemeDocument
|
||||
// @ts-expect-error A theme document must provide at least one mode.
|
||||
export const empty = { version: 2 } satisfies ThemeDocument
|
||||
// @ts-expect-error A base mode must be complete; partial tokens are only valid under @dialog.
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
DEFAULT_CATEGORICAL,
|
||||
DEFAULT_THEME,
|
||||
migrateV1,
|
||||
resolveThemeDocument,
|
||||
selectThemeMode,
|
||||
themeModes,
|
||||
} from "@opencode/theme/tui"
|
||||
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
|
||||
import { DEFAULT_THEMES, getOpenCodeTheme, resolveTheme as resolveV1 } from "../../../src/theme"
|
||||
import opencodeSource from "../../../src/theme/assets/opencode.json" with { type: "json" }
|
||||
import type { ThemeV1Json } from "@opencode/theme/tui/v1"
|
||||
|
||||
const opencodeV1 = opencodeSource as ThemeV1Json
|
||||
const opencodeLight = resolveThemeDocument(getOpenCodeTheme(), "light")
|
||||
const opencodeDark = resolveThemeDocument(getOpenCodeTheme(), "dark")
|
||||
|
||||
test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
const migrated = migrateV1(DEFAULT_THEMES.opencode)
|
||||
const migrated = migrateV1(opencodeV1)
|
||||
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
|
||||
const legacy = resolveV1(opencodeV1, "light")
|
||||
const resolved = resolveThemeDocument(migrated, "light")
|
||||
|
||||
expect(migrated.base.categorical?.length).toBeGreaterThan(0)
|
||||
@@ -52,7 +57,7 @@ test("migrates resolved V1 modes into V2 tokens", () => {
|
||||
})
|
||||
|
||||
test("references generated hues from matching token colors", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.border = source.theme.primary
|
||||
source.theme.borderActive = source.theme.accent
|
||||
source.theme.syntaxKeyword = source.theme.error
|
||||
@@ -68,7 +73,7 @@ test("references generated hues from matching token colors", () => {
|
||||
})
|
||||
|
||||
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
const ambiguous = { light: "#808080", dark: "#808080" }
|
||||
source.theme.accent = ambiguous
|
||||
source.theme.warning = ambiguous
|
||||
@@ -101,10 +106,28 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
||||
})
|
||||
|
||||
test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
const colors = {
|
||||
light: {
|
||||
red: "#fca5a5",
|
||||
orange: "#fdba74",
|
||||
yellow: "#fde047",
|
||||
green: "#86efac",
|
||||
blue: "#93c5fd",
|
||||
purple: "#d8b4fe",
|
||||
},
|
||||
dark: {
|
||||
red: "#b91c1c",
|
||||
orange: "#c2410c",
|
||||
yellow: "#a16207",
|
||||
green: "#15803d",
|
||||
blue: "#1d4ed8",
|
||||
purple: "#7e22ce",
|
||||
},
|
||||
} as const
|
||||
const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
|
||||
light: DEFAULT_THEME.light.hue[name][300],
|
||||
dark: DEFAULT_THEME.dark.hue[name][300],
|
||||
light: colors.light[name],
|
||||
dark: colors.dark[name],
|
||||
})
|
||||
source.theme.secondary = mapped("purple")
|
||||
source.theme.accent = mapped("orange")
|
||||
@@ -122,11 +145,11 @@ test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
})
|
||||
|
||||
test("gives accent and primary ownership of their inferred hues", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.success = DEFAULT_THEME.light.hue.orange[700]
|
||||
source.theme.accent = DEFAULT_THEME.light.hue.orange[600]
|
||||
source.theme.info = DEFAULT_THEME.light.hue.blue[700]
|
||||
source.theme.primary = DEFAULT_THEME.light.hue.blue[600]
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.success = hex(opencodeLight.hue.orange[700])
|
||||
source.theme.accent = hex(opencodeLight.hue.orange[600])
|
||||
source.theme.info = hex(opencodeLight.hue.blue[700])
|
||||
source.theme.primary = hex(opencodeLight.hue.blue[600])
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light) throw new Error("Expected light mode")
|
||||
@@ -139,7 +162,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
|
||||
expect(migrated.light.hue?.accent).toBe("$hue.orange")
|
||||
expect(migrated.light.hue?.interactive).toBe("$hue.blue")
|
||||
|
||||
source.theme.primary = DEFAULT_THEME.light.hue.orange[500]
|
||||
source.theme.primary = hex(opencodeLight.hue.orange[500])
|
||||
const collisionMode = migrateV1(source).light
|
||||
const collision = collisionMode?.hue?.orange
|
||||
if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
|
||||
@@ -149,7 +172,7 @@ test("gives accent and primary ownership of their inferred hues", () => {
|
||||
})
|
||||
|
||||
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.secondary = "transparent"
|
||||
source.theme.accent = "transparent"
|
||||
source.theme.success = "transparent"
|
||||
@@ -163,7 +186,7 @@ test("uses default categorical hues when V1 semantic colors are ambiguous", () =
|
||||
})
|
||||
|
||||
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.background = { light: "#eeeeee", dark: "#111111" }
|
||||
source.theme.backgroundPanel = { light: "#dddddd", dark: "#222222" }
|
||||
source.theme.backgroundElement = { light: "#cccccc", dark: "#333333" }
|
||||
@@ -202,7 +225,7 @@ test("builds and extrapolates gray from V1 surfaces and text without using menus
|
||||
})
|
||||
|
||||
test("uses the base text reference for primary actions on transparent backgrounds", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.background = "transparent"
|
||||
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
||||
delete source.theme.selectedListItemText
|
||||
@@ -214,7 +237,7 @@ test("uses the base text reference for primary actions on transparent background
|
||||
})
|
||||
|
||||
test("retains V1 circular reference errors", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.defs = { ...source.defs, one: "two", two: "one" }
|
||||
source.theme.primary = "one"
|
||||
|
||||
@@ -231,7 +254,7 @@ test("migrates every built-in V1 theme in its supported modes", () => {
|
||||
})
|
||||
|
||||
test("collapses identical V1 backgrounds when both variants infer one mode", () => {
|
||||
const dark = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const dark = structuredClone(opencodeV1)
|
||||
dark.theme.background = "#111111"
|
||||
dark.theme.text = "#eeeeee"
|
||||
const migratedDark = migrateV1(dark)
|
||||
@@ -240,7 +263,7 @@ test("collapses identical V1 backgrounds when both variants infer one mode", ()
|
||||
expect(themeModes(migratedDark)).toEqual(["dark"])
|
||||
expect(selectThemeMode(migratedDark, "light").mode).toBe("dark")
|
||||
|
||||
const light = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const light = structuredClone(opencodeV1)
|
||||
light.theme.background = "#eeeeee"
|
||||
light.theme.text = "#111111"
|
||||
const migratedLight = migrateV1(light)
|
||||
@@ -251,7 +274,7 @@ test("collapses identical V1 backgrounds when both variants infer one mode", ()
|
||||
})
|
||||
|
||||
test("keeps both modes when a shared background has different contrast", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const source = structuredClone(opencodeV1)
|
||||
source.theme.background = "#808080"
|
||||
source.theme.text = { light: "#111111", dark: "#eeeeee" }
|
||||
const migrated = migrateV1(source)
|
||||
|
||||
@@ -227,7 +227,7 @@ palette:
|
||||
|
||||
| Group | Purpose |
|
||||
| ------------ | ------------------------------------------------------------- |
|
||||
| `text` | Base, muted, action, form-field, status, and feedback text |
|
||||
| `text` | Base, muted, action, form-field, and feedback text |
|
||||
| `background` | Base, surfaces, actions, form fields, and feedback fills |
|
||||
| `border` | Borders |
|
||||
| `scrollbar` | Scrollbars |
|
||||
|
||||
Reference in New Issue
Block a user