From e8d9fb4e51ec7a4c17cc61f515e0bd304dd0a934 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 19 Jul 2026 18:37:06 +0800 Subject: [PATCH] feat(desktop): add data-driven theme system --- desktop/.gitignore | 3 + desktop/package.json | 9 +- desktop/scripts/apply-flavor.mjs | 68 +++++++ desktop/src/main/index.ts | 2 + desktop/src/main/preload.ts | 8 + desktop/src/main/themes.ts | 195 +++++++++++++++++++ desktop/src/renderer/src/hooks/useTheme.ts | 197 ++++++++++++++++++-- desktop/src/renderer/src/i18n.ts | 2 + desktop/src/renderer/src/index.css | 108 +++++++++++ desktop/src/renderer/src/layout/NavRail.tsx | 45 ++++- desktop/src/renderer/src/main.tsx | 4 + desktop/src/renderer/src/pages/ChatPage.tsx | 2 +- desktop/src/renderer/src/theme/themes.ts | 142 ++++++++++++++ desktop/src/renderer/src/types.ts | 6 + desktop/tailwind.config.js | 10 +- 15 files changed, 778 insertions(+), 23 deletions(-) create mode 100644 desktop/scripts/apply-flavor.mjs create mode 100644 desktop/src/main/themes.ts create mode 100644 desktop/src/renderer/src/theme/themes.ts diff --git a/desktop/.gitignore b/desktop/.gitignore index 7824fd01..82f5ee9b 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -4,3 +4,6 @@ release/ *.log .DS_Store Thumbs.db +flavors/ +resources/app-config.json +resources/themes/ diff --git a/desktop/package.json b/desktop/package.json index 6fb438a8..b77d9450 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -15,7 +15,10 @@ "build:renderer": "vite build", "dist": "npm run build && electron-builder", "dist:mac": "npm run build && electron-builder --mac", - "dist:win": "npm run build && electron-builder --win" + "dist:win": "npm run build && electron-builder --win", + "flavor:apply": "node scripts/apply-flavor.mjs", + "flavor:clear": "node scripts/apply-flavor.mjs --clear", + "dist:flavor": "node scripts/apply-flavor.mjs $FLAVOR && npm run build && electron-builder && node scripts/apply-flavor.mjs --clear" }, "dependencies": { "electron-updater": "^6.8.9", @@ -60,7 +63,9 @@ "from": "resources", "to": ".", "filter": [ - "icon.png" + "icon.png", + "app-config.json", + "themes/**" ] } ], diff --git a/desktop/scripts/apply-flavor.mjs b/desktop/scripts/apply-flavor.mjs new file mode 100644 index 00000000..b9c1b3f6 --- /dev/null +++ b/desktop/scripts/apply-flavor.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// Stage or clear a flavor into resources/ before packaging. +// +// A flavor lives in flavors// and may contain: +// app-config.json → copied to resources/app-config.json +// themes//... → copied to resources/themes//... +// +// Usage: +// node scripts/apply-flavor.mjs # stage flavors/ into resources/ +// node scripts/apply-flavor.mjs --clear # remove staged app-config.json + themes/ +// +// The standard build ships neither app-config.json nor resources/themes, so it +// keeps the default theme and free switching. Always --clear after a flavored +// build so the repo's resources/ stays clean. + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(here, '..') +const resourcesDir = path.join(root, 'resources') +const configFile = path.join(resourcesDir, 'app-config.json') +const resThemesDir = path.join(resourcesDir, 'themes') + +function rimraf(target) { + fs.rmSync(target, { recursive: true, force: true }) +} + +function copyDir(src, dest) { + fs.mkdirSync(dest, { recursive: true }) + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name) + const d = path.join(dest, entry.name) + if (entry.isDirectory()) copyDir(s, d) + else fs.copyFileSync(s, d) + } +} + +function clear() { + rimraf(configFile) + rimraf(resThemesDir) + console.log('[flavor] cleared resources/app-config.json and resources/themes') +} + +function apply(name) { + const flavorDir = path.join(root, 'flavors', name) + if (!fs.existsSync(flavorDir)) { + console.error(`[flavor] no such flavor: flavors/${name}`) + process.exit(1) + } + clear() + const srcConfig = path.join(flavorDir, 'app-config.json') + if (fs.existsSync(srcConfig)) { + fs.mkdirSync(resourcesDir, { recursive: true }) + fs.copyFileSync(srcConfig, configFile) + console.log(`[flavor] staged app-config.json from flavors/${name}`) + } + const srcThemes = path.join(flavorDir, 'themes') + if (fs.existsSync(srcThemes)) { + copyDir(srcThemes, resThemesDir) + console.log(`[flavor] staged themes from flavors/${name}`) + } +} + +const arg = process.argv[2] +if (!arg || arg === '--clear') clear() +else apply(arg) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 5f9d1301..586dff2f 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -6,6 +6,7 @@ import { PythonBackend } from './python-manager' import { buildAppMenu } from './menu' import { createTray, destroyTray } from './tray' import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater' +import { setupThemeIPC } from './themes' // Force the product name so the Dock/menu shows "CowAgent" even in dev mode, // where the default Electron binary would otherwise report "Electron". @@ -306,6 +307,7 @@ app.whenReady().then(async () => { } setupIPC() + setupThemeIPC() createWindow() buildAppMenu(() => mainWindow) // No menu-bar tray on macOS — the Dock + window controls are enough there. diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts index b0fcfacb..2eb22201 100644 --- a/desktop/src/main/preload.ts +++ b/desktop/src/main/preload.ts @@ -43,6 +43,14 @@ contextBridge.exposeInMainWorld('electronAPI', { // Current app version (e.g. "0.0.5"), shown in the NavRail footer. getAppVersion: () => ipcRenderer.invoke('get-app-version'), + // Themes (bundled + user themes from ~/.cow/themes), assets inlined. + listThemes: () => ipcRenderer.invoke('themes-list') as Promise[]>, + getThemesDir: () => ipcRenderer.invoke('themes-dir') as Promise, + // Optional app config (first-run default theme + display name). Null in + // the standard build. + getAppConfig: () => + ipcRenderer.invoke('app-config-get') as Promise<{ defaultTheme?: string; appName?: string } | null>, + // Auto-update: trigger checks/download/install and subscribe to status. The // optional lang routes installer downloads to the China CDN mirror (zh) or R2. checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang), diff --git a/desktop/src/main/themes.ts b/desktop/src/main/themes.ts new file mode 100644 index 00000000..8d0d1c9e --- /dev/null +++ b/desktop/src/main/themes.ts @@ -0,0 +1,195 @@ +import { app, ipcMain } from 'electron' +import path from 'path' +import fs from 'fs' +import os from 'os' + +// Themes come from two sources and are merged at load time: +// +// 1. Bundled themes — resources/themes/ shipped inside the app package +// (read-only). Present only in builds produced from a flavor; absent +// in the standard build. +// 2. User themes — ~/.cow/themes/, the shared data dir users can add to +// (via a future in-app store/import). Each theme is its own folder: +// +// / +// ├── theme.json (required) +// ├── wallpaper.jpg (optional) +// └── logo.svg (optional) +// +// An optional app config (resources/app-config.json) can set a first-run +// default theme and a display name. When it's absent the app behaves exactly +// as the standard build (default theme, free switching). + +const THEMES_DIRNAME = 'themes' +// Max bytes for an inlined image (mirrors the theme spec limit). +const MAX_IMAGE_BYTES = 16 * 1024 * 1024 +const IMAGE_MIME: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.gif': 'image/gif', +} + +function cowRoot(): string { + // Honor an explicit override, else default to ~/.cow to match the backend. + return process.env.COW_HOME || path.join(os.homedir(), '.cow') +} + +export function themesDir(): string { + return path.join(cowRoot(), THEMES_DIRNAME) +} + +// Directory holding themes bundled inside the app package (read-only). In dev +// it maps to the repo's resources/; in a packaged app to process.resourcesPath. +// Returns null when that folder doesn't exist (the standard build). +function bundledThemesDir(): string | null { + const base = app.isPackaged + ? path.join(process.resourcesPath, THEMES_DIRNAME) + : path.resolve(__dirname, '../../resources', THEMES_DIRNAME) + try { + return fs.statSync(base).isDirectory() ? base : null + } catch { + return null + } +} + +// Optional app config bundled with the app. Absent in the standard build. +export interface AppConfig { + defaultTheme?: string + appName?: string +} + +function appConfigPath(): string { + return app.isPackaged + ? path.join(process.resourcesPath, 'app-config.json') + : path.resolve(__dirname, '../../resources', 'app-config.json') +} + +export function loadAppConfig(): AppConfig | null { + try { + const raw = fs.readFileSync(appConfigPath(), 'utf8') + const parsed = JSON.parse(raw) as AppConfig + if (!parsed || typeof parsed !== 'object') return null + return parsed + } catch { + return null // no app config → standard behavior + } +} + +function ensureThemesDir(): string { + const dir = themesDir() + try { + fs.mkdirSync(dir, { recursive: true }) + } catch { + // Non-fatal: scanning will simply return no themes. + } + return dir +} + +// Read an image inside a theme folder and return a data: URL, or null. The +// path is constrained to the theme folder to avoid escaping via '..'. +function inlineImage(themeFolder: string, rel: string): string | null { + if (!rel || typeof rel !== 'string') return null + const resolved = path.resolve(themeFolder, rel) + if (resolved !== themeFolder && !resolved.startsWith(themeFolder + path.sep)) return null + let stat: fs.Stats + try { + stat = fs.statSync(resolved) + } catch { + return null + } + if (!stat.isFile() || stat.size === 0 || stat.size > MAX_IMAGE_BYTES) return null + const ext = path.extname(resolved).toLowerCase() + const mime = IMAGE_MIME[ext] + if (!mime) return null + try { + const buf = fs.readFileSync(resolved) + return `data:${mime};base64,${buf.toString('base64')}` + } catch { + return null + } +} + +// Walk a theme object and replace any wallpaper/logo image *file references* +// with inlined data URLs so the renderer can use them directly. +function inlineThemeAssets(theme: Record, themeFolder: string) { + for (const appearance of ['light', 'dark'] as const) { + const app_ = theme[appearance] as Record | undefined + const wp = app_?.wallpaper as Record | undefined + if (wp && typeof wp.image === 'string') { + const url = inlineImage(themeFolder, wp.image) + if (url) wp.image = url + else delete wp.image + } + } + const identity = theme.identity as Record | undefined + if (identity && typeof identity.logo === 'string') { + const url = inlineImage(themeFolder, identity.logo) + if (url) identity.logo = url + else delete identity.logo + } +} + +// Scan one directory for theme folders and return validated, asset-inlined themes. +function scanDir(dir: string): Record[] { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + return [] + } + const themes: Record[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + const folder = path.join(dir, entry.name) + const jsonPath = path.join(folder, 'theme.json') + let raw: string + try { + raw = fs.readFileSync(jsonPath, 'utf8') + } catch { + continue // no theme.json in this folder + } + let theme: Record + try { + theme = JSON.parse(raw) + } catch (e) { + console.warn(`[themes] invalid theme.json in ${entry.name}:`, (e as Error).message) + continue + } + // Default the id to the folder name so it's always stable/unique. + if (!theme.id || typeof theme.id !== 'string') theme.id = entry.name + if (!theme.name || typeof theme.name !== 'string') theme.name = String(theme.id) + inlineThemeAssets(theme, folder) + themes.push(theme) + } + return themes +} + +// Merge bundled themes (read-only, shipped in the package) with user themes +// (~/.cow/themes). Bundled themes take precedence on id conflicts so a shipped +// theme can't be shadowed by a user folder of the same id. +export function loadThemes(): Record[] { + ensureThemesDir() + const byId = new Map>() + for (const theme of scanDir(themesDir())) byId.set(String(theme.id), theme) + const bundled = bundledThemesDir() + if (bundled) { + for (const theme of scanDir(bundled)) byId.set(String(theme.id), theme) + } + return [...byId.values()] +} + +export function setupThemeIPC() { + ipcMain.handle('themes-list', () => { + try { + return loadThemes() + } catch (e) { + console.warn('[themes] load failed:', (e as Error).message) + return [] + } + }) + ipcMain.handle('themes-dir', () => themesDir()) + ipcMain.handle('app-config-get', () => loadAppConfig()) +} diff --git a/desktop/src/renderer/src/hooks/useTheme.ts b/desktop/src/renderer/src/hooks/useTheme.ts index 0f496b15..a7ada74a 100644 --- a/desktop/src/renderer/src/hooks/useTheme.ts +++ b/desktop/src/renderer/src/hooks/useTheme.ts @@ -1,57 +1,228 @@ import { useState, useEffect, useCallback } from 'react' +import { + COLOR_KEYS, + DEFAULT_THEME_ID, + getAllThemes, + getTheme, + registerRuntimeThemes, + SHAPE_KEYS, + tokenToCssVar, + type Theme, + type Wallpaper, +} from '../theme/themes' export type ThemePref = 'light' | 'dark' | 'system' export type ResolvedTheme = 'light' | 'dark' -const STORAGE_KEY = 'cow_theme' +// Appearance preference (light/dark/system). +const PREF_KEY = 'cow_theme' +// Selected theme id (which visual theme is active). +const THEME_ID_KEY = 'cow_theme_id' function getSystemTheme(): ResolvedTheme { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } -function readStored(): ThemePref { - const saved = localStorage.getItem(STORAGE_KEY) +function readStoredPref(): ThemePref { + const saved = localStorage.getItem(PREF_KEY) if (saved === 'dark' || saved === 'light' || saved === 'system') return saved // First run: follow the OS appearance rather than forcing a fixed theme. return 'system' } -function applyTheme(resolved: ResolvedTheme) { +function readStoredThemeId(): string { + return localStorage.getItem(THEME_ID_KEY) || DEFAULT_THEME_ID +} + +// Whether the user has ever explicitly chosen a theme. Used so a bundled +// first-run default only applies when the user hasn't made a choice yet. +function hasStoredThemeId(): boolean { + return localStorage.getItem(THEME_ID_KEY) != null +} + +// All CSS variables a theme may inject, so we can fully reset before +// applying one (prevents stale overrides from leaking across switches). +const WALLPAPER_VARS = [ + '--wallpaper-image', + '--wallpaper-position', + '--wallpaper-overlay', + '--glass-fill', + '--glass-blur', + '--surface-alpha', +] as const + +function hexToRgb(hex: string): [number, number, number] | null { + const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()) + if (!m) return null + const n = parseInt(m[1], 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} + +// Apply the resolved appearance (light/dark) as the .dark class, plus the +// active theme's overrides. Themes are pure data: we only write contract +// tokens as inline CSS variables on (and toggle a wallpaper flag on +// ), so components restyle without any code change. +function applyAppearanceAndTheme(resolved: ResolvedTheme, themeId: string) { const root = document.documentElement root.classList.toggle('dark', resolved === 'dark') + + const theme = getTheme(themeId) + if (theme.id === DEFAULT_THEME_ID) root.removeAttribute('data-theme') + else root.setAttribute('data-theme', theme.id) + + activeSurface = { + light: theme.light?.colors?.bgSurface, + dark: theme.dark?.colors?.bgSurface, + } + + // Reset everything a theme can touch, then re-apply the active one. + for (const key of COLOR_KEYS) root.style.removeProperty(tokenToCssVar(key)) + for (const key of SHAPE_KEYS) root.style.removeProperty(tokenToCssVar(key)) + for (const v of WALLPAPER_VARS) root.style.removeProperty(v) + + // Shape tokens are appearance-independent. + if (theme.shape) { + for (const [k, v] of Object.entries(theme.shape)) { + if (v) root.style.setProperty(tokenToCssVar(k), v) + } + } + + const appearance = resolved === 'dark' ? theme.dark : theme.light + if (appearance?.colors) { + for (const [k, v] of Object.entries(appearance.colors)) { + if (v) root.style.setProperty(tokenToCssVar(k), v) + } + } + + applyWallpaper(resolved, appearance?.wallpaper) +} + +// Render (or clear) the ambient wallpaper + frosted-glass panels. +function applyWallpaper(resolved: ResolvedTheme, wp?: Wallpaper) { + const root = document.documentElement + const body = document.body + if (!wp?.image) { + body.removeAttribute('data-wallpaper') + return + } + body.setAttribute('data-wallpaper', 'on') + root.style.setProperty('--wallpaper-image', `url("${wp.image}")`) + + const fx = clamp01(wp.focusX ?? 0.5) * 100 + const fy = clamp01(wp.focusY ?? 0.5) * 100 + root.style.setProperty('--wallpaper-position', `${fx}% ${fy}%`) + + // Default scrim: darker in dark mode, lighter in light mode. + const opacity = clamp01(wp.overlayOpacity ?? (resolved === 'dark' ? 0.4 : 0.5)) + const scrim = resolved === 'dark' ? '0, 0, 0' : '255, 255, 255' + root.style.setProperty('--wallpaper-overlay', `rgba(${scrim}, ${opacity})`) + + if (wp.glass) { + // Frosted glass: surfaces become translucent (so the wallpaper shows + // through) + blurred. We derive the tint from the theme's own surface + // color so it stays on-palette, then drive alpha via --surface-alpha. + const themeSurface = + (resolved === 'dark' ? getThemeSurface('dark') : getThemeSurface('light')) ?? + (resolved === 'dark' ? '#141416' : '#ffffff') + const rgb = hexToRgb(themeSurface) ?? (resolved === 'dark' ? [20, 20, 22] : [255, 255, 255]) + const alpha = resolved === 'dark' ? 0.55 : 0.62 + root.style.setProperty('--glass-fill', `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`) + root.style.setProperty('--glass-blur', '20px') + // Also make the generic surface tokens translucent so existing cards + // (bg-surface) read as glass without touching component code. + root.style.setProperty('--surface-alpha', String(alpha)) + } +} + +// Cache of the active theme's surface color for glass tinting. +let activeSurface: { light?: string; dark?: string } = {} +function getThemeSurface(mode: 'light' | 'dark'): string | undefined { + return activeSurface[mode] +} + +function clamp01(n: number): number { + return Math.min(1, Math.max(0, n)) +} + +// Apply the persisted appearance + theme once, before React renders, so the +// first paint already has the right colors (no flash of the default theme). +export function initThemeEarly() { + const pref = readStoredPref() + const resolved: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref + applyAppearanceAndTheme(resolved, readStoredThemeId()) } export function useTheme() { - const [pref, setPref] = useState(readStored) + const [pref, setPref] = useState(readStoredPref) + const [themeId, setThemeIdState] = useState(readStoredThemeId) + const [themes, setThemes] = useState(getAllThemes) const [resolved, setResolved] = useState(() => - readStored() === 'system' ? getSystemTheme() : (readStored() as ResolvedTheme) + readStoredPref() === 'system' ? getSystemTheme() : (readStoredPref() as ResolvedTheme) ) + // Display name; a bundled app config may override the default. + const [appName, setAppName] = useState('CowAgent') + // Snapshot before any effect persists a value, so we can tell a genuine + // first run (no prior choice) from a user who explicitly picked a theme. + const [firstRun] = useState(() => !hasStoredThemeId()) + // Load themes (bundled + user) and the optional app config once on mount. + // On a genuine first run, apply the config's default theme if one is set; + // otherwise just re-apply the current selection now its definition is loaded. + useEffect(() => { + let cancelled = false + Promise.all([ + window.electronAPI?.listThemes?.() ?? Promise.resolve([]), + window.electronAPI?.getAppConfig?.() ?? Promise.resolve(null), + ]) + .then(([remote, config]) => { + if (cancelled) return + registerRuntimeThemes(remote) + setThemes(getAllThemes()) + if (config?.appName) setAppName(config.appName) + + // First-run default from the app config (if the theme exists). + if (firstRun && config?.defaultTheme && getTheme(config.defaultTheme).id === config.defaultTheme) { + setThemeIdState(config.defaultTheme) // triggers the apply effect below + return + } + // Re-apply the current selection now that its definition is loaded. + const next: ResolvedTheme = readStoredPref() === 'system' ? getSystemTheme() : (readStoredPref() as ResolvedTheme) + applyAppearanceAndTheme(next, readStoredThemeId()) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [firstRun]) + + // Re-apply whenever the appearance preference or theme changes. useEffect(() => { const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref setResolved(next) - applyTheme(next) - localStorage.setItem(STORAGE_KEY, pref) - }, [pref]) + applyAppearanceAndTheme(next, themeId) + localStorage.setItem(PREF_KEY, pref) + localStorage.setItem(THEME_ID_KEY, themeId) + }, [pref, themeId]) - // Follow system changes only when preference is "system" + // Follow system changes only when preference is "system". useEffect(() => { if (pref !== 'system') return const mq = window.matchMedia('(prefers-color-scheme: dark)') const handler = () => { const next = getSystemTheme() setResolved(next) - applyTheme(next) + applyAppearanceAndTheme(next, themeId) } mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) - }, [pref]) + }, [pref, themeId]) const toggleTheme = useCallback(() => { setPref(resolved === 'dark' ? 'light' : 'dark') }, [resolved]) const setTheme = useCallback((next: ThemePref) => setPref(next), []) + const setThemeId = useCallback((next: string) => setThemeIdState(next), []) - return { theme: resolved, pref, toggleTheme, setTheme } + return { theme: resolved, pref, themeId, themes, appName, toggleTheme, setTheme, setThemeId } } diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index d38d90de..377e8eff 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -85,6 +85,7 @@ const translations: Record> = { menu_more: '更多', menu_theme_light: '浅色模式', menu_theme_dark: '深色模式', + menu_theme_picker: '主题', menu_language: '语言', menu_website: '官网', menu_docs: '文档中心', @@ -477,6 +478,7 @@ const translations: Record> = { menu_more: 'More', menu_theme_light: 'Light mode', menu_theme_dark: 'Dark mode', + menu_theme_picker: 'Theme', menu_language: 'Language', menu_website: 'Website', menu_docs: 'Documentation', diff --git a/desktop/src/renderer/src/index.css b/desktop/src/renderer/src/index.css index cf651243..7b0fd827 100644 --- a/desktop/src/renderer/src/index.css +++ b/desktop/src/renderer/src/index.css @@ -52,6 +52,27 @@ --ai-bubble-bg: transparent; --message-gap: 16px; + /* Shape tokens — themable (radius / fonts). Kept as variables so a + theme can reshape the whole UI without touching components. */ + --radius-card: 12px; + --radius-btn: 8px; + --radius-sm: 6px; + --font-sans: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + + /* Wallpaper layer — empty by default (solid theme). A theme sets these + to render an ambient background image behind the app. */ + --wallpaper-image: none; + --wallpaper-position: 50% 50%; + --wallpaper-overlay: rgba(255, 255, 255, 0); + /* Panel fill used by .glass surfaces; opaque by default so non-glass + themes look identical to today. */ + --glass-fill: var(--bg-surface); + --glass-blur: 0px; + /* 1 = fully opaque surfaces (default). A glass wallpaper theme lowers this + so bg-surface panels turn translucent and reveal the wallpaper. */ + --surface-alpha: 1; + color-scheme: light; } @@ -107,6 +128,93 @@ body { text-rendering: optimizeLegibility; } +body { + font-family: var(--font-sans); +} + +/* ============================================================ + Wallpaper layer — ambient background behind the whole app. + Rendered as a fixed pseudo-element so it never scrolls and + sits under all content. Invisible unless a theme sets + --wallpaper-image (default 'none'). + ============================================================ */ +#root { + position: relative; + isolation: isolate; +} + +body[data-wallpaper='on']::before { + content: ''; + position: fixed; + inset: 0; + z-index: -2; + background-image: var(--wallpaper-image); + background-size: cover; + background-position: var(--wallpaper-position); + background-repeat: no-repeat; + pointer-events: none; +} + +/* Scrim over the wallpaper to keep foreground readable. */ +body[data-wallpaper='on']::after { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + background: var(--wallpaper-overlay); + pointer-events: none; +} + +/* When a wallpaper is active, let the app base show it through. The three + structural panels use bg-base; make that fill translucent so the ambient + image reads behind the UI. Surfaces/cards stay controlled by their own + tokens (or .glass) for readability. Zero component changes needed. */ +body[data-wallpaper='on'] { + background: transparent; +} + +body[data-wallpaper='on'] .bg-base { + background-color: color-mix(in srgb, var(--bg-base) 45%, transparent); +} + +/* Glass mode (wallpaper + glass theme): turn the common surface panels + translucent + blurred so the wallpaper reads behind the whole UI, with a + subtle top border-highlight for a premium, layered material feel. All via + CSS only — components keep using bg-surface / bg-elevated unchanged. */ +body[data-wallpaper='on'] .bg-surface, +body[data-wallpaper='on'] .bg-elevated { + background-color: color-mix(in srgb, var(--bg-surface) calc(var(--surface-alpha) * 100%), transparent); + backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4); +} + +/* Slightly brighten default borders over a wallpaper so panels keep clean + edges against the artwork. Kept subtle — no inset shadows on every bordered + element (that would look noisy on inputs/dividers). */ +body[data-wallpaper='on'] .border-default { + border-color: color-mix(in srgb, var(--border-default) 78%, rgba(255, 255, 255, 0.12)); +} + +/* ---- New-chat home page under a wallpaper ------------------------ + When a wallpaper is active, the empty chat home keeps its original + centered layout; the suggestion cards just get a premium frosted-glass + treatment so they read cleanly over the artwork. Only the empty state. */ +body[data-wallpaper='on'] .chat-home .bg-surface { + background-color: color-mix(in srgb, var(--bg-surface) calc(var(--surface-alpha) * 100%), transparent); + backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18), inset 0 1px 0 0 rgba(255, 255, 255, 0.08); +} + +/* Frosted-glass panel: translucent fill + blur so the wallpaper shows + through. Opt-in via the .glass class; a theme toggles the fill/blur + variables. Falls back to a solid surface when glass is off. */ +.glass { + background: var(--glass-fill); + backdrop-filter: blur(var(--glass-blur)) saturate(1.4); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4); +} + /* Smooth theme transition (respect reduced motion) */ body, body * { diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx index f099b6ac..0add57ed 100644 --- a/desktop/src/renderer/src/layout/NavRail.tsx +++ b/desktop/src/renderer/src/layout/NavRail.tsx @@ -21,8 +21,11 @@ import { FileText, Store, MessageSquareWarning, + Palette, + Check, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' +import type { Theme } from '../theme/themes' // The desktop app's own brand icon (transparent PNG), bundled by Vite. import brandLogo from '../assets/logo.png' import { t, getLang, setLang, Lang } from '../i18n' @@ -76,7 +79,7 @@ const NavRail: React.FC = ({ onLangChange }) => { const location = useLocation() const navigate = useNavigate() const { navCollapsed, toggleNav } = useUIStore() - const { theme, toggleTheme } = useTheme() + const { theme, toggleTheme, themeId, themes, appName, setThemeId } = useTheme() // On macOS the top-left is occupied by the native traffic lights, so the // brand mark is only shown on Windows/Linux where that corner is otherwise // empty (mirrors the web console's sidebar logo). @@ -178,7 +181,7 @@ const NavRail: React.FC = ({ onLangChange }) => {
{!collapsed && ( - CowAgent + {appName} )}
)} @@ -230,6 +233,9 @@ const NavRail: React.FC = ({ onLangChange }) => { navigate('/logs') }} onTheme={toggleTheme} + themeId={themeId} + themes={themes} + onThemeId={setThemeId} onLanguage={toggleLanguage} onCheckUpdate={checkUpdate} onOpenLink={(url) => { @@ -312,12 +318,16 @@ const FooterMenu: React.FC<{ checking: boolean pendingUpdate: boolean upToDate: boolean + themeId: string + themes: Theme[] + onThemeId: (id: string) => void onLogs: () => void onTheme: () => void onLanguage: () => void onCheckUpdate: () => void onOpenLink: (url: string) => void -}> = ({ theme, checking, pendingUpdate, upToDate, onLogs, onTheme, onLanguage, onCheckUpdate, onOpenLink }) => { +}> = ({ theme, checking, pendingUpdate, upToDate, themeId, themes, onThemeId, onLogs, onTheme, onLanguage, onCheckUpdate, onOpenLink }) => { + const [themeMenuOpen, setThemeMenuOpen] = useState(false) const updateLabel = checking ? t('update_checking') : upToDate @@ -350,6 +360,24 @@ const FooterMenu: React.FC<{ label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')} onClick={onTheme} /> + } + label={t('menu_theme_picker')} + trailing={themeMenuOpen ? '▾' : '▸'} + onClick={() => setThemeMenuOpen((o) => !o)} + /> + {themeMenuOpen && + themes.map((th) => ( + + ))} } label={t('menu_language')} @@ -361,6 +389,17 @@ const FooterMenu: React.FC<{ ) } +// Tiny 3-color preview (bg / surface / accent) for the theme picker. +const ThemeSwatch: React.FC<{ preview: { accent: string; bg: string; surface: string } }> = ({ + preview, +}) => ( + + + + + +) + const MenuItem: React.FC<{ icon: React.ReactNode label: string diff --git a/desktop/src/renderer/src/main.tsx b/desktop/src/renderer/src/main.tsx index 158640ab..706a0750 100644 --- a/desktop/src/renderer/src/main.tsx +++ b/desktop/src/renderer/src/main.tsx @@ -2,8 +2,12 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { HashRouter } from 'react-router-dom' import App from './App' +import { initThemeEarly } from './hooks/useTheme' import './index.css' +// Apply persisted appearance + theme before first paint to avoid a flash. +initThemeEarly() + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/desktop/src/renderer/src/pages/ChatPage.tsx b/desktop/src/renderer/src/pages/ChatPage.tsx index 885da3ac..6dac5316 100644 --- a/desktop/src/renderer/src/pages/ChatPage.tsx +++ b/desktop/src/renderer/src/pages/ChatPage.tsx @@ -241,7 +241,7 @@ const ChatPage: React.FC = ({ baseUrl }) => { )} {isEmpty ? ( -
+
CowAgent

{t('chat_welcome')}

diff --git a/desktop/src/renderer/src/theme/themes.ts b/desktop/src/renderer/src/theme/themes.ts new file mode 100644 index 00000000..e79853da --- /dev/null +++ b/desktop/src/renderer/src/theme/themes.ts @@ -0,0 +1,142 @@ +// ============================================================ +// Theme Contract — single source of truth for themes. +// +// A "theme" is pure data: it overrides semantic design tokens +// (colors, radius, shadow, font) and an optional background +// wallpaper. It never touches component code or DOM structure, +// so themes stay stable across UI refactors — as long as +// components keep consuming these tokens, old themes keep working. +// +// Contract rule: themes may ONLY set fields defined here. Adding a +// new token is an additive, versioned change (bump THEME_SPEC_VERSION). +// +// Themes are NOT bundled into the app. They live in ~/.cow/themes// +// (theme.json + images) and are loaded at runtime via the main process, +// which inlines images as data URLs. The only built-in theme is 'default' +// (a pure-color fallback that always works offline). +// ============================================================ + +// ---- Color tokens (per-appearance) ------------------------- +export const COLOR_KEYS = [ + 'accent', + 'accentHover', + 'accentActive', + 'accentSoft', + 'accentContrast', + 'bgBase', + 'bgSurface', + 'bgSurface2', + 'bgElevated', + 'bgInset', + 'textPrimary', + 'textSecondary', + 'textTertiary', + 'textDisabled', + 'borderDefault', + 'borderStrong', + 'borderSubtle', + 'shadowSm', + 'shadowMd', + 'shadowLg', +] as const +export type ColorKey = (typeof COLOR_KEYS)[number] + +// ---- Shape tokens (appearance-independent) ----------------- +// Radius / font apply regardless of light or dark. +export const SHAPE_KEYS = ['radiusCard', 'radiusBtn', 'radiusSm', 'fontSans', 'fontMono'] as const +export type ShapeKey = (typeof SHAPE_KEYS)[number] + +// camelCase token -> --kebab-case CSS variable +export function tokenToCssVar(key: string): string { + return '--' + key.replace(/[A-Z0-9]/g, (m) => '-' + m.toLowerCase()) +} + +// ---- Wallpaper (Codex-style ambient background) ------------ +export interface Wallpaper { + // Image URL (bundled asset path or data/file URL). Empty = solid color. + image?: string + focusX?: number // 0..1 horizontal focal point, default 0.5 + focusY?: number // 0..1 vertical focal point, default 0.5 + overlayOpacity?: number // 0..1 scrim strength over the image, default per-appearance + // Panels become translucent + blurred (frosted glass) so the wallpaper + // shows through. When false, panels stay solid (wallpaper only behind base). + glass?: boolean +} + +export interface ThemeAppearance { + colors?: Partial> + wallpaper?: Wallpaper +} + +// Optional per-theme identity overrides (logo + display name). +export interface ThemeIdentity { + logo?: string // data URL (inlined by main) or asset URL + appName?: string +} + +export interface Theme { + id: string + name: string + specVersion?: number + // Optional preview swatch; when absent it's derived from colors. + preview?: { accent: string; bg: string; surface: string } + identity?: ThemeIdentity + // Appearance-independent shape tokens. + shape?: Partial> + // Per-appearance colors + wallpaper. + light?: ThemeAppearance + dark?: ThemeAppearance +} + +// Bump when the contract changes in a breaking way. theme.json files declare +// which version they target so imports can be validated. +export const THEME_SPEC_VERSION = 1 + +// 'default' uses the built-in :root / .dark values in index.css and +// applies no data-theme attribute nor overrides. +export const DEFAULT_THEME_ID = 'default' + +// The only built-in theme: 'default' maps to the base :root / .dark values in +// index.css (no overrides). Everything else is loaded at runtime from +// ~/.cow/themes. This keeps the app bundle free of theme assets. +export const DEFAULT_THEME: Theme = { + id: DEFAULT_THEME_ID, + name: 'CowAgent', + preview: { accent: '#4abe6e', bg: '#f9fafb', surface: '#ffffff' }, +} + +// Runtime registry: default first, then whatever was loaded from ~/.cow. +let runtimeThemes: Theme[] = [DEFAULT_THEME] + +// Basic shape validation so a malformed theme.json can't break the app. +function isValidTheme(x: unknown): x is Theme { + if (!x || typeof x !== 'object') return false + const s = x as Record + return typeof s.id === 'string' && s.id.length > 0 +} + +// Derive a preview swatch from a theme's colors when it didn't ship one. +function derivePreview(theme: Theme): { accent: string; bg: string; surface: string } { + if (theme.preview) return theme.preview + const c = theme.dark?.colors ?? theme.light?.colors ?? {} + return { + accent: c.accent ?? '#4abe6e', + bg: c.bgBase ?? '#111111', + surface: c.bgSurface ?? '#1c1c1f', + } +} + +// Replace the runtime theme list with default + validated remote themes. +export function registerRuntimeThemes(themes: unknown[]): void { + const valid = (themes ?? []).filter(isValidTheme).filter((t) => t.id !== DEFAULT_THEME_ID) + for (const t of valid) t.preview = derivePreview(t) + runtimeThemes = [DEFAULT_THEME, ...valid] +} + +export function getAllThemes(): Theme[] { + return runtimeThemes +} + +export function getTheme(id: string): Theme { + return runtimeThemes.find((t) => t.id === id) ?? runtimeThemes[0] +} diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index c3a02d69..21a1fcef 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -21,6 +21,12 @@ export interface ElectronAPI { onMenuAction?: (callback: (action: string) => void) => () => void // Current app version string (e.g. "0.0.5"). getAppVersion?: () => Promise + // Themes (bundled + user themes from ~/.cow/themes), images inlined. + listThemes?: () => Promise[]> + getThemesDir?: () => Promise + // Optional app config: first-run default theme + display name. Null when + // the build ships no app config (standard build). + getAppConfig?: () => Promise<{ defaultTheme?: string; appName?: string } | null> // Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN. checkForUpdate?: (lang?: string) => Promise downloadUpdate?: (lang?: string) => Promise diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 81789719..dc51e5b6 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -5,8 +5,9 @@ module.exports = { theme: { extend: { fontFamily: { - sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"', 'sans-serif'], - mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'], + // Driven by CSS variables so a skin can restyle typography. + sans: 'var(--font-sans)', + mono: 'var(--font-mono)', }, colors: { 'danger-soft': 'var(--danger-soft)', @@ -62,8 +63,9 @@ module.exports = { lg: 'var(--shadow-lg)', }, borderRadius: { - card: '12px', - btn: '8px', + card: 'var(--radius-card)', + btn: 'var(--radius-btn)', + sm: 'var(--radius-sm)', }, animation: { 'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',