|
|
@@ -1,9 +1,16 @@
|
|
|
import { RGBA } from "@opentui/core"
|
|
|
+import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
|
|
import type { Theme, ThemeJson } from "../index"
|
|
|
import { DEFAULT_THEME } from "./defaults"
|
|
|
import type { ThemeFile } from "./index"
|
|
|
+import { HueStep } from "./schema"
|
|
|
|
|
|
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
|
|
+type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
|
|
+
|
|
|
+const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
|
|
|
+const minimumChroma = 0.03
|
|
|
+const lightThreshold = 0.6
|
|
|
|
|
|
export function migrateV1(theme: ThemeJson): ThemeFile {
|
|
|
return {
|
|
|
@@ -18,33 +25,48 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|
|
const color = (key: ThemeColor) => hex(theme[key])
|
|
|
const selected = hex(selectedForeground(theme, theme.primary))
|
|
|
const destructive = hex(selectedForeground(theme, theme.error))
|
|
|
+ const hues = inferHues(theme, mode)
|
|
|
+ const text = mode === "light" ? "$hue.neutral.900" : "$hue.neutral.100"
|
|
|
+ const textMuted = mode === "light" ? "$hue.neutral.700" : "$hue.neutral.300"
|
|
|
+ const primary = mode === "light" ? "$hue.interactive.900" : "$hue.interactive.100"
|
|
|
+ const background = mode === "light" ? "$hue.neutral.100" : "$hue.neutral.900"
|
|
|
+ const backgroundPanel = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
|
|
|
+ const backgroundMenu = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
|
|
|
|
|
|
return {
|
|
|
hue: {
|
|
|
- ...DEFAULT_THEME[mode].hue,
|
|
|
- accent: hueScale(theme.secondary),
|
|
|
+ gray: neutralScale(theme, mode),
|
|
|
+ ...Object.fromEntries(
|
|
|
+ chromaticHues.map((name) => {
|
|
|
+ const match = hues[name]
|
|
|
+ return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ accent: ambiguous(theme.accent) ? "$hue.gray" : hueScale(theme.accent, mode),
|
|
|
+ interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
|
|
|
+ neutral: "$hue.gray",
|
|
|
},
|
|
|
text: {
|
|
|
- default: color("text"),
|
|
|
- subdued: color("textMuted"),
|
|
|
+ default: text,
|
|
|
+ subdued: textMuted,
|
|
|
action: {
|
|
|
primary: {
|
|
|
- default: selected,
|
|
|
- $disabled: color("textMuted"),
|
|
|
+ default: "$text.default",
|
|
|
+ $disabled: textMuted,
|
|
|
$focused: selected,
|
|
|
},
|
|
|
secondary: {
|
|
|
default: "$text.default",
|
|
|
- $disabled: color("textMuted"),
|
|
|
+ $disabled: textMuted,
|
|
|
},
|
|
|
- destructive: { default: destructive, $disabled: color("textMuted") },
|
|
|
+ destructive: { default: destructive, $disabled: textMuted },
|
|
|
},
|
|
|
formfield: {
|
|
|
- default: color("text"),
|
|
|
- $focused: color("primary"),
|
|
|
- $pressed: color("primary"),
|
|
|
- $disabled: color("textMuted"),
|
|
|
- $selected: color("primary"),
|
|
|
+ default: text,
|
|
|
+ $focused: primary,
|
|
|
+ $pressed: primary,
|
|
|
+ $disabled: textMuted,
|
|
|
+ $selected: primary,
|
|
|
},
|
|
|
feedback: {
|
|
|
error: { default: color("error") },
|
|
|
@@ -54,13 +76,13 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|
|
},
|
|
|
},
|
|
|
background: {
|
|
|
- default: color("background"),
|
|
|
+ default: background,
|
|
|
surface: {
|
|
|
- offset: color("backgroundPanel"),
|
|
|
- overlay: color("backgroundMenu"),
|
|
|
+ offset: backgroundPanel,
|
|
|
+ overlay: backgroundMenu,
|
|
|
},
|
|
|
action: {
|
|
|
- primary: { default: color("primary"), $focused: color("primary") },
|
|
|
+ primary: { default: "transparent", $focused: primary },
|
|
|
secondary: {
|
|
|
default: "$background.default",
|
|
|
$focused: color("backgroundElement"),
|
|
|
@@ -131,21 +153,44 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
|
|
"@context:elevated": {
|
|
|
background: {
|
|
|
default: "$background.surface.offset",
|
|
|
- action: {
|
|
|
- primary: {
|
|
|
- default: color("primary"),
|
|
|
- $focused: color("primary"),
|
|
|
- },
|
|
|
- secondary: {
|
|
|
- default: "$background.surface.offset",
|
|
|
- },
|
|
|
- },
|
|
|
},
|
|
|
},
|
|
|
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function inferHues(theme: Theme, mode: "light" | "dark") {
|
|
|
+ return [theme.accent, theme.success, theme.warning, theme.primary, theme.error, theme.info, theme.secondary].reduce<
|
|
|
+ Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
|
|
|
+ >((result, color) => {
|
|
|
+ const value = toOklch(color)
|
|
|
+ if (ambiguous(color, value.c)) return result
|
|
|
+ const anchor = inferenceAnchor(value.l)
|
|
|
+ const nearest = chromaticHues
|
|
|
+ .map((name) => ({
|
|
|
+ name,
|
|
|
+ distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
|
|
|
+ }))
|
|
|
+ .sort((first, second) => first.distance - second.distance)[0]
|
|
|
+ const current = result[nearest.name]
|
|
|
+ if (current && current.distance <= nearest.distance) return result
|
|
|
+ return { ...result, [nearest.name]: { color, distance: nearest.distance } }
|
|
|
+ }, {})
|
|
|
+}
|
|
|
+
|
|
|
+function inferenceAnchor(lightness: number): HueStep {
|
|
|
+ return lightness >= lightThreshold ? 300 : 700
|
|
|
+}
|
|
|
+
|
|
|
+function hueDistance(first: number, second: number) {
|
|
|
+ const difference = Math.abs(first - second)
|
|
|
+ return Math.min(difference, 360 - difference)
|
|
|
+}
|
|
|
+
|
|
|
+function ambiguous(color: RGBA, chroma = toOklch(color).c) {
|
|
|
+ return color.toInts()[3] === 0 || chroma < minimumChroma
|
|
|
+}
|
|
|
+
|
|
|
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
|
|
const defs = theme.defs ?? {}
|
|
|
|
|
|
@@ -192,28 +237,66 @@ function selectedForeground(theme: Theme, background: RGBA) {
|
|
|
: RGBA.fromInts(255, 255, 255)
|
|
|
}
|
|
|
|
|
|
-function hueScale(color: RGBA) {
|
|
|
- return {
|
|
|
- 100: mix(color, 255, 0.8),
|
|
|
- 200: mix(color, 255, 0.6),
|
|
|
- 300: mix(color, 255, 0.4),
|
|
|
- 400: mix(color, 255, 0.2),
|
|
|
- 500: hex(color),
|
|
|
- 600: mix(color, 0, 0.15),
|
|
|
- 700: mix(color, 0, 0.3),
|
|
|
- 800: mix(color, 0, 0.45),
|
|
|
- 900: mix(color, 0, 0.6),
|
|
|
- }
|
|
|
+function hueScale(color: RGBA, mode: "light" | "dark") {
|
|
|
+ const value = toOklch(color)
|
|
|
+ const anchor = mode === "light" ? 900 : 100
|
|
|
+ const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
|
|
|
+ const alpha = color.toInts()[3]
|
|
|
+ return Object.fromEntries(
|
|
|
+ HueStep.literals.map((step) => {
|
|
|
+ if (step === anchor) return [step, hex(color)]
|
|
|
+ const progress = mode === "light" ? (900 - step) / 800 : (step - 100) / 800
|
|
|
+ const generated = oklchToHex({
|
|
|
+ l: value.l + (endpoint - value.l) * progress,
|
|
|
+ c: value.c * (1 - progress * 0.5),
|
|
|
+ h: value.h,
|
|
|
+ })
|
|
|
+ return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
|
|
|
+ }),
|
|
|
+ ) as Record<HueStep, string>
|
|
|
+}
|
|
|
+
|
|
|
+function neutralScale(theme: Theme, mode: "light" | "dark") {
|
|
|
+ const anchors = neutralAnchors(theme, mode)
|
|
|
+ return Object.fromEntries(
|
|
|
+ HueStep.literals.map((step) => {
|
|
|
+ const exact = anchors.find((anchor) => anchor.step === step)
|
|
|
+ if (exact) return [step, hex(exact.color)]
|
|
|
+ const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
|
|
|
+ const upper = anchors.find((anchor) => anchor.step > step)!
|
|
|
+ return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
|
|
|
+ }),
|
|
|
+ ) as Record<HueStep, string>
|
|
|
+}
|
|
|
+
|
|
|
+function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
|
|
+ const light: { step: HueStep; color: RGBA }[] = [
|
|
|
+ { step: 100, color: theme.background },
|
|
|
+ { step: 200, color: theme.backgroundPanel },
|
|
|
+ { step: 300, color: theme.backgroundMenu },
|
|
|
+ { step: 700, color: theme.textMuted },
|
|
|
+ { step: 900, color: theme.text },
|
|
|
+ ]
|
|
|
+ if (mode === "light") return light
|
|
|
+ return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
|
|
|
+}
|
|
|
+
|
|
|
+function interpolate(first: RGBA, second: RGBA, amount: number) {
|
|
|
+ const start = toOklch(first)
|
|
|
+ const end = toOklch(second)
|
|
|
+ const hue = ((((end.h - start.h) % 360) + 540) % 360) - 180
|
|
|
+ const generated = oklchToHex({
|
|
|
+ l: start.l + (end.l - start.l) * amount,
|
|
|
+ c: start.c + (end.c - start.c) * amount,
|
|
|
+ h: start.h + hue * amount,
|
|
|
+ })
|
|
|
+ const alpha = Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)
|
|
|
+ return alpha === 255 ? generated : `${generated}${byte(alpha)}`
|
|
|
}
|
|
|
|
|
|
-function mix(color: RGBA, target: number, amount: number) {
|
|
|
- const [r, g, b, a] = color.toInts()
|
|
|
- return hexInts(
|
|
|
- Math.round(r + (target - r) * amount),
|
|
|
- Math.round(g + (target - g) * amount),
|
|
|
- Math.round(b + (target - b) * amount),
|
|
|
- a,
|
|
|
- )
|
|
|
+function toOklch(color: RGBA) {
|
|
|
+ const [red, green, blue] = color.toInts()
|
|
|
+ return rgbToOklch(red / 255, green / 255, blue / 255)
|
|
|
}
|
|
|
|
|
|
function hex(color: RGBA) {
|
|
|
@@ -221,10 +304,13 @@ function hex(color: RGBA) {
|
|
|
}
|
|
|
|
|
|
function hexInts(r: number, g: number, b: number, a: number) {
|
|
|
- const byte = (value: number) => value.toString(16).padStart(2, "0")
|
|
|
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
|
|
|
}
|
|
|
|
|
|
+function byte(value: number) {
|
|
|
+ return value.toString(16).padStart(2, "0")
|
|
|
+}
|
|
|
+
|
|
|
function ansi(code: number) {
|
|
|
if (code < 16) {
|
|
|
const colors = [
|