From 4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 28 Dec 2025 05:12:32 -0600 Subject: feat(desktop): themes --- packages/ui/src/theme/color.ts | 267 +++++++++++ packages/ui/src/theme/context.tsx | 280 ++++++++++++ packages/ui/src/theme/default-themes.ts | 35 ++ packages/ui/src/theme/desktop-theme.schema.json | 104 +++++ packages/ui/src/theme/index.ts | 71 +++ packages/ui/src/theme/loader.ts | 213 +++++++++ packages/ui/src/theme/preload.ts | 115 +++++ packages/ui/src/theme/resolve.ts | 365 ++++++++++++++++ packages/ui/src/theme/themes/ayu.json | 131 ++++++ packages/ui/src/theme/themes/catppuccin.json | 131 ++++++ packages/ui/src/theme/themes/dracula.json | 131 ++++++ packages/ui/src/theme/themes/monokai.json | 131 ++++++ packages/ui/src/theme/themes/nord.json | 131 ++++++ packages/ui/src/theme/themes/oc-1.json | 535 +++++++++++++++++++++++ packages/ui/src/theme/themes/onedarkpro.json | 131 ++++++ packages/ui/src/theme/themes/shadesofpurple.json | 131 ++++++ packages/ui/src/theme/themes/solarized.json | 131 ++++++ packages/ui/src/theme/themes/tokyonight.json | 155 +++++++ packages/ui/src/theme/types.ts | 94 ++++ 19 files changed, 3282 insertions(+) create mode 100644 packages/ui/src/theme/color.ts create mode 100644 packages/ui/src/theme/context.tsx create mode 100644 packages/ui/src/theme/default-themes.ts create mode 100644 packages/ui/src/theme/desktop-theme.schema.json create mode 100644 packages/ui/src/theme/index.ts create mode 100644 packages/ui/src/theme/loader.ts create mode 100644 packages/ui/src/theme/preload.ts create mode 100644 packages/ui/src/theme/resolve.ts create mode 100644 packages/ui/src/theme/themes/ayu.json create mode 100644 packages/ui/src/theme/themes/catppuccin.json create mode 100644 packages/ui/src/theme/themes/dracula.json create mode 100644 packages/ui/src/theme/themes/monokai.json create mode 100644 packages/ui/src/theme/themes/nord.json create mode 100644 packages/ui/src/theme/themes/oc-1.json create mode 100644 packages/ui/src/theme/themes/onedarkpro.json create mode 100644 packages/ui/src/theme/themes/shadesofpurple.json create mode 100644 packages/ui/src/theme/themes/solarized.json create mode 100644 packages/ui/src/theme/themes/tokyonight.json create mode 100644 packages/ui/src/theme/types.ts (limited to 'packages/ui/src/theme') diff --git a/packages/ui/src/theme/color.ts b/packages/ui/src/theme/color.ts new file mode 100644 index 000000000..3a0526ca6 --- /dev/null +++ b/packages/ui/src/theme/color.ts @@ -0,0 +1,267 @@ +/** + * Color utilities for theme generation using OKLCH color space. + * OKLCH provides perceptually uniform color manipulation. + */ + +import type { HexColor, OklchColor } from "./types" + +/** + * Convert hex color to RGB values (0-1 range) + */ +export function hexToRgb(hex: HexColor): { r: number; g: number; b: number } { + const h = hex.replace("#", "") + const full = + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h + + const num = parseInt(full, 16) + return { + r: ((num >> 16) & 255) / 255, + g: ((num >> 8) & 255) / 255, + b: (num & 255) / 255, + } +} + +/** + * Convert RGB (0-1 range) to hex color + */ +export function rgbToHex(r: number, g: number, b: number): HexColor { + const toHex = (v: number) => { + const clamped = Math.max(0, Math.min(1, v)) + const int = Math.round(clamped * 255) + return int.toString(16).padStart(2, "0") + } + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** + * Convert linear RGB to sRGB + */ +function linearToSrgb(c: number): number { + if (c <= 0.0031308) return c * 12.92 + return 1.055 * Math.pow(c, 1 / 2.4) - 0.055 +} + +/** + * Convert sRGB to linear RGB + */ +function srgbToLinear(c: number): number { + if (c <= 0.04045) return c / 12.92 + return Math.pow((c + 0.055) / 1.055, 2.4) +} + +/** + * Convert RGB to OKLCH + */ +export function rgbToOklch(r: number, g: number, b: number): OklchColor { + // Convert to linear RGB + const lr = srgbToLinear(r) + const lg = srgbToLinear(g) + const lb = srgbToLinear(b) + + // RGB to OKLab matrix multiplication + const l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb + const m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb + const s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb + + const l = Math.cbrt(l_) + const m = Math.cbrt(m_) + const s = Math.cbrt(s_) + + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const bOk = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + + const C = Math.sqrt(a * a + bOk * bOk) + let H = Math.atan2(bOk, a) * (180 / Math.PI) + if (H < 0) H += 360 + + return { l: L, c: C, h: H } +} + +/** + * Convert OKLCH to RGB + */ +export function oklchToRgb(oklch: OklchColor): { r: number; g: number; b: number } { + const { l: L, c: C, h: H } = oklch + + const a = C * Math.cos((H * Math.PI) / 180) + const b = C * Math.sin((H * Math.PI) / 180) + + const l = L + 0.3963377774 * a + 0.2158037573 * b + const m = L - 0.1055613458 * a - 0.0638541728 * b + const s = L - 0.0894841775 * a - 1.291485548 * b + + const l3 = l * l * l + const m3 = m * m * m + const s3 = s * s * s + + const lr = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3 + const lg = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3 + const lb = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3 + + return { + r: linearToSrgb(lr), + g: linearToSrgb(lg), + b: linearToSrgb(lb), + } +} + +/** + * Convert hex to OKLCH + */ +export function hexToOklch(hex: HexColor): OklchColor { + const { r, g, b } = hexToRgb(hex) + return rgbToOklch(r, g, b) +} + +/** + * Convert OKLCH to hex + */ +export function oklchToHex(oklch: OklchColor): HexColor { + const { r, g, b } = oklchToRgb(oklch) + return rgbToHex(r, g, b) +} + +/** + * Generate a 12-step color scale from a seed color. + * Steps 1-4: Very light (backgrounds) + * Steps 5-7: Mid tones (borders, subtle UI) + * Steps 8-9: Saturated (primary buttons, text) + * Steps 10-12: Dark (text, strong accents) + * + * @param seed - The seed color (typically step 9 - the main accent) + * @param isDark - Whether generating for dark mode + */ +export function generateScale(seed: HexColor, isDark: boolean): HexColor[] { + const base = hexToOklch(seed) + const scale: HexColor[] = [] + + // Lightness values for each step (0-1) + // These are tuned to match Radix-style color scales + const lightSteps = isDark + ? [0.15, 0.18, 0.22, 0.26, 0.32, 0.38, 0.46, 0.56, base.l, base.l - 0.05, 0.75, 0.93] + : [0.99, 0.97, 0.94, 0.9, 0.85, 0.79, 0.72, 0.64, base.l, base.l + 0.05, 0.45, 0.25] + + // Chroma multipliers - less saturation at extremes + const chromaMultipliers = isDark + ? [0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1, 1, 0.9, 0.6] + : [0.1, 0.15, 0.25, 0.35, 0.45, 0.55, 0.7, 0.85, 1, 1, 0.95, 0.85] + + for (let i = 0; i < 12; i++) { + scale.push( + oklchToHex({ + l: lightSteps[i], + c: base.c * chromaMultipliers[i], + h: base.h, + }), + ) + } + + return scale +} + +/** + * Generate a neutral gray scale from a seed color. + * The seed color's hue is used to tint the grays slightly. + * + * @param seed - A neutral-ish color to derive the gray scale from + * @param isDark - Whether generating for dark mode + */ +export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[] { + const base = hexToOklch(seed) + const scale: HexColor[] = [] + + // Very low chroma for neutrals - just a hint of the hue + const neutralChroma = Math.min(base.c, 0.02) + + const lightSteps = isDark + ? [0.13, 0.16, 0.2, 0.24, 0.28, 0.33, 0.4, 0.52, 0.58, 0.66, 0.82, 0.96] + : [0.995, 0.98, 0.96, 0.94, 0.91, 0.88, 0.84, 0.78, 0.62, 0.56, 0.46, 0.2] + + for (let i = 0; i < 12; i++) { + scale.push( + oklchToHex({ + l: lightSteps[i], + c: neutralChroma, + h: base.h, + }), + ) + } + + return scale +} + +/** + * Generate alpha variants of a color scale. + * Returns hex colors with alpha pre-multiplied against white (light) or black (dark). + */ +export function generateAlphaScale(scale: HexColor[], isDark: boolean): HexColor[] { + // Alpha values for each step + const alphas = isDark + ? [0.02, 0.04, 0.08, 0.12, 0.16, 0.2, 0.26, 0.36, 0.44, 0.52, 0.76, 0.96] + : [0.01, 0.03, 0.06, 0.09, 0.12, 0.15, 0.2, 0.28, 0.48, 0.56, 0.64, 0.88] + + return scale.map((hex, i) => { + const { r, g, b } = hexToRgb(hex) + const a = alphas[i] + + // Pre-multiply against white (light) or black (dark) + const bg = isDark ? 0 : 1 + const blendedR = r * a + bg * (1 - a) + const blendedG = g * a + bg * (1 - a) + const blendedB = b * a + bg * (1 - a) + + // Return as hex with alpha encoded in the color itself + // For true alpha, we'd need rgba(), but this approximates it + return rgbToHex(blendedR, blendedG, blendedB) + }) +} + +/** + * Mix two colors together + */ +export function mixColors(color1: HexColor, color2: HexColor, amount: number): HexColor { + const c1 = hexToOklch(color1) + const c2 = hexToOklch(color2) + + return oklchToHex({ + l: c1.l + (c2.l - c1.l) * amount, + c: c1.c + (c2.c - c1.c) * amount, + h: c1.h + (c2.h - c1.h) * amount, + }) +} + +/** + * Lighten a color by a given amount (0-1) + */ +export function lighten(color: HexColor, amount: number): HexColor { + const oklch = hexToOklch(color) + return oklchToHex({ + ...oklch, + l: Math.min(1, oklch.l + amount), + }) +} + +/** + * Darken a color by a given amount (0-1) + */ +export function darken(color: HexColor, amount: number): HexColor { + const oklch = hexToOklch(color) + return oklchToHex({ + ...oklch, + l: Math.max(0, oklch.l - amount), + }) +} + +/** + * Adjust the alpha/opacity of a hex color (returns rgba string) + */ +export function withAlpha(color: HexColor, alpha: number): string { + const { r, g, b } = hexToRgb(color) + return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha})` +} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx new file mode 100644 index 000000000..d8ca6d507 --- /dev/null +++ b/packages/ui/src/theme/context.tsx @@ -0,0 +1,280 @@ +/** + * Theme context for SolidJS applications. + * Provides reactive theme management with localStorage persistence and caching. + * + * Works in conjunction with the preload script to provide zero-FOUC theming: + * 1. Preload script applies cached CSS immediately from localStorage + * 2. ThemeProvider takes over, resolves theme, and updates cache + */ + +import { + createContext, + useContext, + createSignal, + onMount, + onCleanup, + createEffect, + type JSX, + type Accessor, +} from "solid-js" +import type { DesktopTheme } from "./types" +import { resolveThemeVariant, themeToCss } from "./resolve" +import { STORAGE_KEYS, getThemeCacheKey } from "./preload" +import { DEFAULT_THEMES } from "./default-themes" + +export type ColorScheme = "light" | "dark" | "system" + +interface ThemeContextValue { + /** Currently active theme ID */ + themeId: Accessor + /** Current color scheme preference */ + colorScheme: Accessor + /** Resolved current mode (light or dark) */ + mode: Accessor<"light" | "dark"> + /** All available themes */ + themes: Accessor> + /** Set the active theme by ID */ + setTheme: (id: string) => void + /** Set color scheme preference */ + setColorScheme: (scheme: ColorScheme) => void + /** Register a custom theme */ + registerTheme: (theme: DesktopTheme) => void +} + +const ThemeContext = createContext() + +/** + * Static tokens that don't change between themes + */ +const STATIC_TOKENS = ` + --font-family-sans: "Inter", "Inter Fallback"; + --font-family-sans--font-feature-settings: "ss03" 1; + --font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback"; + --font-family-mono--font-feature-settings: "ss01" 1; + --font-size-small: 13px; + --font-size-base: 14px; + --font-size-large: 16px; + --font-size-x-large: 20px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --line-height-large: 150%; + --line-height-x-large: 180%; + --line-height-2x-large: 200%; + --letter-spacing-normal: 0; + --letter-spacing-tight: -0.16; + --letter-spacing-tightest: -0.32; + --paragraph-spacing-base: 0; + --spacing: 0.25rem; + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 64rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + --container-3xs: 16rem; + --container-2xs: 18rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --container-7xl: 80rem; + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.625rem; + --shadow-xs: 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-md: 0 6px 8px -4px rgba(19, 16, 16, 0.12), 0 4px 3px -2px rgba(19, 16, 16, 0.12), 0 1px 2px -1px rgba(19, 16, 16, 0.12); + --shadow-xs-border: 0 0 0 1px var(--border-base), 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-xs-border-base: 0 0 0 1px var(--border-weak-base), 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-xs-border-select: 0 0 0 3px var(--border-weak-selected), 0 0 0 1px var(--border-selected), 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); + --shadow-xs-border-focus: 0 0 0 1px var(--border-base), 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak), 0 0 0 3px var(--border-selected); +` + +const THEME_STYLE_ID = "oc-theme" + +function ensureThemeStyleElement(): HTMLStyleElement { + const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null + if (existing) { + return existing + } + const element = document.createElement("style") + element.id = THEME_STYLE_ID + document.head.appendChild(element) + return element +} + +/** + * Resolve a mode from system preference + */ +function getSystemMode(): "light" | "dark" { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" +} + +/** + * Apply theme CSS to the document + */ +function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark"): void { + const isDark = mode === "dark" + const variant = isDark ? theme.dark : theme.light + const tokens = resolveThemeVariant(variant, isDark) + const css = themeToCss(tokens) + + // Cache to localStorage for preload script + const cacheKey = getThemeCacheKey(themeId, mode) + try { + localStorage.setItem(cacheKey, css) + } catch { + // localStorage might be full or disabled + } + + // Build full CSS + const fullCss = `:root { + ${STATIC_TOKENS} + color-scheme: ${mode}; + --text-mix-blend-mode: ${isDark ? "plus-lighter" : "multiply"}; + ${css} +}` + + // Remove preload style if it exists + const preloadStyle = document.getElementById("oc-theme-preload") + if (preloadStyle) { + preloadStyle.remove() + } + + const themeStyleElement = ensureThemeStyleElement() + themeStyleElement.textContent = fullCss + + // Update data attributes + document.documentElement.dataset.theme = themeId + document.documentElement.dataset.colorScheme = mode +} + +/** + * Cache both light and dark variants of a theme + */ +function cacheThemeVariants(theme: DesktopTheme, themeId: string): void { + for (const mode of ["light", "dark"] as const) { + const isDark = mode === "dark" + const variant = isDark ? theme.dark : theme.light + const tokens = resolveThemeVariant(variant, isDark) + const css = themeToCss(tokens) + const cacheKey = getThemeCacheKey(themeId, mode) + try { + localStorage.setItem(cacheKey, css) + } catch { + // localStorage might be full or disabled + } + } +} + +export function ThemeProvider(props: { children: JSX.Element; defaultTheme?: string }) { + const [themes, setThemes] = createSignal>(DEFAULT_THEMES) + const [themeId, setThemeIdSignal] = createSignal(props.defaultTheme ?? "oc-1") + const [colorScheme, setColorSchemeSignal] = createSignal("system") + const [mode, setMode] = createSignal<"light" | "dark">(getSystemMode()) + + // Listen for system color scheme changes + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handler = () => { + if (colorScheme() === "system") { + setMode(getSystemMode()) + } + } + mediaQuery.addEventListener("change", handler) + onCleanup(() => mediaQuery.removeEventListener("change", handler)) + + // Load saved preferences + const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) + const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null + + if (savedTheme && themes()[savedTheme]) { + setThemeIdSignal(savedTheme) + } + + if (savedScheme) { + setColorSchemeSignal(savedScheme) + if (savedScheme !== "system") { + setMode(savedScheme) + } + } + + // Cache current theme variants for future preloads + const currentTheme = themes()[themeId()] + if (currentTheme) { + cacheThemeVariants(currentTheme, themeId()) + } + }) + + // Apply theme when themeId or mode changes + createEffect(() => { + const id = themeId() + const m = mode() + const theme = themes()[id] + if (theme) { + applyThemeCss(theme, id, m) + } + }) + + const setTheme = (id: string) => { + const theme = themes()[id] + if (!theme) { + console.warn(`Theme "${id}" not found`) + return + } + + setThemeIdSignal(id) + localStorage.setItem(STORAGE_KEYS.THEME_ID, id) + + // Cache both variants for future preloads + cacheThemeVariants(theme, id) + } + + const setColorSchemePref = (scheme: ColorScheme) => { + setColorSchemeSignal(scheme) + localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + + if (scheme === "system") { + setMode(getSystemMode()) + } else { + setMode(scheme) + } + } + + const registerTheme = (theme: DesktopTheme) => { + setThemes((prev) => ({ + ...prev, + [theme.id]: theme, + })) + } + + return ( + + {props.children} + + ) +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext) + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider") + } + return ctx +} diff --git a/packages/ui/src/theme/default-themes.ts b/packages/ui/src/theme/default-themes.ts new file mode 100644 index 000000000..749d5e97c --- /dev/null +++ b/packages/ui/src/theme/default-themes.ts @@ -0,0 +1,35 @@ +import type { DesktopTheme } from "./types" +import oc1ThemeJson from "./themes/oc-1.json" +import tokyoThemeJson from "./themes/tokyonight.json" +import draculaThemeJson from "./themes/dracula.json" +import monokaiThemeJson from "./themes/monokai.json" +import solarizedThemeJson from "./themes/solarized.json" +import nordThemeJson from "./themes/nord.json" +import catppuccinThemeJson from "./themes/catppuccin.json" +import ayuThemeJson from "./themes/ayu.json" +import oneDarkProThemeJson from "./themes/onedarkpro.json" +import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json" + +export const oc1Theme = oc1ThemeJson as DesktopTheme +export const tokyonightTheme = tokyoThemeJson as DesktopTheme +export const draculaTheme = draculaThemeJson as DesktopTheme +export const monokaiTheme = monokaiThemeJson as DesktopTheme +export const solarizedTheme = solarizedThemeJson as DesktopTheme +export const nordTheme = nordThemeJson as DesktopTheme +export const catppuccinTheme = catppuccinThemeJson as DesktopTheme +export const ayuTheme = ayuThemeJson as DesktopTheme +export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme +export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme + +export const DEFAULT_THEMES: Record = { + "oc-1": oc1Theme, + tokyonight: tokyonightTheme, + dracula: draculaTheme, + monokai: monokaiTheme, + solarized: solarizedTheme, + nord: nordTheme, + catppuccin: catppuccinTheme, + ayu: ayuTheme, + onedarkpro: oneDarkProTheme, + shadesofpurple: shadesOfPurpleTheme, +} diff --git a/packages/ui/src/theme/desktop-theme.schema.json b/packages/ui/src/theme/desktop-theme.schema.json new file mode 100644 index 000000000..b60a8f37c --- /dev/null +++ b/packages/ui/src/theme/desktop-theme.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://opencode.ai/desktop-theme.json", + "title": "OpenCode Desktop Theme", + "description": "A theme definition for the OpenCode desktop application", + "type": "object", + "required": ["name", "id", "light", "dark"], + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference" + }, + "name": { + "type": "string", + "description": "Human-readable theme name" + }, + "id": { + "type": "string", + "description": "Unique theme identifier (slug)", + "pattern": "^[a-z0-9-]+$" + }, + "light": { + "$ref": "#/definitions/ThemeVariant", + "description": "Light mode color variant" + }, + "dark": { + "$ref": "#/definitions/ThemeVariant", + "description": "Dark mode color variant" + } + }, + "definitions": { + "HexColor": { + "type": "string", + "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", + "description": "A hex color value like #fff, #ffff, #ffffff, or #ffffffff" + }, + "ColorValue": { + "type": "string", + "pattern": "^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|var\(--[a-z0-9-]+\))$", + "description": "Either a hex color value (#rgb/#rgba/#rrggbb/#rrggbbaa) or a CSS variable reference" + }, + "ThemeSeedColors": { + "type": "object", + "description": "The minimum set of colors needed to generate a theme", + "required": ["neutral", "primary", "success", "warning", "error", "info", "interactive", "diffAdd", "diffDelete"], + "properties": { + "neutral": { + "$ref": "#/definitions/HexColor", + "description": "Base neutral color for generating the gray scale" + }, + "primary": { + "$ref": "#/definitions/HexColor", + "description": "Primary brand/accent color" + }, + "success": { + "$ref": "#/definitions/HexColor", + "description": "Success state color (typically green)" + }, + "warning": { + "$ref": "#/definitions/HexColor", + "description": "Warning state color (typically yellow/orange)" + }, + "error": { + "$ref": "#/definitions/HexColor", + "description": "Error/critical state color (typically red)" + }, + "info": { + "$ref": "#/definitions/HexColor", + "description": "Informational state color (typically purple/blue)" + }, + "interactive": { + "$ref": "#/definitions/HexColor", + "description": "Interactive element color (links, buttons)" + }, + "diffAdd": { + "$ref": "#/definitions/HexColor", + "description": "Color for diff additions" + }, + "diffDelete": { + "$ref": "#/definitions/HexColor", + "description": "Color for diff deletions" + } + } + }, + "ThemeVariant": { + "type": "object", + "description": "A theme variant (light or dark) with seed colors and optional overrides", + "required": ["seeds"], + "properties": { + "seeds": { + "$ref": "#/definitions/ThemeSeedColors", + "description": "Seed colors used to generate the full palette" + }, + "overrides": { + "type": "object", + "description": "Optional direct overrides for any CSS variable (without -- prefix)", + "additionalProperties": { + "$ref": "#/definitions/ColorValue" + } + } + } + } + } +} diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts new file mode 100644 index 000000000..8f3da4ca1 --- /dev/null +++ b/packages/ui/src/theme/index.ts @@ -0,0 +1,71 @@ +/** + * Desktop Theme System + * + * Provides JSON-based theming for the desktop app. Unlike TUI themes, + * desktop themes use more design tokens and generate full color scales + * from seed colors. + * + * Usage: + * ```ts + * import { applyTheme } from "@opencode/ui/theme" + * import myTheme from "./themes/my-theme.json" + * + * applyTheme(myTheme) + * ``` + */ + +// Types +export type { + DesktopTheme, + ThemeSeedColors, + ThemeVariant, + HexColor, + OklchColor, + ResolvedTheme, + ColorValue, + CssVarRef, +} from "./types" + +// Color utilities +export { + hexToRgb, + rgbToHex, + hexToOklch, + oklchToHex, + rgbToOklch, + oklchToRgb, + generateScale, + generateNeutralScale, + generateAlphaScale, + mixColors, + lighten, + darken, + withAlpha, +} from "./color" + +// Theme resolution +export { resolveThemeVariant, resolveTheme, themeToCss } from "./resolve" + +// Theme loader +export { applyTheme, loadThemeFromUrl, getActiveTheme, removeTheme, setColorScheme } from "./loader" + +// Theme context (SolidJS) +export { ThemeProvider, useTheme, type ColorScheme } from "./context" + +// Preload script utilities +export { generatePreloadScript, generatePreloadScriptFormatted, STORAGE_KEYS, getThemeCacheKey } from "./preload" + +// Default themes +export { + DEFAULT_THEMES, + oc1Theme, + tokyonightTheme, + draculaTheme, + monokaiTheme, + solarizedTheme, + nordTheme, + catppuccinTheme, + ayuTheme, + oneDarkProTheme, + shadesOfPurpleTheme, +} from "./default-themes" diff --git a/packages/ui/src/theme/loader.ts b/packages/ui/src/theme/loader.ts new file mode 100644 index 000000000..b25c833dd --- /dev/null +++ b/packages/ui/src/theme/loader.ts @@ -0,0 +1,213 @@ +/** + * Theme loader - loads theme JSON files and applies them to the DOM. + */ + +import type { DesktopTheme, ResolvedTheme } from "./types" +import { resolveThemeVariant, themeToCss } from "./resolve" + +/** Currently active theme */ +let activeTheme: DesktopTheme | null = null + +const THEME_STYLE_ID = "opencode-theme" + +function ensureLoaderStyleElement(): HTMLStyleElement { + const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null + if (existing) { + return existing + } + const element = document.createElement("style") + element.id = THEME_STYLE_ID + document.head.appendChild(element) + return element +} + +/** + * Load and apply a theme to the document. + * Creates or updates a