mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 22:27:13 +08:00
feat(desktop): add data-driven theme system
This commit is contained in:
3
desktop/.gitignore
vendored
3
desktop/.gitignore
vendored
@@ -4,3 +4,6 @@ release/
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
flavors/
|
||||||
|
resources/app-config.json
|
||||||
|
resources/themes/
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
"dist:mac": "npm run build && electron-builder --mac",
|
"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": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
@@ -60,7 +63,9 @@
|
|||||||
"from": "resources",
|
"from": "resources",
|
||||||
"to": ".",
|
"to": ".",
|
||||||
"filter": [
|
"filter": [
|
||||||
"icon.png"
|
"icon.png",
|
||||||
|
"app-config.json",
|
||||||
|
"themes/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
68
desktop/scripts/apply-flavor.mjs
Normal file
68
desktop/scripts/apply-flavor.mjs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Stage or clear a flavor into resources/ before packaging.
|
||||||
|
//
|
||||||
|
// A flavor lives in flavors/<name>/ and may contain:
|
||||||
|
// app-config.json → copied to resources/app-config.json
|
||||||
|
// themes/<id>/... → copied to resources/themes/<id>/...
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/apply-flavor.mjs <name> # stage flavors/<name> 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)
|
||||||
@@ -6,6 +6,7 @@ import { PythonBackend } from './python-manager'
|
|||||||
import { buildAppMenu } from './menu'
|
import { buildAppMenu } from './menu'
|
||||||
import { createTray, destroyTray } from './tray'
|
import { createTray, destroyTray } from './tray'
|
||||||
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
|
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,
|
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
|
||||||
// where the default Electron binary would otherwise report "Electron".
|
// where the default Electron binary would otherwise report "Electron".
|
||||||
@@ -306,6 +307,7 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupIPC()
|
setupIPC()
|
||||||
|
setupThemeIPC()
|
||||||
createWindow()
|
createWindow()
|
||||||
buildAppMenu(() => mainWindow)
|
buildAppMenu(() => mainWindow)
|
||||||
// No menu-bar tray on macOS — the Dock + window controls are enough there.
|
// No menu-bar tray on macOS — the Dock + window controls are enough there.
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
|
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
|
||||||
|
// Themes (bundled + user themes from ~/.cow/themes), assets inlined.
|
||||||
|
listThemes: () => ipcRenderer.invoke('themes-list') as Promise<Record<string, unknown>[]>,
|
||||||
|
getThemesDir: () => ipcRenderer.invoke('themes-dir') as Promise<string>,
|
||||||
|
// 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
|
// Auto-update: trigger checks/download/install and subscribe to status. The
|
||||||
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
|
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
|
||||||
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
|
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
|
||||||
|
|||||||
195
desktop/src/main/themes.ts
Normal file
195
desktop/src/main/themes.ts
Normal file
@@ -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:
|
||||||
|
//
|
||||||
|
// <id>/
|
||||||
|
// ├── 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<string, string> = {
|
||||||
|
'.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<string, unknown>, themeFolder: string) {
|
||||||
|
for (const appearance of ['light', 'dark'] as const) {
|
||||||
|
const app_ = theme[appearance] as Record<string, unknown> | undefined
|
||||||
|
const wp = app_?.wallpaper as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>[] {
|
||||||
|
let entries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const themes: Record<string, unknown>[] = []
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>[] {
|
||||||
|
ensureThemesDir()
|
||||||
|
const byId = new Map<string, Record<string, unknown>>()
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -1,57 +1,228 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
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 ThemePref = 'light' | 'dark' | 'system'
|
||||||
export type ResolvedTheme = 'light' | 'dark'
|
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 {
|
function getSystemTheme(): ResolvedTheme {
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStored(): ThemePref {
|
function readStoredPref(): ThemePref {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const saved = localStorage.getItem(PREF_KEY)
|
||||||
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
||||||
// First run: follow the OS appearance rather than forcing a fixed theme.
|
// First run: follow the OS appearance rather than forcing a fixed theme.
|
||||||
return 'system'
|
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 <html> (and toggle a wallpaper flag on
|
||||||
|
// <body>), so components restyle without any code change.
|
||||||
|
function applyAppearanceAndTheme(resolved: ResolvedTheme, themeId: string) {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.classList.toggle('dark', resolved === 'dark')
|
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() {
|
export function useTheme() {
|
||||||
const [pref, setPref] = useState<ThemePref>(readStored)
|
const [pref, setPref] = useState<ThemePref>(readStoredPref)
|
||||||
|
const [themeId, setThemeIdState] = useState<string>(readStoredThemeId)
|
||||||
|
const [themes, setThemes] = useState<Theme[]>(getAllThemes)
|
||||||
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
|
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
|
||||||
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<string>('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(() => {
|
useEffect(() => {
|
||||||
const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
|
const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
|
||||||
setResolved(next)
|
setResolved(next)
|
||||||
applyTheme(next)
|
applyAppearanceAndTheme(next, themeId)
|
||||||
localStorage.setItem(STORAGE_KEY, pref)
|
localStorage.setItem(PREF_KEY, pref)
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (pref !== 'system') return
|
if (pref !== 'system') return
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
const next = getSystemTheme()
|
const next = getSystemTheme()
|
||||||
setResolved(next)
|
setResolved(next)
|
||||||
applyTheme(next)
|
applyAppearanceAndTheme(next, themeId)
|
||||||
}
|
}
|
||||||
mq.addEventListener('change', handler)
|
mq.addEventListener('change', handler)
|
||||||
return () => mq.removeEventListener('change', handler)
|
return () => mq.removeEventListener('change', handler)
|
||||||
}, [pref])
|
}, [pref, themeId])
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
setPref(resolved === 'dark' ? 'light' : 'dark')
|
setPref(resolved === 'dark' ? 'light' : 'dark')
|
||||||
}, [resolved])
|
}, [resolved])
|
||||||
|
|
||||||
const setTheme = useCallback((next: ThemePref) => setPref(next), [])
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
menu_more: '更多',
|
menu_more: '更多',
|
||||||
menu_theme_light: '浅色模式',
|
menu_theme_light: '浅色模式',
|
||||||
menu_theme_dark: '深色模式',
|
menu_theme_dark: '深色模式',
|
||||||
|
menu_theme_picker: '主题',
|
||||||
menu_language: '语言',
|
menu_language: '语言',
|
||||||
menu_website: '官网',
|
menu_website: '官网',
|
||||||
menu_docs: '文档中心',
|
menu_docs: '文档中心',
|
||||||
@@ -477,6 +478,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
menu_more: 'More',
|
menu_more: 'More',
|
||||||
menu_theme_light: 'Light mode',
|
menu_theme_light: 'Light mode',
|
||||||
menu_theme_dark: 'Dark mode',
|
menu_theme_dark: 'Dark mode',
|
||||||
|
menu_theme_picker: 'Theme',
|
||||||
menu_language: 'Language',
|
menu_language: 'Language',
|
||||||
menu_website: 'Website',
|
menu_website: 'Website',
|
||||||
menu_docs: 'Documentation',
|
menu_docs: 'Documentation',
|
||||||
|
|||||||
@@ -52,6 +52,27 @@
|
|||||||
--ai-bubble-bg: transparent;
|
--ai-bubble-bg: transparent;
|
||||||
--message-gap: 16px;
|
--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;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +128,93 @@ body {
|
|||||||
text-rendering: optimizeLegibility;
|
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) */
|
/* Smooth theme transition (respect reduced motion) */
|
||||||
body,
|
body,
|
||||||
body * {
|
body * {
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Store,
|
Store,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
|
Palette,
|
||||||
|
Check,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } 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.
|
// The desktop app's own brand icon (transparent PNG), bundled by Vite.
|
||||||
import brandLogo from '../assets/logo.png'
|
import brandLogo from '../assets/logo.png'
|
||||||
import { t, getLang, setLang, Lang } from '../i18n'
|
import { t, getLang, setLang, Lang } from '../i18n'
|
||||||
@@ -76,7 +79,7 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { navCollapsed, toggleNav } = useUIStore()
|
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
|
// 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
|
// brand mark is only shown on Windows/Linux where that corner is otherwise
|
||||||
// empty (mirrors the web console's sidebar logo).
|
// empty (mirrors the web console's sidebar logo).
|
||||||
@@ -178,7 +181,7 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
<div className="flex items-center gap-2 min-w-0 select-none">
|
<div className="flex items-center gap-2 min-w-0 select-none">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className="text-[14px] font-semibold text-content truncate">CowAgent</span>
|
<span className="text-[14px] font-semibold text-content truncate">{appName}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -230,6 +233,9 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
navigate('/logs')
|
navigate('/logs')
|
||||||
}}
|
}}
|
||||||
onTheme={toggleTheme}
|
onTheme={toggleTheme}
|
||||||
|
themeId={themeId}
|
||||||
|
themes={themes}
|
||||||
|
onThemeId={setThemeId}
|
||||||
onLanguage={toggleLanguage}
|
onLanguage={toggleLanguage}
|
||||||
onCheckUpdate={checkUpdate}
|
onCheckUpdate={checkUpdate}
|
||||||
onOpenLink={(url) => {
|
onOpenLink={(url) => {
|
||||||
@@ -312,12 +318,16 @@ const FooterMenu: React.FC<{
|
|||||||
checking: boolean
|
checking: boolean
|
||||||
pendingUpdate: boolean
|
pendingUpdate: boolean
|
||||||
upToDate: boolean
|
upToDate: boolean
|
||||||
|
themeId: string
|
||||||
|
themes: Theme[]
|
||||||
|
onThemeId: (id: string) => void
|
||||||
onLogs: () => void
|
onLogs: () => void
|
||||||
onTheme: () => void
|
onTheme: () => void
|
||||||
onLanguage: () => void
|
onLanguage: () => void
|
||||||
onCheckUpdate: () => void
|
onCheckUpdate: () => void
|
||||||
onOpenLink: (url: string) => 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
|
const updateLabel = checking
|
||||||
? t('update_checking')
|
? t('update_checking')
|
||||||
: upToDate
|
: upToDate
|
||||||
@@ -350,6 +360,24 @@ const FooterMenu: React.FC<{
|
|||||||
label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')}
|
label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')}
|
||||||
onClick={onTheme}
|
onClick={onTheme}
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<Palette size={16} />}
|
||||||
|
label={t('menu_theme_picker')}
|
||||||
|
trailing={themeMenuOpen ? '▾' : '▸'}
|
||||||
|
onClick={() => setThemeMenuOpen((o) => !o)}
|
||||||
|
/>
|
||||||
|
{themeMenuOpen &&
|
||||||
|
themes.map((th) => (
|
||||||
|
<button
|
||||||
|
key={th.id}
|
||||||
|
onClick={() => onThemeId(th.id)}
|
||||||
|
className="w-full flex items-center gap-2.5 pl-8 pr-3 h-9 text-[13px] text-content-secondary hover:bg-surface-2 hover:text-content cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<ThemeSwatch preview={th.preview ?? { accent: '#4abe6e', bg: '#111', surface: '#1c1c1f' }} />
|
||||||
|
<span className="flex-1 text-left truncate">{th.name}</span>
|
||||||
|
{themeId === th.id && <Check size={14} className="flex-shrink-0 text-accent" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<Languages size={16} />}
|
icon={<Languages size={16} />}
|
||||||
label={t('menu_language')}
|
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,
|
||||||
|
}) => (
|
||||||
|
<span className="flex-shrink-0 inline-flex h-4 w-4 rounded-full overflow-hidden border border-default">
|
||||||
|
<span className="w-1/3 h-full" style={{ background: preview.bg }} />
|
||||||
|
<span className="w-1/3 h-full" style={{ background: preview.surface }} />
|
||||||
|
<span className="w-1/3 h-full" style={{ background: preview.accent }} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
const MenuItem: React.FC<{
|
const MenuItem: React.FC<{
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { HashRouter } from 'react-router-dom'
|
import { HashRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { initThemeEarly } from './hooks/useTheme'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
// Apply persisted appearance + theme before first paint to avoid a flash.
|
||||||
|
initThemeEarly()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
<div data-home className="chat-home flex flex-col items-center justify-center h-full px-6 py-12">
|
||||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||||
|
|||||||
142
desktop/src/renderer/src/theme/themes.ts
Normal file
142
desktop/src/renderer/src/theme/themes.ts
Normal file
@@ -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/<id>/
|
||||||
|
// (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<Record<ColorKey, string>>
|
||||||
|
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<Record<ShapeKey, string>>
|
||||||
|
// 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<string, unknown>
|
||||||
|
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]
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ export interface ElectronAPI {
|
|||||||
onMenuAction?: (callback: (action: string) => void) => () => void
|
onMenuAction?: (callback: (action: string) => void) => () => void
|
||||||
// Current app version string (e.g. "0.0.5").
|
// Current app version string (e.g. "0.0.5").
|
||||||
getAppVersion?: () => Promise<string>
|
getAppVersion?: () => Promise<string>
|
||||||
|
// Themes (bundled + user themes from ~/.cow/themes), images inlined.
|
||||||
|
listThemes?: () => Promise<Record<string, unknown>[]>
|
||||||
|
getThemesDir?: () => Promise<string>
|
||||||
|
// 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.
|
// Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN.
|
||||||
checkForUpdate?: (lang?: string) => Promise<void>
|
checkForUpdate?: (lang?: string) => Promise<void>
|
||||||
downloadUpdate?: (lang?: string) => Promise<void>
|
downloadUpdate?: (lang?: string) => Promise<void>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"', 'sans-serif'],
|
// Driven by CSS variables so a skin can restyle typography.
|
||||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
|
sans: 'var(--font-sans)',
|
||||||
|
mono: 'var(--font-mono)',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
'danger-soft': 'var(--danger-soft)',
|
'danger-soft': 'var(--danger-soft)',
|
||||||
@@ -62,8 +63,9 @@ module.exports = {
|
|||||||
lg: 'var(--shadow-lg)',
|
lg: 'var(--shadow-lg)',
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
card: '12px',
|
card: 'var(--radius-card)',
|
||||||
btn: '8px',
|
btn: 'var(--radius-btn)',
|
||||||
|
sm: 'var(--radius-sm)',
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',
|
'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',
|
||||||
|
|||||||
Reference in New Issue
Block a user