mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 03:03:19 +08:00
feat(desktop): platform-aware shell, design tokens and three-column layout
This commit is contained in:
@@ -38,15 +38,50 @@ function getIconPath(ext: string = 'png'): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWin = process.platform === 'win32'
|
||||
|
||||
// Persisted window bounds
|
||||
const windowStateFile = () => path.join(app.getPath('userData'), 'window-state.json')
|
||||
|
||||
function loadWindowState(): { width: number; height: number; x?: number; y?: number } {
|
||||
try {
|
||||
const raw = fs.readFileSync(windowStateFile(), 'utf-8')
|
||||
const s = JSON.parse(raw)
|
||||
if (typeof s.width === 'number' && typeof s.height === 'number') return s
|
||||
} catch {
|
||||
/* first run or unreadable */
|
||||
}
|
||||
return { width: 1280, height: 800 }
|
||||
}
|
||||
|
||||
function saveWindowState() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
if (mainWindow.isMinimized() || mainWindow.isFullScreen()) return
|
||||
const b = mainWindow.getBounds()
|
||||
try {
|
||||
fs.writeFileSync(windowStateFile(), JSON.stringify(b))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const state = loadWindowState()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 12, y: 18 },
|
||||
backgroundColor: '#111111',
|
||||
// macOS: native traffic lights inset into our custom titlebar.
|
||||
// Windows: fully frameless; we render custom window controls in-app.
|
||||
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
|
||||
trafficLightPosition: isMac ? { x: 14, y: 16 } : undefined,
|
||||
frame: isMac ? undefined : false,
|
||||
backgroundColor: '#0e0e10',
|
||||
icon: getIconPath(),
|
||||
show: false,
|
||||
webPreferences: {
|
||||
@@ -56,6 +91,12 @@ function createWindow() {
|
||||
},
|
||||
})
|
||||
|
||||
const persist = () => saveWindowState()
|
||||
mainWindow.on('resize', persist)
|
||||
mainWindow.on('move', persist)
|
||||
mainWindow.on('maximize', emitMaximizeState)
|
||||
mainWindow.on('unmaximize', emitMaximizeState)
|
||||
|
||||
const rendererHtml = path.join(__dirname, '../renderer/index.html')
|
||||
|
||||
if (isDev) {
|
||||
@@ -143,6 +184,22 @@ function setupIPC() {
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
|
||||
// Custom window controls (used by Windows frameless titlebar)
|
||||
ipcMain.handle('window-minimize', () => mainWindow?.minimize())
|
||||
ipcMain.handle('window-maximize', () => {
|
||||
if (!mainWindow) return false
|
||||
if (mainWindow.isMaximized()) mainWindow.unmaximize()
|
||||
else mainWindow.maximize()
|
||||
return mainWindow.isMaximized()
|
||||
})
|
||||
ipcMain.handle('window-close', () => mainWindow?.close())
|
||||
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
|
||||
}
|
||||
|
||||
function emitMaximizeState() {
|
||||
const max = mainWindow?.isMaximized() ?? false
|
||||
mainWindow?.webContents.send('window-maximize-changed', max)
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
@@ -180,5 +237,6 @@ app.on('window-all-closed', () => {
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
saveWindowState()
|
||||
pythonBackend?.stop()
|
||||
})
|
||||
|
||||
@@ -15,5 +15,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('backend-log', (_event, line) => callback(line))
|
||||
},
|
||||
|
||||
// Window controls (custom titlebar on Windows)
|
||||
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
|
||||
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
|
||||
windowClose: () => ipcRenderer.invoke('window-close'),
|
||||
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
||||
onMaximizeChange: (callback: (maximized: boolean) => void) => {
|
||||
ipcRenderer.on('window-maximize-changed', (_event, max) => callback(max))
|
||||
},
|
||||
|
||||
platform: process.platform,
|
||||
})
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh" class="dark">
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
|
||||
<title>CowAgent</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
|
||||
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
|
||||
<link rel="stylesheet" href="./vendor/fontawesome/css/all.min.css" />
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('cow_theme') || 'dark';
|
||||
if (theme === 'dark') document.documentElement.classList.add('dark');
|
||||
// Resolve theme before first paint to avoid flash-of-wrong-theme.
|
||||
(function () {
|
||||
try {
|
||||
var pref = localStorage.getItem('cow_theme') || 'dark';
|
||||
var resolved = pref === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: pref;
|
||||
if (resolved === 'dark') document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { Routes, Route, useLocation } from 'react-router-dom'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import { PanelLeftOpen } from 'lucide-react'
|
||||
import NavRail from './layout/NavRail'
|
||||
import SessionList from './layout/SessionList'
|
||||
import WindowControls from './layout/WindowControls'
|
||||
import StatusScreen from './components/StatusScreen'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useBackend } from './hooks/useBackend'
|
||||
import { t, getLang, setLang, Lang } from './i18n'
|
||||
import { usePlatform } from './hooks/usePlatform'
|
||||
import { useUIStore } from './store/uiStore'
|
||||
import apiClient from './api/client'
|
||||
import { t } from './i18n'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import ConfigPage from './pages/ConfigPage'
|
||||
import SkillsPage from './pages/SkillsPage'
|
||||
@@ -12,99 +17,61 @@ import MemoryPage from './pages/MemoryPage'
|
||||
import ChannelsPage from './pages/ChannelsPage'
|
||||
import TasksPage from './pages/TasksPage'
|
||||
import LogsPage from './pages/LogsPage'
|
||||
|
||||
const APP_VERSION = 'v2.0.3'
|
||||
|
||||
const VIEW_META: Record<string, { group: string; page: string }> = {
|
||||
'/': { group: 'nav_chat', page: 'menu_chat' },
|
||||
'/config': { group: 'nav_manage', page: 'menu_config' },
|
||||
'/skills': { group: 'nav_manage', page: 'menu_skills' },
|
||||
'/memory': { group: 'nav_manage', page: 'menu_memory' },
|
||||
'/channels': { group: 'nav_manage', page: 'menu_channels' },
|
||||
'/tasks': { group: 'nav_manage', page: 'menu_tasks' },
|
||||
'/logs': { group: 'nav_monitor', page: 'menu_logs' },
|
||||
}
|
||||
import PlaceholderPage from './pages/PlaceholderPage'
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const backend = useBackend()
|
||||
const location = useLocation()
|
||||
const { isWin } = usePlatform()
|
||||
const { sessionsCollapsed, toggleSessions } = useUIStore()
|
||||
const [, forceUpdate] = useState(0)
|
||||
|
||||
const toggleLanguage = useCallback(() => {
|
||||
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
||||
setLang(next)
|
||||
forceUpdate((n) => n + 1)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), [])
|
||||
|
||||
if (backend.status !== 'ready') {
|
||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
const meta = VIEW_META[location.pathname] || VIEW_META['/']
|
||||
const isChat = location.pathname === '/'
|
||||
const showSessions = isChat && !sessionsCollapsed
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar version={APP_VERSION} />
|
||||
<div className="flex h-screen overflow-hidden bg-base text-content">
|
||||
<NavRail onLangChange={handleLangChange} />
|
||||
|
||||
{showSessions && <SessionList />}
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||||
{/* Top Header */}
|
||||
<header className="h-[52px] flex items-center gap-3 px-4 border-b border-slate-200 dark:border-white/10 bg-white dark:bg-[#1A1A1A] flex-shrink-0 z-10 titlebar-drag">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm min-w-0 titlebar-no-drag">
|
||||
<span className="text-slate-400 dark:text-slate-500 truncate">{t(meta.group)}</span>
|
||||
<i className="fas fa-chevron-right text-[10px] text-slate-300 dark:text-slate-600" />
|
||||
<span className="font-medium text-slate-700 dark:text-slate-200 truncate">{t(meta.page)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Language Toggle */}
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
|
||||
>
|
||||
<i className="fas fa-globe text-xs" />
|
||||
<span>{getLang() === 'zh' ? 'EN' : '中'}</span>
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
|
||||
>
|
||||
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} />
|
||||
</button>
|
||||
|
||||
{/* Docs */}
|
||||
<a
|
||||
href="https://docs.cowagent.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
|
||||
>
|
||||
<i className="fas fa-book text-base" />
|
||||
</a>
|
||||
|
||||
{/* GitHub */}
|
||||
<a
|
||||
href="https://github.com/zhayujie/chatgpt-on-wechat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
|
||||
>
|
||||
<i className="fab fa-github text-lg" />
|
||||
</a>
|
||||
{/* Top titlebar strip — drag region + Windows controls */}
|
||||
<header className="h-[44px] flex items-center gap-1 px-2 flex-shrink-0 titlebar-drag bg-base border-b border-default">
|
||||
{isChat && sessionsCollapsed && (
|
||||
<button
|
||||
onClick={toggleSessions}
|
||||
title={t('nav_expand')}
|
||||
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<PanelLeftOpen size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0" />
|
||||
{isWin && <WindowControls />}
|
||||
</header>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
|
||||
<Routes>
|
||||
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/config" element={<ConfigPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/knowledge" element={<PlaceholderPage title={t('menu_knowledge')} />} />
|
||||
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/models" element={<PlaceholderPage title={t('menu_models')} />} />
|
||||
<Route path="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/tasks" element={<TasksPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/settings" element={<ConfigPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { t } from '../i18n'
|
||||
|
||||
interface SidebarProps {
|
||||
version: string
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
key: string
|
||||
labelKey: string
|
||||
items: { path: string; labelKey: string; icon: string }[]
|
||||
}
|
||||
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
key: 'chat',
|
||||
labelKey: 'nav_chat',
|
||||
items: [{ path: '/', labelKey: 'menu_chat', icon: 'fas fa-message' }],
|
||||
},
|
||||
{
|
||||
key: 'manage',
|
||||
labelKey: 'nav_manage',
|
||||
items: [
|
||||
{ path: '/config', labelKey: 'menu_config', icon: 'fas fa-sliders' },
|
||||
{ path: '/skills', labelKey: 'menu_skills', icon: 'fas fa-bolt' },
|
||||
{ path: '/memory', labelKey: 'menu_memory', icon: 'fas fa-brain' },
|
||||
{ path: '/channels', labelKey: 'menu_channels', icon: 'fas fa-tower-broadcast' },
|
||||
{ path: '/tasks', labelKey: 'menu_tasks', icon: 'fas fa-clock' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'monitor',
|
||||
labelKey: 'nav_monitor',
|
||||
items: [{ path: '/logs', labelKey: 'menu_logs', icon: 'fas fa-terminal' }],
|
||||
},
|
||||
]
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ version }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
chat: true,
|
||||
manage: true,
|
||||
monitor: true,
|
||||
})
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-[#0A0A0A] text-neutral-400 flex flex-col flex-shrink-0 h-full">
|
||||
{/* Logo area with traffic light spacing on macOS */}
|
||||
<div className="flex items-center gap-3 pl-20 pr-5 h-[52px] border-b border-white/10 flex-shrink-0 titlebar-drag">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-8 h-8 rounded-lg flex-shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-white font-semibold text-sm truncate">CowAgent</span>
|
||||
<span className="text-neutral-500 text-xs">{t('console')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
|
||||
{menuGroups.map((group) => {
|
||||
const isOpen = openGroups[group.key] !== false
|
||||
return (
|
||||
<div key={group.key} className={`menu-group ${isOpen ? 'open' : ''}`}>
|
||||
<button
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500 hover:text-neutral-300 cursor-pointer transition-colors duration-150"
|
||||
>
|
||||
<i className="fas fa-chevron-right text-[10px] chevron" />
|
||||
<span>{t(group.labelKey)}</span>
|
||||
</button>
|
||||
<div className="menu-group-items pl-2">
|
||||
{group.items.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<a
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className={`sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px] ${
|
||||
isActive ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
<i className={`${item.icon} item-icon text-xs w-5 text-center`} />
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-white/10 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<i className="fas fa-circle text-[6px] text-primary-400" />
|
||||
<a
|
||||
href="https://github.com/zhayujie/chatgpt-on-wechat/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary-400 transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
{version}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
189
desktop/src/renderer/src/highlight.css
Normal file
189
desktop/src/renderer/src/highlight.css
Normal file
@@ -0,0 +1,189 @@
|
||||
/* highlight.js github themes, scoped for light/dark. Generated; do not edit. */
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em
|
||||
}
|
||||
code.hljs {
|
||||
padding: 3px 5px
|
||||
}
|
||||
/*!
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/
|
||||
.hljs {
|
||||
color: #24292e;
|
||||
background: #ffffff
|
||||
}
|
||||
.hljs-doctag,
|
||||
.hljs-keyword,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-template-tag,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable.language_ {
|
||||
/* prettylights-syntax-keyword */
|
||||
color: #d73a49
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-title.class_,
|
||||
.hljs-title.class_.inherited__,
|
||||
.hljs-title.function_ {
|
||||
/* prettylights-syntax-entity */
|
||||
color: #6f42c1
|
||||
}
|
||||
.hljs-attr,
|
||||
.hljs-attribute,
|
||||
.hljs-literal,
|
||||
.hljs-meta,
|
||||
.hljs-number,
|
||||
.hljs-operator,
|
||||
.hljs-variable,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-id {
|
||||
/* prettylights-syntax-constant */
|
||||
color: #005cc5
|
||||
}
|
||||
.hljs-regexp,
|
||||
.hljs-string,
|
||||
.hljs-meta .hljs-string {
|
||||
/* prettylights-syntax-string */
|
||||
color: #032f62
|
||||
}
|
||||
.hljs-built_in,
|
||||
.hljs-symbol {
|
||||
/* prettylights-syntax-variable */
|
||||
color: #e36209
|
||||
}
|
||||
.hljs-comment,
|
||||
.hljs-code,
|
||||
.hljs-formula {
|
||||
/* prettylights-syntax-comment */
|
||||
color: #6a737d
|
||||
}
|
||||
.hljs-name,
|
||||
.hljs-quote,
|
||||
.hljs-selector-tag,
|
||||
.hljs-selector-pseudo {
|
||||
/* prettylights-syntax-entity-tag */
|
||||
color: #22863a
|
||||
}
|
||||
.hljs-subst {
|
||||
/* prettylights-syntax-storage-modifier-import */
|
||||
color: #24292e
|
||||
}
|
||||
.hljs-section {
|
||||
/* prettylights-syntax-markup-heading */
|
||||
color: #005cc5;
|
||||
font-weight: bold
|
||||
}
|
||||
.hljs-bullet {
|
||||
/* prettylights-syntax-markup-list */
|
||||
color: #735c0f
|
||||
}
|
||||
.hljs-emphasis {
|
||||
/* prettylights-syntax-markup-italic */
|
||||
color: #24292e;
|
||||
font-style: italic
|
||||
}
|
||||
.hljs-strong {
|
||||
/* prettylights-syntax-markup-bold */
|
||||
color: #24292e;
|
||||
font-weight: bold
|
||||
}
|
||||
.hljs-addition {
|
||||
/* prettylights-syntax-markup-inserted */
|
||||
color: #22863a;
|
||||
background-color: #f0fff4
|
||||
}
|
||||
.hljs-deletion {
|
||||
/* prettylights-syntax-markup-deleted */
|
||||
color: #b31d28;
|
||||
background-color: #ffeef0
|
||||
}
|
||||
.hljs-char.escape_,
|
||||
.hljs-link,
|
||||
.hljs-params,
|
||||
.hljs-property,
|
||||
.hljs-punctuation,
|
||||
.hljs-tag {
|
||||
/* purposely ignored */
|
||||
|
||||
}
|
||||
.dark pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em
|
||||
}.dark code.hljs {
|
||||
padding: 3px 5px
|
||||
}.dark /*!
|
||||
Theme: GitHub Dark
|
||||
Description: Dark theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||
Current colors taken from GitHub's CSS
|
||||
*/
|
||||
.hljs {
|
||||
color: #c9d1d9;
|
||||
background: #0d1117
|
||||
}.dark .hljs-doctag, .dark .hljs-keyword, .dark .hljs-meta .hljs-keyword, .dark .hljs-template-tag, .dark .hljs-template-variable, .dark .hljs-type, .dark .hljs-variable.language_ {
|
||||
/* prettylights-syntax-keyword */
|
||||
color: #ff7b72
|
||||
}.dark .hljs-title, .dark .hljs-title.class_, .dark .hljs-title.class_.inherited__, .dark .hljs-title.function_ {
|
||||
/* prettylights-syntax-entity */
|
||||
color: #d2a8ff
|
||||
}.dark .hljs-attr, .dark .hljs-attribute, .dark .hljs-literal, .dark .hljs-meta, .dark .hljs-number, .dark .hljs-operator, .dark .hljs-variable, .dark .hljs-selector-attr, .dark .hljs-selector-class, .dark .hljs-selector-id {
|
||||
/* prettylights-syntax-constant */
|
||||
color: #79c0ff
|
||||
}.dark .hljs-regexp, .dark .hljs-string, .dark .hljs-meta .hljs-string {
|
||||
/* prettylights-syntax-string */
|
||||
color: #a5d6ff
|
||||
}.dark .hljs-built_in, .dark .hljs-symbol {
|
||||
/* prettylights-syntax-variable */
|
||||
color: #ffa657
|
||||
}.dark .hljs-comment, .dark .hljs-code, .dark .hljs-formula {
|
||||
/* prettylights-syntax-comment */
|
||||
color: #8b949e
|
||||
}.dark .hljs-name, .dark .hljs-quote, .dark .hljs-selector-tag, .dark .hljs-selector-pseudo {
|
||||
/* prettylights-syntax-entity-tag */
|
||||
color: #7ee787
|
||||
}.dark .hljs-subst {
|
||||
/* prettylights-syntax-storage-modifier-import */
|
||||
color: #c9d1d9
|
||||
}.dark .hljs-section {
|
||||
/* prettylights-syntax-markup-heading */
|
||||
color: #1f6feb;
|
||||
font-weight: bold
|
||||
}.dark .hljs-bullet {
|
||||
/* prettylights-syntax-markup-list */
|
||||
color: #f2cc60
|
||||
}.dark .hljs-emphasis {
|
||||
/* prettylights-syntax-markup-italic */
|
||||
color: #c9d1d9;
|
||||
font-style: italic
|
||||
}.dark .hljs-strong {
|
||||
/* prettylights-syntax-markup-bold */
|
||||
color: #c9d1d9;
|
||||
font-weight: bold
|
||||
}.dark .hljs-addition {
|
||||
/* prettylights-syntax-markup-inserted */
|
||||
color: #aff5b4;
|
||||
background-color: #033a16
|
||||
}.dark .hljs-deletion {
|
||||
/* prettylights-syntax-markup-deleted */
|
||||
color: #ffdcd7;
|
||||
background-color: #67060c
|
||||
}.dark .hljs-char.escape_, .dark .hljs-link, .dark .hljs-params, .dark .hljs-property, .dark .hljs-punctuation, .dark .hljs-tag {
|
||||
/* purposely ignored */
|
||||
|
||||
}
|
||||
29
desktop/src/renderer/src/hooks/usePlatform.ts
Normal file
29
desktop/src/renderer/src/hooks/usePlatform.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export type Platform = 'mac' | 'win' | 'linux'
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
const p = window.electronAPI?.platform
|
||||
if (p === 'darwin') return 'mac'
|
||||
if (p === 'win32') return 'win'
|
||||
if (p === 'linux') return 'linux'
|
||||
// Fallback for browser dev without electron
|
||||
if (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform)) return 'mac'
|
||||
return 'win'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the host platform and applies a `.platform-*` class on <html>
|
||||
* so CSS can branch on platform (titlebar layout, scrollbars, etc.).
|
||||
*/
|
||||
export function usePlatform(): { platform: Platform; isMac: boolean; isWin: boolean } {
|
||||
const [platform] = useState<Platform>(detectPlatform)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.classList.remove('platform-mac', 'platform-win', 'platform-linux')
|
||||
root.classList.add(`platform-${platform}`)
|
||||
}, [platform])
|
||||
|
||||
return { platform, isMac: platform === 'mac', isWin: platform === 'win' }
|
||||
}
|
||||
@@ -1,27 +1,57 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
export type ThemePref = 'light' | 'dark' | 'system'
|
||||
export type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
const STORAGE_KEY = 'cow_theme'
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function readStored(): ThemePref {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
||||
// Default to dark to match the app's flagship look
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
function applyTheme(resolved: ResolvedTheme) {
|
||||
const root = document.documentElement
|
||||
root.classList.toggle('dark', resolved === 'dark')
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('cow_theme')
|
||||
if (saved === 'dark' || saved === 'light') return saved
|
||||
return 'dark'
|
||||
})
|
||||
const [pref, setPref] = useState<ThemePref>(readStored)
|
||||
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
|
||||
readStored() === 'system' ? getSystemTheme() : (readStored() as ResolvedTheme)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
|
||||
setResolved(next)
|
||||
applyTheme(next)
|
||||
localStorage.setItem(STORAGE_KEY, pref)
|
||||
}, [pref])
|
||||
|
||||
// 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)
|
||||
}
|
||||
localStorage.setItem('cow_theme', theme)
|
||||
}, [theme])
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [pref])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
setPref(resolved === 'dark' ? 'light' : 'dark')
|
||||
}, [resolved])
|
||||
|
||||
return { theme, toggleTheme }
|
||||
const setTheme = useCallback((next: ThemePref) => setPref(next), [])
|
||||
|
||||
return { theme: resolved, pref, toggleTheme, setTheme }
|
||||
}
|
||||
|
||||
@@ -1,29 +1,185 @@
|
||||
@import './highlight.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================================
|
||||
Design tokens — semantic CSS variables driving both themes.
|
||||
Components reference these via Tailwind semantic classes
|
||||
(bg-surface, text-primary, border-default, etc.), so theme
|
||||
switching only swaps variable values, never component code.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* Brand accent (CowAgent green), used sparingly */
|
||||
--accent: #4abe6e;
|
||||
--accent-hover: #35a85b;
|
||||
--accent-active: #228547;
|
||||
--accent-soft: rgba(74, 190, 110, 0.12);
|
||||
--accent-contrast: #ffffff;
|
||||
|
||||
/* Status colors */
|
||||
--success: #4abe6e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--danger-soft: rgba(239, 68, 68, 0.1);
|
||||
--danger-border: rgba(239, 68, 68, 0.3);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Light theme — layered neutral surfaces */
|
||||
--bg-base: #fafafa; /* app background */
|
||||
--bg-surface: #ffffff; /* panels, cards */
|
||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
||||
|
||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
||||
--text-secondary: #52525b; /* body, labels */
|
||||
--text-tertiary: #71717a; /* hints, captions */
|
||||
--text-disabled: #a1a1aa;
|
||||
|
||||
--border-default: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-subtle: #f0f0f1;
|
||||
|
||||
--overlay: rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Chat-specific tokens (AI-Native UI) */
|
||||
--user-bubble-bg: var(--accent-soft);
|
||||
--ai-bubble-bg: transparent;
|
||||
--message-gap: 16px;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme — layered greys instead of harsh pure black */
|
||||
--bg-base: #0e0e10;
|
||||
--bg-surface: #161618;
|
||||
--bg-surface-2: #1c1c1f;
|
||||
--bg-elevated: #1f1f23;
|
||||
--bg-inset: #161618;
|
||||
|
||||
--text-primary: #f4f4f5; /* contrast > 7:1 on bg-base */
|
||||
--text-secondary: #c4c4cc;
|
||||
--text-tertiary: #8e8e96;
|
||||
--text-disabled: #5a5a62;
|
||||
|
||||
--border-default: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--border-subtle: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--accent-contrast: #0e0e10;
|
||||
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--user-bubble-bg: rgba(74, 190, 110, 0.16);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Base
|
||||
============================================================ */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #94a3b8 transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
||||
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-slate-800 overflow-hidden;
|
||||
font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply bg-[#111111] text-slate-200;
|
||||
/* Smooth theme transition (respect reduced motion) */
|
||||
body,
|
||||
body * {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Scrollbar — thin, low-key, theme-aware
|
||||
============================================================ */
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Windows scrollbars are slightly wider/more visible */
|
||||
.platform-win ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Titlebar drag regions (frameless window)
|
||||
============================================================ */
|
||||
|
||||
.titlebar-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.titlebar-no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Animations
|
||||
============================================================ */
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
@@ -33,211 +189,150 @@ body {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar-item.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
/* AI typing indicator — 3-dot pulse */
|
||||
@keyframes typingPulse {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.sidebar-item.active .item-icon {
|
||||
color: #4ABE6E;
|
||||
.typing-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: var(--text-tertiary);
|
||||
animation: typingPulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.menu-group .chevron {
|
||||
transition: transform 0.25s ease;
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.16s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.32s; }
|
||||
|
||||
/* Smooth content reveal */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.menu-group.open .chevron {
|
||||
transform: rotate(90deg);
|
||||
.animate-reveal {
|
||||
animation: fadeInUp 0.25s ease both;
|
||||
}
|
||||
|
||||
.menu-group-items {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease;
|
||||
/* Skeleton shimmer */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.menu-group.open .menu-group-items {
|
||||
max-height: 500px;
|
||||
transition: max-height 0.35s ease;
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-surface-2) 25%,
|
||||
var(--border-subtle) 50%,
|
||||
var(--bg-surface-2) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Markdown content */
|
||||
.msg-content pre {
|
||||
@apply rounded-lg overflow-x-auto my-3;
|
||||
background: #f1f5f9;
|
||||
/* ============================================================
|
||||
Markdown content (assistant messages, memory/knowledge viewers)
|
||||
============================================================ */
|
||||
|
||||
.msg-content > *:first-child { margin-top: 0; }
|
||||
.msg-content > *:last-child { margin-bottom: 0; }
|
||||
.msg-content p { margin: 0.5em 0; line-height: 1.7; }
|
||||
.msg-content ul, .msg-content ol { padding-left: 1.4em; margin: 0.5em 0; }
|
||||
.msg-content li { margin: 0.25em 0; }
|
||||
.msg-content h1, .msg-content h2, .msg-content h3, .msg-content h4 {
|
||||
font-weight: 600;
|
||||
margin: 0.8em 0 0.4em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg-content h1 { font-size: 1.4em; }
|
||||
.msg-content h2 { font-size: 1.25em; }
|
||||
.msg-content h3 { font-size: 1.1em; }
|
||||
|
||||
.dark .msg-content pre {
|
||||
background: #111111;
|
||||
}
|
||||
.msg-content a { color: var(--accent); text-decoration: none; }
|
||||
.msg-content a:hover { text-decoration: underline; }
|
||||
|
||||
.msg-content :not(pre) > code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875em;
|
||||
background: rgba(74, 190, 110, 0.1);
|
||||
color: #1C6B3B;
|
||||
}
|
||||
|
||||
.dark .msg-content :not(pre) > code {
|
||||
background: rgba(74, 190, 110, 0.15);
|
||||
color: #74E9A4;
|
||||
}
|
||||
|
||||
.msg-content p { @apply my-2 leading-relaxed; }
|
||||
.msg-content ul, .msg-content ol { @apply pl-6 my-2; }
|
||||
.msg-content li { @apply my-1; }
|
||||
|
||||
.msg-content a {
|
||||
color: #35A85B;
|
||||
}
|
||||
|
||||
.msg-content a:hover {
|
||||
color: #228547;
|
||||
.msg-content blockquote {
|
||||
padding-left: 0.9em;
|
||||
margin: 0.6em 0;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.msg-content table {
|
||||
@apply border-collapse w-full my-3;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.8em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.msg-content th, .msg-content td {
|
||||
@apply border px-3 py-2 text-sm;
|
||||
border-color: #e2e8f0;
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 0.4em 0.7em;
|
||||
text-align: left;
|
||||
}
|
||||
.msg-content th { background: var(--bg-surface-2); font-weight: 600; }
|
||||
|
||||
.msg-content hr { border: none; border-top: 1px solid var(--border-default); margin: 1em 0; }
|
||||
|
||||
/* Inline code */
|
||||
.msg-content :not(pre) > code {
|
||||
padding: 0.12em 0.4em;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.dark .msg-content th, .dark .msg-content td {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
/* Fenced code blocks */
|
||||
.msg-content .code-block-wrapper {
|
||||
margin: 0.7em 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
.msg-content th {
|
||||
@apply font-medium;
|
||||
background: #f1f5f9;
|
||||
.msg-content .code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px 5px 12px;
|
||||
background: var(--bg-surface-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.dark .msg-content th {
|
||||
background: #111111;
|
||||
.msg-content .code-block-lang {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.msg-content blockquote {
|
||||
@apply pl-4 my-3;
|
||||
border-left: 4px solid #4ABE6E;
|
||||
.msg-content .code-copy-btn {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.dark .msg-content blockquote {
|
||||
background: rgba(74, 190, 110, 0.08);
|
||||
.msg-content .code-copy-btn:hover { color: var(--text-secondary); background: var(--bg-inset); }
|
||||
.msg-content .code-copy-btn.copied { color: var(--accent); }
|
||||
.msg-content .code-block-wrapper pre {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.msg-content hr {
|
||||
@apply my-4;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .msg-content hr {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Tool steps */
|
||||
.tool-header {
|
||||
@apply cursor-pointer transition-colors duration-150;
|
||||
}
|
||||
|
||||
.tool-header:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.dark .tool-header:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.tool-detail {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dark .tool-detail {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Config dropdown */
|
||||
.cfg-dropdown { @apply relative; }
|
||||
|
||||
.cfg-dropdown-selected {
|
||||
@apply flex items-center justify-between px-3 rounded-lg border cursor-pointer transition-colors text-sm;
|
||||
height: 40px;
|
||||
border-color: #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dark .cfg-dropdown-selected {
|
||||
border-color: #475569;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.cfg-dropdown-menu {
|
||||
@apply absolute left-0 right-0 z-50 rounded-lg border shadow-lg overflow-y-auto hidden;
|
||||
top: calc(100% + 4px);
|
||||
max-height: 240px;
|
||||
padding: 4px;
|
||||
border-color: #e2e8f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dark .cfg-dropdown-menu {
|
||||
border-color: #334155;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.cfg-dropdown.open .cfg-dropdown-menu {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.cfg-dropdown-item {
|
||||
@apply px-3 py-2 rounded-md text-sm cursor-pointer transition-colors;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cfg-dropdown-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.dark .cfg-dropdown-item {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.dark .cfg-dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.cfg-dropdown-item.active {
|
||||
background: rgba(74, 190, 110, 0.1);
|
||||
color: #4ABE6E;
|
||||
}
|
||||
|
||||
.dark .cfg-dropdown-item.active {
|
||||
background: rgba(74, 190, 110, 0.15);
|
||||
color: #74E9A4;
|
||||
}
|
||||
|
||||
/* API Key mask */
|
||||
.cfg-key-masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
/* Chat input */
|
||||
#chat-input {
|
||||
height: 42px;
|
||||
max-height: 180px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* Titlebar drag region for macOS */
|
||||
.titlebar-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.titlebar-no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
.msg-content .code-block-wrapper pre code,
|
||||
.msg-content pre code.hljs {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
126
desktop/src/renderer/src/layout/NavRail.tsx
Normal file
126
desktop/src/renderer/src/layout/NavRail.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Zap,
|
||||
Radio,
|
||||
Clock,
|
||||
Cpu,
|
||||
Settings,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { t, getLang, setLang, Lang } from '../i18n'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
labelKey: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ path: '/', labelKey: 'menu_chat', icon: MessageSquare },
|
||||
{ path: '/knowledge', labelKey: 'menu_knowledge', icon: BookOpen },
|
||||
{ path: '/memory', labelKey: 'menu_memory', icon: Brain },
|
||||
{ path: '/skills', labelKey: 'menu_skills', icon: Zap },
|
||||
{ path: '/models', labelKey: 'menu_models', icon: Cpu },
|
||||
{ path: '/channels', labelKey: 'menu_channels', icon: Radio },
|
||||
{ path: '/tasks', labelKey: 'menu_tasks', icon: Clock },
|
||||
{ path: '/settings', labelKey: 'menu_settings', icon: Settings },
|
||||
]
|
||||
|
||||
interface NavRailProps {
|
||||
onLangChange: () => void
|
||||
}
|
||||
|
||||
const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { navCollapsed, toggleNav } = useUIStore()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
const collapsed = navCollapsed
|
||||
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
||||
setLang(next)
|
||||
onLangChange()
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
|
||||
{/* Top: full-width drag strip; reserve space for macOS traffic lights.
|
||||
No right border here so the divider doesn't cut across the traffic lights. */}
|
||||
<div className="titlebar-drag h-[44px] flex-shrink-0" />
|
||||
|
||||
{/* Content area carries the right divider, starting below the titlebar */}
|
||||
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
|
||||
{/* Nav items */}
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
title={collapsed ? t(item.labelKey) : undefined}
|
||||
className={`group w-full flex items-center gap-3 rounded-btn cursor-pointer transition-colors h-9 ${
|
||||
collapsed ? 'justify-center px-0' : 'px-3'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-2 hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.8} className="flex-shrink-0" />
|
||||
{!collapsed && <span className="text-[13px] truncate">{t(item.labelKey)}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleTheme} title={theme === 'dark' ? 'Light' : 'Dark'}>
|
||||
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</FooterBtn>
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleLanguage} title="Language">
|
||||
<span className="text-[13px] font-medium w-[18px] text-center">{getLang() === 'zh' ? 'EN' : '中'}</span>
|
||||
</FooterBtn>
|
||||
<div className={collapsed ? '' : 'flex-1'} />
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
|
||||
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||
</FooterBtn>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const FooterBtn: React.FC<{
|
||||
collapsed: boolean
|
||||
onClick: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}> = ({ collapsed, onClick, title, children }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`inline-flex items-center gap-1.5 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors ${
|
||||
collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
export default NavRail
|
||||
180
desktop/src/renderer/src/layout/SessionList.tsx
Normal file
180
desktop/src/renderer/src/layout/SessionList.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, MessageSquare, Pencil, Trash2, Check, X, PanelLeftClose } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import type { SessionItem } from '../types'
|
||||
|
||||
function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
const startOfYesterday = startOfToday - 86400
|
||||
|
||||
const today: SessionItem[] = []
|
||||
const yesterday: SessionItem[] = []
|
||||
const earlier: SessionItem[] = []
|
||||
|
||||
for (const s of sessions) {
|
||||
const ts = s.last_active || s.created_at
|
||||
if (ts >= startOfToday) today.push(s)
|
||||
else if (ts >= startOfYesterday) yesterday.push(s)
|
||||
else earlier.push(s)
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: t('session_today'), items: today },
|
||||
{ label: t('session_yesterday'), items: yesterday },
|
||||
{ label: t('session_earlier'), items: earlier },
|
||||
].filter((g) => g.items.length > 0)
|
||||
}
|
||||
|
||||
const SessionList: React.FC = () => {
|
||||
const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } =
|
||||
useSessionStore()
|
||||
const toggleSessions = useUIStore((s) => s.toggleSessions)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions(1)
|
||||
}, [loadSessions])
|
||||
|
||||
const groups = useMemo(() => groupByTime(sessions), [sessions])
|
||||
|
||||
const startEdit = (s: SessionItem) => {
|
||||
setEditingId(s.session_id)
|
||||
setEditValue(s.title || '')
|
||||
}
|
||||
|
||||
const commitEdit = async () => {
|
||||
if (editingId && editValue.trim()) {
|
||||
await rename(editingId, editValue.trim())
|
||||
}
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag">
|
||||
<button
|
||||
onClick={toggleSessions}
|
||||
title={t('nav_collapse')}
|
||||
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<PanelLeftClose size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => newSession()}
|
||||
title={t('session_new')}
|
||||
className="titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus size={15} />
|
||||
{t('session_new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-2 pb-2"
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80 && hasMore && !loading) loadMore()
|
||||
}}
|
||||
>
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
|
||||
<MessageSquare size={22} className="text-content-disabled mb-2" />
|
||||
<p className="text-xs text-content-tertiary">{t('session_empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="mb-2">
|
||||
<div className="px-2 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-content-disabled">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.items.map((s) => {
|
||||
const isActive = s.session_id === activeId
|
||||
const isEditing = editingId === s.session_id
|
||||
return (
|
||||
<div
|
||||
key={s.session_id}
|
||||
onClick={() => !isEditing && setActive(s.session_id)}
|
||||
className={`group flex items-center gap-2 px-2 h-9 rounded-btn cursor-pointer transition-colors ${
|
||||
isActive ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitEdit()
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 min-w-0 bg-inset border border-strong rounded px-1.5 py-0.5 text-[13px] text-content focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`flex-1 min-w-0 truncate text-[13px] ${
|
||||
isActive ? 'text-accent font-medium' : 'text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{s.title || s.session_id}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconBtn onClick={(e) => { e.stopPropagation(); commitEdit() }}><Check size={13} /></IconBtn>
|
||||
<IconBtn onClick={(e) => { e.stopPropagation(); setEditingId(null) }}><X size={13} /></IconBtn>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconBtn onClick={(e) => { e.stopPropagation(); startEdit(s) }} title={t('session_rename')}>
|
||||
<Pencil size={13} />
|
||||
</IconBtn>
|
||||
<IconBtn onClick={(e) => { e.stopPropagation(); remove(s.session_id) }} title={t('session_delete')} danger>
|
||||
<Trash2 size={13} />
|
||||
</IconBtn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="skeleton h-7 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IconBtn: React.FC<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
title?: string
|
||||
danger?: boolean
|
||||
children: React.ReactNode
|
||||
}> = ({ onClick, title, danger, children }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`inline-flex items-center justify-center w-6 h-6 rounded cursor-pointer transition-colors text-content-tertiary ${
|
||||
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
export default SessionList
|
||||
41
desktop/src/renderer/src/layout/WindowControls.tsx
Normal file
41
desktop/src/renderer/src/layout/WindowControls.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Minus, Square, Copy, X } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Custom window controls for the frameless Windows titlebar.
|
||||
* On macOS the system renders traffic lights, so this returns null there.
|
||||
*/
|
||||
const WindowControls: React.FC = () => {
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
const api = window.electronAPI
|
||||
|
||||
useEffect(() => {
|
||||
api?.windowIsMaximized().then(setMaximized)
|
||||
api?.onMaximizeChange(setMaximized)
|
||||
}, [api])
|
||||
|
||||
if (api?.platform === 'darwin') return null
|
||||
|
||||
const btn =
|
||||
'titlebar-no-drag inline-flex items-center justify-center w-11 h-full text-content-tertiary hover:text-content cursor-pointer transition-colors'
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch h-full">
|
||||
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMinimize()} aria-label="Minimize">
|
||||
<Minus size={15} strokeWidth={2} />
|
||||
</button>
|
||||
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMaximize()} aria-label="Maximize">
|
||||
{maximized ? <Copy size={12} strokeWidth={2} /> : <Square size={12} strokeWidth={2} />}
|
||||
</button>
|
||||
<button
|
||||
className={`${btn} hover:bg-danger hover:text-white`}
|
||||
onClick={() => api?.windowClose()}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WindowControls
|
||||
20
desktop/src/renderer/src/pages/PlaceholderPage.tsx
Normal file
20
desktop/src/renderer/src/pages/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import { Construction } from 'lucide-react'
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
title: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/** Temporary page for routes that will be implemented in later phases. */
|
||||
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({ title, hint }) => (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-surface-2 flex items-center justify-center mb-4">
|
||||
<Construction size={26} className="text-content-tertiary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-content mb-1">{title}</h2>
|
||||
<p className="text-sm text-content-tertiary max-w-sm">{hint || 'Coming soon in this iteration.'}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default PlaceholderPage
|
||||
@@ -5,10 +5,13 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', 'sans-serif'],
|
||||
sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
'danger-soft': 'var(--danger-soft)',
|
||||
'danger-border': 'var(--danger-border)',
|
||||
// Brand accent (kept for backward compat + explicit accent usage)
|
||||
primary: {
|
||||
50: '#EDFDF3',
|
||||
100: '#D4FAE2',
|
||||
@@ -21,6 +24,46 @@ module.exports = {
|
||||
800: '#1A5532',
|
||||
900: '#16462A',
|
||||
},
|
||||
// Semantic tokens — driven by CSS variables, theme-aware
|
||||
accent: {
|
||||
DEFAULT: 'var(--accent)',
|
||||
hover: 'var(--accent-hover)',
|
||||
active: 'var(--accent-active)',
|
||||
soft: 'var(--accent-soft)',
|
||||
contrast: 'var(--accent-contrast)',
|
||||
},
|
||||
base: 'var(--bg-base)',
|
||||
surface: {
|
||||
DEFAULT: 'var(--bg-surface)',
|
||||
2: 'var(--bg-surface-2)',
|
||||
},
|
||||
elevated: 'var(--bg-elevated)',
|
||||
inset: 'var(--bg-inset)',
|
||||
content: {
|
||||
DEFAULT: 'var(--text-primary)',
|
||||
secondary: 'var(--text-secondary)',
|
||||
tertiary: 'var(--text-tertiary)',
|
||||
disabled: 'var(--text-disabled)',
|
||||
},
|
||||
success: 'var(--success)',
|
||||
warning: 'var(--warning)',
|
||||
danger: 'var(--danger)',
|
||||
info: 'var(--info)',
|
||||
},
|
||||
borderColor: {
|
||||
DEFAULT: 'var(--border-default)',
|
||||
default: 'var(--border-default)',
|
||||
strong: 'var(--border-strong)',
|
||||
subtle: 'var(--border-subtle)',
|
||||
},
|
||||
boxShadow: {
|
||||
sm: 'var(--shadow-sm)',
|
||||
md: 'var(--shadow-md)',
|
||||
lg: 'var(--shadow-lg)',
|
||||
},
|
||||
borderRadius: {
|
||||
card: '12px',
|
||||
btn: '8px',
|
||||
},
|
||||
animation: {
|
||||
'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',
|
||||
|
||||
Reference in New Issue
Block a user