fix: migrate all components to theme-aware CSS variables

Replace ~150 hardcoded Tailwind color classes (bg-zinc-*, text-zinc-*,
border-white/*, text-cyan-*, bg-cyan-*) with CSS custom properties
(--pc-*) across all 17 components.

Add @theme block in index.css for Tailwind v4 theme-aware utility
classes (bg-pc-elevated, text-pc-text, border-pc-border, etc.).

Add --pc-hover, --pc-hover-strong, --pc-separator variables per theme
(white/alpha for dark/OLED, black/alpha for light).

Theme switcher (dark/light/OLED) now actually works — all UI elements
respond to theme changes in real-time.

Fixes #55
This commit is contained in:
Nicolas Varrot
2026-02-13 00:29:50 +00:00
parent 62663e1ac9
commit b60c0ce3c4
17 changed files with 184 additions and 155 deletions

View File

@@ -136,30 +136,30 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative" role="log" aria-label={t('chat.messages')} aria-live="polite"> <div ref={scrollContainerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative" role="log" aria-label={t('chat.messages')} aria-live="polite">
<div className="max-w-4xl mx-auto py-4 w-full"> <div className="max-w-4xl mx-auto py-4 w-full">
{messages.length === 0 && isLoadingHistory && ( {messages.length === 0 && isLoadingHistory && (
<div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500"> <div className="flex flex-col items-center justify-center h-[60vh] text-pc-text-muted">
<Loader2 className="h-8 w-8 text-cyan-300/60 animate-spin mb-4" /> <Loader2 className="h-8 w-8 text-pc-accent-light/60 animate-spin mb-4" />
<div className="text-sm text-zinc-500">{t('chat.loadingHistory')}</div> <div className="text-sm text-pc-text-muted">{t('chat.loadingHistory')}</div>
</div> </div>
)} )}
{messages.length === 0 && !isLoadingHistory && ( {messages.length === 0 && !isLoadingHistory && (
<div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500"> <div className="flex flex-col items-center justify-center h-[60vh] text-pc-text-muted">
<div className="relative mb-6"> <div className="relative mb-6">
<div className="absolute -inset-4 rounded-3xl bg-gradient-to-r from-cyan-400/10 via-indigo-500/10 to-violet-500/10 blur-2xl" /> <div className="absolute -inset-4 rounded-3xl bg-gradient-to-r from-cyan-400/10 via-indigo-500/10 to-violet-500/10 blur-2xl" />
<div className="relative flex h-16 w-16 items-center justify-center rounded-3xl border border-white/8 bg-zinc-800/40"> <div className="relative flex h-16 w-16 items-center justify-center rounded-3xl border border-pc-border bg-pc-elevated/40">
<Bot className="h-8 w-8 text-cyan-200" /> <Bot className="h-8 w-8 text-pc-accent-light" />
</div> </div>
</div> </div>
<div className="text-lg text-zinc-200 font-semibold">{t('chat.welcome')}</div> <div className="text-lg text-pc-text font-semibold">{t('chat.welcome')}</div>
<div className="text-sm mt-1 text-zinc-500">{t('chat.welcomeSub')}</div> <div className="text-sm mt-1 text-pc-text-muted">{t('chat.welcomeSub')}</div>
</div> </div>
)} )}
{visibleMessages.map(({ msg, showSep }) => ( {visibleMessages.map(({ msg, showSep }) => (
<div key={msg.id}> <div key={msg.id}>
{showSep && ( {showSep && (
<div className="flex items-center gap-3 py-3 px-4 select-none" aria-label={formatDateSeparator(msg.timestamp, t)}> <div className="flex items-center gap-3 py-3 px-4 select-none" aria-label={formatDateSeparator(msg.timestamp, t)}>
<div className="flex-1 h-px bg-white/8" /> <div className="flex-1 h-px bg-[var(--pc-hover-strong)]" />
<span className="text-[11px] font-medium text-zinc-500 uppercase tracking-wider">{formatDateSeparator(msg.timestamp, t)}</span> <span className="text-[11px] font-medium text-pc-text-muted uppercase tracking-wider">{formatDateSeparator(msg.timestamp, t)}</span>
<div className="flex-1 h-px bg-white/8" /> <div className="flex-1 h-px bg-[var(--pc-hover-strong)]" />
</div> </div>
)} )}
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} /> <ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} />
@@ -176,7 +176,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
onClick={globalState === 'expand-all' ? collapseAll : expandAll} onClick={globalState === 'expand-all' ? collapseAll : expandAll}
aria-label={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')} aria-label={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')}
title={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')} title={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')}
className="flex items-center gap-1.5 rounded-full border border-white/10 bg-zinc-800/90 backdrop-blur-lg px-3 py-2 text-xs text-zinc-300 shadow-lg hover:bg-zinc-700/90 transition-all hover:shadow-violet-500/10" className="flex items-center gap-1.5 rounded-full border border-pc-border-strong bg-pc-elevated/90 backdrop-blur-lg px-3 py-2 text-xs text-pc-text shadow-lg hover:bg-pc-elevated/90 transition-all hover:shadow-violet-500/10"
> >
{globalState === 'expand-all' ? <ChevronsDownUp size={14} className="text-violet-300" /> : <ChevronsUpDown size={14} className="text-violet-300" />} {globalState === 'expand-all' ? <ChevronsDownUp size={14} className="text-violet-300" /> : <ChevronsUpDown size={14} className="text-violet-300" />}
</button> </button>
@@ -185,9 +185,9 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
<button <button
onClick={() => scrollToBottom('smooth')} onClick={() => scrollToBottom('smooth')}
aria-label={t('chat.scrollToBottom')} aria-label={t('chat.scrollToBottom')}
className="flex items-center gap-1.5 rounded-full border border-white/10 bg-zinc-800/90 backdrop-blur-lg px-3.5 py-2 text-xs text-zinc-300 shadow-lg hover:bg-zinc-700/90 transition-all hover:shadow-cyan-500/10" className="flex items-center gap-1.5 rounded-full border border-pc-border-strong bg-pc-elevated/90 backdrop-blur-lg px-3.5 py-2 text-xs text-pc-text shadow-lg hover:bg-pc-elevated/90 transition-all hover:shadow-cyan-500/10"
> >
<ArrowDown size={14} className="text-cyan-300" /> <ArrowDown size={14} className="text-pc-accent-light" />
<span className="hidden sm:inline">{t('chat.scrollToBottom')}</span> <span className="hidden sm:inline">{t('chat.scrollToBottom')}</span>
</button> </button>
)} )}

View File

@@ -214,7 +214,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
return ( return (
<div <div
className="border-t border-white/8 bg-[var(--pc-bg-input)]/60 backdrop-blur-xl p-4" className="border-t border-pc-border bg-[var(--pc-bg-input)]/60 backdrop-blur-xl p-4"
role="form" role="form"
aria-label={t('chat.inputLabel')} aria-label={t('chat.inputLabel')}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@@ -222,26 +222,26 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
onDrop={handleDrop} onDrop={handleDrop}
> >
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className={`rounded-3xl border bg-[var(--pc-bg-surface)]/40 p-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] transition-colors ${isDragOver ? 'border-cyan-400/40 bg-cyan-400/5' : 'border-white/8'}`}> <div className={`rounded-3xl border bg-[var(--pc-bg-surface)]/40 p-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] transition-colors ${isDragOver ? 'border-[var(--pc-accent-dim)] bg-[var(--pc-accent-glow)]' : 'border-pc-border'}`}>
{/* File previews */} {/* File previews */}
{files.length > 0 && ( {files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3 px-1"> <div className="flex flex-wrap gap-2 mb-3 px-1">
{files.map(f => ( {files.map(f => (
<div key={f.id} className="group relative flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/50 px-3 py-2 text-xs text-zinc-400"> <div key={f.id} className="group relative flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/50 px-3 py-2 text-xs text-pc-text-secondary">
{f.preview ? ( {f.preview ? (
<img src={f.preview} alt="" className="h-8 w-8 rounded-lg object-cover" /> <img src={f.preview} alt="" className="h-8 w-8 rounded-lg object-cover" />
) : ( ) : (
<FileText size={16} className="text-zinc-500 shrink-0" /> <FileText size={16} className="text-pc-text-muted shrink-0" />
)} )}
<div className="min-w-0 max-w-[120px]"> <div className="min-w-0 max-w-[120px]">
<div className="truncate text-zinc-300">{f.file.name}</div> <div className="truncate text-pc-text">{f.file.name}</div>
<div className="text-[10px] text-zinc-500">{formatSize(f.file.size)}</div> <div className="text-[10px] text-pc-text-muted">{formatSize(f.file.size)}</div>
</div> </div>
<button <button
onClick={() => removeFile(f.id)} onClick={() => removeFile(f.id)}
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-zinc-700 border border-white/10 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500/80" className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-pc-elevated border border-pc-border-strong flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500/80"
> >
<X size={10} className="text-zinc-200" /> <X size={10} className="text-pc-text" />
</button> </button>
</div> </div>
))} ))}
@@ -253,7 +253,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={disabled} disabled={disabled}
className="shrink-0 h-11 w-11 rounded-2xl border border-white/8 bg-zinc-800/30 flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:bg-white/5 transition-colors disabled:opacity-30" className="shrink-0 h-11 w-11 rounded-2xl border border-pc-border bg-pc-elevated/30 flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:bg-[var(--pc-hover)] transition-colors disabled:opacity-30"
title={t('chat.attachFile')} title={t('chat.attachFile')}
aria-label={t('chat.attachFile')} aria-label={t('chat.attachFile')}
> >
@@ -278,7 +278,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
aria-label={t('chat.inputLabel')} aria-label={t('chat.inputLabel')}
disabled={disabled} disabled={disabled}
rows={1} rows={1}
className="flex-1 bg-transparent resize-none rounded-2xl border border-white/8 bg-zinc-900/35 px-4 py-3 text-sm text-zinc-300 placeholder:text-zinc-500 outline-none focus:ring-2 focus:ring-cyan-400/30 transition-all max-h-[200px]" className="flex-1 bg-transparent resize-none rounded-2xl border border-pc-border bg-pc-input/35 px-4 py-3 text-sm text-pc-text placeholder:text-pc-text-muted outline-none focus:ring-2 focus:ring-[var(--pc-accent-dim)] transition-all max-h-[200px]"
/> />
{isGenerating ? ( {isGenerating ? (
<button <button
@@ -293,7 +293,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
onClick={handleSubmit} onClick={handleSubmit}
disabled={(!text.trim() && files.length === 0) || disabled} disabled={(!text.trim() && files.length === 0) || disabled}
aria-label={t('chat.send')} aria-label={t('chat.send')}
className="shrink-0 h-11 px-5 rounded-2xl bg-gradient-to-r from-cyan-500/80 via-indigo-500/70 to-violet-500/80 text-zinc-900 font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(34,211,238,0.1)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2" className="shrink-0 h-11 px-5 rounded-2xl bg-gradient-to-r from-cyan-500/80 via-indigo-500/70 to-violet-500/80 text-pc-text font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(34,211,238,0.1)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2"
> >
<Send size={16} /> <Send size={16} />
<span className="hidden sm:inline">{t('chat.send')}</span> <span className="hidden sm:inline">{t('chat.send')}</span>

View File

@@ -204,15 +204,15 @@ function InternalsSummary({ blocks }: { blocks: MessageBlock[] }) {
function InternalOnlyMessage({ message }: { message: ChatMessageType }) { function InternalOnlyMessage({ message }: { message: ChatMessageType }) {
return ( return (
<div className="animate-fade-in flex gap-3 px-4 py-1"> <div className="animate-fade-in flex gap-3 px-4 py-1">
<div className="shrink-0 mt-0.5 flex h-6 w-6 items-center justify-center rounded-xl border border-white/5 bg-zinc-800/30"> <div className="shrink-0 mt-0.5 flex h-6 w-6 items-center justify-center rounded-xl border border-pc-border bg-pc-elevated/30">
<Wrench className="h-3 w-3 text-zinc-500" /> <Wrench className="h-3 w-3 text-pc-text-muted" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="space-y-1"> <div className="space-y-1">
{renderInternalBlocks(message.blocks)} {renderInternalBlocks(message.blocks)}
</div> </div>
{message.timestamp && ( {message.timestamp && (
<div className="mt-0.5 text-[10px] text-zinc-600"> <div className="mt-0.5 text-[10px] text-pc-text-faint">
{formatTimestamp(message.timestamp)} {formatTimestamp(message.timestamp)}
</div> </div>
)} )}
@@ -233,7 +233,7 @@ function CopyButton({ text }: { text: string }) {
return ( return (
<button <button
onClick={handleCopy} onClick={handleCopy}
className="absolute top-2 right-2 h-7 w-7 rounded-lg border border-white/8 bg-zinc-800/80 backdrop-blur-sm flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:border-cyan-400/30 transition-all opacity-0 group-hover:opacity-100" className="absolute top-2 right-2 h-7 w-7 rounded-lg border border-pc-border bg-pc-elevated/80 backdrop-blur-sm flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:border-[var(--pc-accent-dim)] transition-all opacity-0 group-hover:opacity-100"
title={copied ? t('message.copied') : t('message.copy')} title={copied ? t('message.copied') : t('message.copy')}
aria-label={t('message.copy')} aria-label={t('message.copy')}
> >
@@ -271,18 +271,18 @@ function MetadataViewer({ metadata }: { metadata?: Record<string, unknown> }) {
<button <button
ref={btnRef} ref={btnRef}
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
className="h-7 w-7 rounded-lg border border-white/8 bg-zinc-800/80 backdrop-blur-sm flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:border-cyan-400/30 transition-all opacity-0 group-hover:opacity-100" className="h-7 w-7 rounded-lg border border-pc-border bg-pc-elevated/80 backdrop-blur-sm flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:border-[var(--pc-accent-dim)] transition-all opacity-0 group-hover:opacity-100"
title={t('message.metadata')} title={t('message.metadata')}
aria-label={t('message.metadata')} aria-label={t('message.metadata')}
> >
<Info size={13} /> <Info size={13} />
</button> </button>
{open && pos && createPortal( {open && pos && createPortal(
<div ref={panelRef} className="fixed z-[9999] w-72 max-h-64 overflow-auto rounded-xl border border-white/10 bg-zinc-900/95 backdrop-blur-md shadow-xl p-3 text-[11px] text-zinc-400 font-mono leading-relaxed custom-scrollbar" style={{ top: pos.top, left: pos.left, transform: 'translateY(-100%)' }}> <div ref={panelRef} className="fixed z-[9999] w-72 max-h-64 overflow-auto rounded-xl border border-pc-border-strong bg-pc-input/95 backdrop-blur-md shadow-xl p-3 text-[11px] text-pc-text-secondary font-mono leading-relaxed custom-scrollbar" style={{ top: pos.top, left: pos.left, transform: 'translateY(-100%)' }}>
{Object.entries(metadata).map(([k, v]) => ( {Object.entries(metadata).map(([k, v]) => (
<div key={k} className="flex gap-2 py-0.5"> <div key={k} className="flex gap-2 py-0.5">
<span className="text-cyan-400/70 shrink-0">{k}:</span> <span className="text-pc-accent/70 shrink-0">{k}:</span>
<span className="text-zinc-300 break-all">{typeof v === 'object' ? JSON.stringify(v) : String(v)}</span> <span className="text-pc-text break-all">{typeof v === 'object' ? JSON.stringify(v) : String(v)}</span>
</div> </div>
))} ))}
</div>, </div>,
@@ -309,12 +309,12 @@ function SystemEventMessage({ message }: { message: ChatMessageType }) {
return ( return (
<div className="animate-fade-in flex items-center justify-center gap-2 px-4 py-1.5 my-0.5"> <div className="animate-fade-in flex items-center justify-center gap-2 px-4 py-1.5 my-0.5">
<div className="flex items-center gap-1.5 max-w-[85%] rounded-full px-3 py-1 bg-zinc-800/30 border border-white/5"> <div className="flex items-center gap-1.5 max-w-[85%] rounded-full px-3 py-1 bg-pc-elevated/30 border border-pc-border">
<Zap className="h-3 w-3 text-zinc-500 shrink-0" /> <Zap className="h-3 w-3 text-pc-text-muted shrink-0" />
<span className="text-[11px] font-medium text-zinc-500 shrink-0">{label}</span> <span className="text-[11px] font-medium text-pc-text-muted shrink-0">{label}</span>
<span className="text-[11px] text-zinc-500 truncate">{display}</span> <span className="text-[11px] text-pc-text-muted truncate">{display}</span>
{message.timestamp && ( {message.timestamp && (
<span className="text-[10px] text-zinc-600 shrink-0 ml-1">{formatTimestamp(message.timestamp)}</span> <span className="text-[10px] text-pc-text-faint shrink-0 ml-1">{formatTimestamp(message.timestamp)}</span>
)} )}
</div> </div>
</div> </div>
@@ -343,12 +343,12 @@ export function ChatMessageComponent({ message, onRetry, agentAvatarUrl }: { mes
return ( return (
<div className={`animate-fade-in flex gap-3 px-4 py-2 ${isUser ? 'flex-row-reverse' : ''}`}> <div className={`animate-fade-in flex gap-3 px-4 py-2 ${isUser ? 'flex-row-reverse' : ''}`}>
{/* Avatar */} {/* Avatar */}
<div className="shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-white/8 bg-zinc-800/40 overflow-hidden"> <div className="shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-pc-border bg-pc-elevated/40 overflow-hidden">
{isUser {isUser
? <User className="h-4 w-4 text-cyan-200" /> ? <User className="h-4 w-4 text-pc-accent-light" />
: agentAvatarUrl : agentAvatarUrl
? <img src={agentAvatarUrl} alt="Agent" className="h-full w-full object-cover" /> ? <img src={agentAvatarUrl} alt="Agent" className="h-full w-full object-cover" />
: <Bot className="h-4 w-4 text-cyan-200" /> : <Bot className="h-4 w-4 text-pc-accent-light" />
} }
</div> </div>
@@ -356,8 +356,8 @@ export function ChatMessageComponent({ message, onRetry, agentAvatarUrl }: { mes
<div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}> <div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}>
<div className={`group relative inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed max-w-full overflow-hidden ${ <div className={`group relative inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed max-w-full overflow-hidden ${
isUser isUser
? 'bg-gradient-to-b from-cyan-800/40 to-cyan-900/25 text-zinc-100 border border-cyan-400/30' ? 'bg-[var(--pc-user-bubble)] text-pc-text border border-[var(--pc-user-border)]'
: 'bg-zinc-800/40 text-zinc-300 border border-white/8 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]' : 'bg-pc-elevated/40 text-pc-text border border-pc-border shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
}`}> }`}>
{/* Action buttons */} {/* Action buttons */}
{!isUser && !message.isStreaming && getPlainText(message).trim() && ( {!isUser && !message.isStreaming && getPlainText(message).trim() && (
@@ -370,7 +370,7 @@ export function ChatMessageComponent({ message, onRetry, agentAvatarUrl }: { mes
{isUser && onRetry && ( {isUser && onRetry && (
<button <button
onClick={() => onRetry(getPlainText(message))} onClick={() => onRetry(getPlainText(message))}
className="absolute top-2 right-2 h-7 w-7 rounded-lg border border-white/8 bg-zinc-800/80 backdrop-blur-sm flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:border-cyan-400/30 transition-all opacity-0 group-hover:opacity-100" className="absolute top-2 right-2 h-7 w-7 rounded-lg border border-pc-border bg-pc-elevated/80 backdrop-blur-sm flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:border-[var(--pc-accent-dim)] transition-all opacity-0 group-hover:opacity-100"
title={t('message.retry')} title={t('message.retry')}
aria-label={t('message.retry')} aria-label={t('message.retry')}
> >
@@ -408,7 +408,7 @@ export function ChatMessageComponent({ message, onRetry, agentAvatarUrl }: { mes
{!isUser && <InternalsSummary blocks={message.blocks} />} {!isUser && <InternalsSummary blocks={message.blocks} />}
</div> </div>
{message.timestamp && ( {message.timestamp && (
<div className={`mt-1 text-[11px] text-zinc-500 ${isUser ? 'text-right pr-2' : 'pl-2'}`}> <div className={`mt-1 text-[11px] text-pc-text-muted ${isUser ? 'text-right pr-2' : 'pl-2'}`}>
{formatTimestamp(message.timestamp)} {formatTimestamp(message.timestamp)}
</div> </div>
)} )}

View File

@@ -60,14 +60,14 @@ export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
return ( return (
<div className="group/code relative"> <div className="group/code relative">
{language && ( {language && (
<div className="flex items-center justify-between px-4 py-1.5 bg-zinc-800/80 border-b border-white/5 rounded-t-lg text-[11px] text-zinc-500 font-mono select-none"> <div className="flex items-center justify-between px-4 py-1.5 bg-pc-elevated/80 border-b border-pc-border rounded-t-lg text-[11px] text-pc-text-muted font-mono select-none">
{formatLanguage(language)} {formatLanguage(language)}
</div> </div>
)} )}
<pre {...props} className={`${props.className || ''} ${language ? '!rounded-t-none !mt-0' : ''}`} /> <pre {...props} className={`${props.className || ''} ${language ? '!rounded-t-none !mt-0' : ''}`} />
<button <button
onClick={handleCopy} onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-zinc-700/60 hover:bg-zinc-600/80 border border-white/10 text-zinc-400 hover:text-zinc-200 opacity-0 group-hover/code:opacity-100 transition-opacity duration-150" className="absolute top-2 right-2 p-1.5 rounded-lg bg-pc-elevated/60 hover:bg-pc-elevated/80 border border-pc-border-strong text-pc-text-secondary hover:text-pc-text opacity-0 group-hover/code:opacity-100 transition-opacity duration-150"
title="Copy code" title="Copy code"
aria-label="Copy code to clipboard" aria-label="Copy code to clipboard"
type="button" type="button"

View File

@@ -40,30 +40,30 @@ export class ErrorBoundary extends Component<Props, State> {
if (this.props.fallback) return this.props.fallback; if (this.props.fallback) return this.props.fallback;
return ( return (
<div className="h-dvh flex items-center justify-center bg-[var(--pc-bg-base)] text-zinc-300 p-6"> <div className="h-dvh flex items-center justify-center bg-[var(--pc-bg-base)] text-pc-text p-6">
<div className="max-w-md w-full space-y-4 text-center"> <div className="max-w-md w-full space-y-4 text-center">
<div className="text-4xl">💥</div> <div className="text-4xl">💥</div>
<h1 className="text-xl font-semibold text-zinc-100"> <h1 className="text-xl font-semibold text-pc-text">
{t('error.title')} {t('error.title')}
</h1> </h1>
<p className="text-sm text-zinc-400"> <p className="text-sm text-pc-text-secondary">
{t('error.description')} {t('error.description')}
</p> </p>
{this.state.error && ( {this.state.error && (
<pre className="mt-3 p-3 rounded-lg bg-zinc-800/60 text-xs text-red-400 text-left overflow-auto max-h-32"> <pre className="mt-3 p-3 rounded-lg bg-pc-elevated/60 text-xs text-red-400 text-left overflow-auto max-h-32">
{this.state.error.message} {this.state.error.message}
</pre> </pre>
)} )}
<div className="flex gap-3 justify-center pt-2"> <div className="flex gap-3 justify-center pt-2">
<button <button
onClick={this.handleRetry} onClick={this.handleRetry}
className="px-4 py-2 rounded-lg bg-zinc-700 hover:bg-zinc-600 text-sm font-medium transition-colors" className="px-4 py-2 rounded-lg bg-pc-elevated hover:bg-pc-elevated text-sm font-medium transition-colors"
> >
{t('error.retry')} {t('error.retry')}
</button> </button>
<button <button
onClick={this.handleReload} onClick={this.handleReload}
className="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-sm font-medium transition-colors" className="px-4 py-2 rounded-lg bg-[var(--pc-accent)] hover:bg-[var(--pc-accent-light)] text-sm font-medium transition-colors"
> >
{t('error.reload')} {t('error.reload')}
</button> </button>

View File

@@ -33,23 +33,23 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
return ( return (
<> <>
<header className="h-14 border-b border-white/8 bg-[var(--pc-bg-surface)]/90 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0" role="banner"> <header className="h-14 border-b border-pc-border bg-[var(--pc-bg-surface)]/90 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0" role="banner">
<button onClick={onToggleSidebar} aria-label={t('header.toggleSidebar')} className="lg:hidden p-2 rounded-2xl hover:bg-white/5 text-zinc-400 transition-colors"> <button onClick={onToggleSidebar} aria-label={t('header.toggleSidebar')} className="lg:hidden p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-secondary transition-colors">
<Menu size={20} /> <Menu size={20} />
</button> </button>
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
<img src={agentAvatarUrl || '/logo.png'} alt="PinchChat" className="h-9 w-9 rounded-2xl object-cover" /> <img src={agentAvatarUrl || '/logo.png'} alt="PinchChat" className="h-9 w-9 rounded-2xl object-cover" />
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold text-zinc-300 text-sm tracking-wide">{t('header.title')}</span> <span className="font-semibold text-pc-text text-sm tracking-wide">{t('header.title')}</span>
<Sparkles className="h-3.5 w-3.5 text-cyan-300/60" /> <Sparkles className="h-3.5 w-3.5 text-pc-accent-light/60" />
</div> </div>
<span className="text-xs text-zinc-500 truncate flex items-center gap-1.5"> <span className="text-xs text-pc-text-muted truncate flex items-center gap-1.5">
{activeSessionData?.agentId && ( {activeSessionData?.agentId && (
<span className="inline-flex items-center gap-0.5 text-cyan-400/70 font-medium"> <span className="inline-flex items-center gap-0.5 text-pc-accent/70 font-medium">
<Bot className="h-3 w-3" /> <Bot className="h-3 w-3" />
{activeSessionData.agentId} {activeSessionData.agentId}
<span className="text-zinc-600 mx-0.5">·</span> <span className="text-pc-text-faint mx-0.5">·</span>
</span> </span>
)} )}
{sessionLabel} {sessionLabel}
@@ -61,7 +61,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
<button <button
onClick={onToggleSound} onClick={onToggleSound}
aria-label={soundEnabled ? t('header.soundOff') : t('header.soundOn')} aria-label={soundEnabled ? t('header.soundOff') : t('header.soundOn')}
className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors" className="p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-muted hover:text-pc-text transition-colors"
title={soundEnabled ? t('header.soundOff') : t('header.soundOn')} title={soundEnabled ? t('header.soundOff') : t('header.soundOn')}
> >
{soundEnabled ? <Volume2 size={16} /> : <VolumeOff size={16} />} {soundEnabled ? <Volume2 size={16} /> : <VolumeOff size={16} />}
@@ -71,7 +71,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
<button <button
onClick={handleExport} onClick={handleExport}
aria-label={t('header.export')} aria-label={t('header.export')}
className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors" className="p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-muted hover:text-pc-text transition-colors"
title={t('header.export')} title={t('header.export')}
> >
<Download size={16} /> <Download size={16} />
@@ -80,26 +80,26 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
<ThemeSwitcher /> <ThemeSwitcher />
<LanguageSelector /> <LanguageSelector />
{status === 'connected' ? ( {status === 'connected' ? (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5"> <div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-cyan-300/80 shadow-[0_0_12px_rgba(34,211,238,0.6)]" /> <span className="w-2 h-2 rounded-full bg-[var(--pc-accent)] shadow-[0_0_12px_var(--pc-accent-dim)]" />
<span className="text-xs text-zinc-300 hidden sm:inline">{t('header.connected')}</span> <span className="text-xs text-pc-text hidden sm:inline">{t('header.connected')}</span>
</div> </div>
) : status === 'connecting' ? ( ) : status === 'connecting' ? (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5"> <div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-yellow-400/80 pulse-dot" /> <span className="w-2 h-2 rounded-full bg-yellow-400/80 pulse-dot" />
<span className="text-xs text-zinc-300 hidden sm:inline">{t('login.connecting')}</span> <span className="text-xs text-pc-text hidden sm:inline">{t('login.connecting')}</span>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5"> <div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-red-400/80" /> <span className="w-2 h-2 rounded-full bg-red-400/80" />
<span className="text-xs text-zinc-300 hidden sm:inline">{t('header.disconnected')}</span> <span className="text-xs text-pc-text hidden sm:inline">{t('header.disconnected')}</span>
</div> </div>
)} )}
{onLogout && ( {onLogout && (
<button <button
onClick={onLogout} onClick={onLogout}
aria-label={t('header.logout')} aria-label={t('header.logout')}
className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors" className="p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-muted hover:text-pc-text transition-colors"
title={t('header.logout')} title={t('header.logout')}
> >
<LogOut size={16} /> <LogOut size={16} />
@@ -115,17 +115,17 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
const opacity = Math.max(0.35, Math.min(1, pct / 100)); const opacity = Math.max(0.35, Math.min(1, pct / 100));
const barStyle = { width: `${pct}%`, backgroundColor: `rgba(56, 189, 248, ${opacity})` }; const barStyle = { width: `${pct}%`, backgroundColor: `rgba(56, 189, 248, ${opacity})` };
return ( return (
<div className="px-4 py-1.5 bg-[var(--pc-bg-surface)]/60 border-b border-white/8 flex items-center gap-3"> <div className="px-4 py-1.5 bg-[var(--pc-bg-surface)]/60 border-b border-pc-border flex items-center gap-3">
{activeSessionData?.model && ( {activeSessionData?.model && (
<span className="inline-flex items-center gap-1 text-[10px] text-zinc-500 shrink-0" title={`Model: ${activeSessionData.model}${activeSessionData.agentId ? ` · Agent: ${activeSessionData.agentId}` : ''}`}> <span className="inline-flex items-center gap-1 text-[10px] text-pc-text-muted shrink-0" title={`Model: ${activeSessionData.model}${activeSessionData.agentId ? ` · Agent: ${activeSessionData.agentId}` : ''}`}>
<Cpu className="h-2.5 w-2.5" /> <Cpu className="h-2.5 w-2.5" />
<span className="truncate max-w-[120px]">{activeSessionData.model.replace(/^.*\//, '')}</span> <span className="truncate max-w-[120px]">{activeSessionData.model.replace(/^.*\//, '')}</span>
</span> </span>
)} )}
<div className="flex-1 h-[5px] rounded-full bg-white/5 overflow-hidden"> <div className="flex-1 h-[5px] rounded-full bg-[var(--pc-hover)] overflow-hidden">
<div className="h-full rounded-full transition-all duration-500" style={barStyle} /> <div className="h-full rounded-full transition-all duration-500" style={barStyle} />
</div> </div>
<span className="text-[11px] text-zinc-400 tabular-nums shrink-0 whitespace-nowrap"> <span className="text-[11px] text-pc-text-secondary tabular-nums shrink-0 whitespace-nowrap">
{(total / 1000).toFixed(1)}k / {(ctx / 1000).toFixed(0)}k tokens {(total / 1000).toFixed(1)}k / {(ctx / 1000).toFixed(0)}k tokens
</span> </span>
</div> </div>

View File

@@ -27,7 +27,7 @@ function Lightbox({ src, alt, onClose }: ImageBlockProps & { onClose: () => void
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close preview" aria-label="Close preview"
className="absolute top-4 right-4 p-2 rounded-full bg-zinc-800/80 border border-white/10 text-zinc-300 hover:text-white hover:bg-zinc-700/80 transition-colors" className="absolute top-4 right-4 p-2 rounded-full bg-pc-elevated/80 border border-pc-border-strong text-pc-text hover:text-white hover:bg-pc-elevated/80 transition-colors"
> >
<X size={20} /> <X size={20} />
</button> </button>
@@ -51,7 +51,7 @@ export function ImageBlock({ src, alt }: ImageBlockProps) {
type="button" type="button"
onClick={() => setLightbox(true)} onClick={() => setLightbox(true)}
aria-label={`View ${alt || 'image'} full size`} aria-label={`View ${alt || 'image'} full size`}
className="block rounded-xl border border-white/8 cursor-pointer hover:brightness-110 transition-all focus:outline-none focus:ring-2 focus:ring-cyan-400/40" className="block rounded-xl border border-pc-border cursor-pointer hover:brightness-110 transition-all focus:outline-none focus:ring-2 focus:ring-[var(--pc-accent-dim)]"
> >
<img <img
src={src} src={src}

View File

@@ -9,7 +9,7 @@ interface Props {
function Kbd({ children }: { children: React.ReactNode }) { function Kbd({ children }: { children: React.ReactNode }) {
return ( return (
<kbd className="inline-flex items-center justify-center min-w-[1.75rem] h-7 px-2 rounded-lg border border-white/10 bg-zinc-800/80 text-xs font-mono text-zinc-300 shadow-[0_1px_0_0_rgba(255,255,255,0.05)]"> <kbd className="inline-flex items-center justify-center min-w-[1.75rem] h-7 px-2 rounded-lg border border-pc-border-strong bg-pc-elevated/80 text-xs font-mono text-pc-text shadow-[0_1px_0_0_rgba(255,255,255,0.05)]">
{children} {children}
</kbd> </kbd>
); );
@@ -18,7 +18,7 @@ function Kbd({ children }: { children: React.ReactNode }) {
function ShortcutRow({ keys, label }: { keys: React.ReactNode; label: string }) { function ShortcutRow({ keys, label }: { keys: React.ReactNode; label: string }) {
return ( return (
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<span className="text-sm text-zinc-400">{label}</span> <span className="text-sm text-pc-text-secondary">{label}</span>
<div className="flex items-center gap-1.5">{keys}</div> <div className="flex items-center gap-1.5">{keys}</div>
</div> </div>
); );
@@ -26,7 +26,7 @@ function ShortcutRow({ keys, label }: { keys: React.ReactNode; label: string })
function SectionTitle({ children }: { children: React.ReactNode }) { function SectionTitle({ children }: { children: React.ReactNode }) {
return ( return (
<div className="text-[11px] uppercase tracking-wider text-zinc-500 font-semibold mt-4 mb-1 first:mt-0"> <div className="text-[11px] uppercase tracking-wider text-pc-text-muted font-semibold mt-4 mb-1 first:mt-0">
{children} {children}
</div> </div>
); );
@@ -56,18 +56,18 @@ export function KeyboardShortcuts({ open, onClose }: Props) {
{/* Modal */} {/* Modal */}
<div <div
className="relative w-full max-w-md mx-4 rounded-3xl border border-white/8 bg-[var(--pc-bg-base)]/95 backdrop-blur-xl shadow-2xl animate-fade-in" className="relative w-full max-w-md mx-4 rounded-3xl border border-pc-border bg-[var(--pc-bg-base)]/95 backdrop-blur-xl shadow-2xl animate-fade-in"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/8"> <div className="flex items-center justify-between px-6 py-4 border-b border-pc-border">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Keyboard size={18} className="text-cyan-300/70" /> <Keyboard size={18} className="text-pc-accent-light/70" />
<h2 className="text-sm font-semibold text-zinc-200">{t('shortcuts.title')}</h2> <h2 className="text-sm font-semibold text-pc-text">{t('shortcuts.title')}</h2>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="h-8 w-8 rounded-xl flex items-center justify-center text-zinc-500 hover:text-zinc-300 hover:bg-white/5 transition-colors" className="h-8 w-8 rounded-xl flex items-center justify-center text-pc-text-muted hover:text-pc-text hover:bg-[var(--pc-hover)] transition-colors"
aria-label={t('shortcuts.close')} aria-label={t('shortcuts.close')}
> >
<X size={16} /> <X size={16} />
@@ -83,7 +83,7 @@ export function KeyboardShortcuts({ open, onClose }: Props) {
label={t('shortcuts.send')} label={t('shortcuts.send')}
/> />
<ShortcutRow <ShortcutRow
keys={<><Kbd>Shift</Kbd><span className="text-zinc-600">+</span><Kbd>Enter</Kbd></>} keys={<><Kbd>Shift</Kbd><span className="text-pc-text-faint">+</span><Kbd>Enter</Kbd></>}
label={t('shortcuts.newline')} label={t('shortcuts.newline')}
/> />
<ShortcutRow <ShortcutRow
@@ -95,11 +95,11 @@ export function KeyboardShortcuts({ open, onClose }: Props) {
<div className="py-3"> <div className="py-3">
<SectionTitle>{t('shortcuts.navigationSection')}</SectionTitle> <SectionTitle>{t('shortcuts.navigationSection')}</SectionTitle>
<ShortcutRow <ShortcutRow
keys={<><Kbd>{mod}</Kbd><span className="text-zinc-600">+</span><Kbd>K</Kbd></>} keys={<><Kbd>{mod}</Kbd><span className="text-pc-text-faint">+</span><Kbd>K</Kbd></>}
label={t('shortcuts.search')} label={t('shortcuts.search')}
/> />
<ShortcutRow <ShortcutRow
keys={<><Kbd>Alt</Kbd><span className="text-zinc-600">+</span><Kbd></Kbd><span className="text-zinc-600">/</span><Kbd></Kbd></>} keys={<><Kbd>Alt</Kbd><span className="text-pc-text-faint">+</span><Kbd></Kbd><span className="text-pc-text-faint">/</span><Kbd></Kbd></>}
label={t('shortcuts.switchSession')} label={t('shortcuts.switchSession')}
/> />
<ShortcutRow <ShortcutRow

View File

@@ -14,7 +14,7 @@ export function LanguageSelector() {
return ( return (
<button <button
onClick={cycle} onClick={cycle}
className="flex items-center gap-1.5 rounded-2xl border border-white/8 bg-zinc-800/30 px-2.5 py-1.5 text-xs text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors" className="flex items-center gap-1.5 rounded-2xl border border-pc-border bg-pc-elevated/30 px-2.5 py-1.5 text-xs text-pc-text-secondary hover:text-pc-text hover:bg-[var(--pc-hover)] transition-colors"
title="Change language" title="Change language"
aria-label={`Language: ${localeLabels[current] || current}. Click to change.`} aria-label={`Language: ${localeLabels[current] || current}. Click to change.`}
> >

View File

@@ -37,22 +37,22 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
}; };
return ( return (
<div className="h-dvh flex items-center justify-center bg-[var(--pc-bg-base)] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial-gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]"> <div className="h-dvh flex items-center justify-center bg-[var(--pc-bg-base)] text-pc-text bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial-gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]">
<div className="w-full max-w-md mx-4"> <div className="w-full max-w-md mx-4">
{/* Logo */} {/* Logo */}
<div className="flex flex-col items-center gap-3 mb-8"> <div className="flex flex-col items-center gap-3 mb-8">
<img src="/logo.png" alt="PinchChat" className="h-20 w-20 drop-shadow-lg" /> <img src="/logo.png" alt="PinchChat" className="h-20 w-20 drop-shadow-lg" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-zinc-200 tracking-wide">{t('login.title')}</h1> <h1 className="text-2xl font-bold text-pc-text tracking-wide">{t('login.title')}</h1>
<Sparkles className="h-5 w-5 text-cyan-300/60" /> <Sparkles className="h-5 w-5 text-pc-accent-light/60" />
</div> </div>
<p className="text-sm text-zinc-500">{t('login.subtitle')}</p> <p className="text-sm text-pc-text-muted">{t('login.subtitle')}</p>
</div> </div>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="rounded-2xl border border-white/8 bg-[var(--pc-bg-surface)]/80 backdrop-blur-xl p-6 space-y-5 shadow-2xl shadow-black/30"> <form onSubmit={handleSubmit} className="rounded-2xl border border-pc-border bg-[var(--pc-bg-surface)]/80 backdrop-blur-xl p-6 space-y-5 shadow-2xl shadow-black/30">
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="gateway-url" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider"> <label htmlFor="gateway-url" className="block text-xs font-medium text-pc-text-secondary uppercase tracking-wider">
{t('login.gatewayUrl')} {t('login.gatewayUrl')}
</label> </label>
<input <input
@@ -61,7 +61,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
value={url} value={url}
onChange={e => setUrl(e.target.value)} onChange={e => setUrl(e.target.value)}
placeholder="ws://192.168.1.14:18789" placeholder="ws://192.168.1.14:18789"
className="w-full rounded-xl border border-white/8 bg-zinc-800/50 px-4 py-3 text-sm text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-cyan-400/40 focus:ring-1 focus:ring-cyan-400/20 transition-all" className="w-full rounded-xl border border-pc-border bg-pc-elevated/50 px-4 py-3 text-sm text-pc-text placeholder:text-pc-text-faint outline-none focus:border-[var(--pc-accent-dim)] focus:ring-1 focus:ring-[var(--pc-accent-glow)] transition-all"
autoComplete="url" autoComplete="url"
disabled={isConnecting} disabled={isConnecting}
/> />
@@ -73,7 +73,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="gateway-token" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider"> <label htmlFor="gateway-token" className="block text-xs font-medium text-pc-text-secondary uppercase tracking-wider">
{t('login.token')} {t('login.token')}
</label> </label>
<div className="relative"> <div className="relative">
@@ -83,14 +83,14 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
value={token} value={token}
onChange={e => setToken(e.target.value)} onChange={e => setToken(e.target.value)}
placeholder={t('login.tokenPlaceholder')} placeholder={t('login.tokenPlaceholder')}
className="w-full rounded-xl border border-white/8 bg-zinc-800/50 px-4 py-3 pr-12 text-sm text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-cyan-400/40 focus:ring-1 focus:ring-cyan-400/20 transition-all" className="w-full rounded-xl border border-pc-border bg-pc-elevated/50 px-4 py-3 pr-12 text-sm text-pc-text placeholder:text-pc-text-faint outline-none focus:border-[var(--pc-accent-dim)] focus:ring-1 focus:ring-[var(--pc-accent-glow)] transition-all"
autoComplete="current-password" autoComplete="current-password"
disabled={isConnecting} disabled={isConnecting}
/> />
<button <button
type="button" type="button"
onClick={() => setShowToken(!showToken)} onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-pc-text-muted hover:text-pc-text transition-colors"
tabIndex={-1} tabIndex={-1}
aria-label={showToken ? t('login.hideToken') : t('login.showToken')} aria-label={showToken ? t('login.hideToken') : t('login.showToken')}
> >
@@ -121,7 +121,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
</button> </button>
</form> </form>
<p className="text-center text-xs text-zinc-600 mt-6"> <p className="text-center text-xs text-pc-text-faint mt-6">
{t('login.storedLocally')} {t('login.storedLocally')}
</p> </p>
</div> </div>

View File

@@ -52,7 +52,7 @@ export function SessionIcon({ session, isActive, isCurrentSession }: {
isCurrentSession?: boolean; isCurrentSession?: boolean;
}) { }) {
const size = 15; const size = 15;
const baseClass = isCurrentSession ? 'text-cyan-300/70' : isActive ? 'text-violet-400/70' : ''; const baseClass = isCurrentSession ? 'text-pc-accent-light/70' : isActive ? 'text-violet-400/70' : '';
// Detect cron sessions from key pattern // Detect cron sessions from key pattern
const isCron = session.key.includes(':cron:'); const isCron = session.key.includes(':cron:');

View File

@@ -142,18 +142,18 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
return ( return (
<> <>
{open && <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden" onClick={onClose} />} {open && <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden" onClick={onClose} />}
<aside role="navigation" aria-label="Sessions" className={`fixed lg:relative top-0 left-0 h-full bg-[var(--pc-bg-base)]/95 border-r border-white/8 z-50 transform ${dragging ? '' : 'transition-transform'} lg:translate-x-0 ${open ? 'translate-x-0' : '-translate-x-full'} flex flex-col backdrop-blur-xl`} style={{ width: `${width}px` }}> <aside role="navigation" aria-label="Sessions" className={`fixed lg:relative top-0 left-0 h-full bg-[var(--pc-bg-base)]/95 border-r border-pc-border z-50 transform ${dragging ? '' : 'transition-transform'} lg:translate-x-0 ${open ? 'translate-x-0' : '-translate-x-full'} flex flex-col backdrop-blur-xl`} style={{ width: `${width}px` }}>
<div className="h-14 flex items-center justify-between px-4 border-b border-white/8"> <div className="h-14 flex items-center justify-between px-4 border-b border-pc-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<div className="absolute -inset-1.5 rounded-xl bg-gradient-to-r from-cyan-400/15 to-violet-500/15 blur-lg" /> <div className="absolute -inset-1.5 rounded-xl bg-gradient-to-r from-cyan-400/15 to-violet-500/15 blur-lg" />
<div className="relative flex h-8 w-8 items-center justify-center rounded-xl border border-white/8 bg-zinc-800/50"> <div className="relative flex h-8 w-8 items-center justify-center rounded-xl border border-pc-border bg-pc-elevated/50">
<Sparkles className="h-4 w-4 text-cyan-200" /> <Sparkles className="h-4 w-4 text-pc-accent-light" />
</div> </div>
</div> </div>
<span className="font-semibold text-sm text-zinc-200 tracking-wide">{t('sidebar.title')}</span> <span className="font-semibold text-sm text-pc-text tracking-wide">{t('sidebar.title')}</span>
</div> </div>
<button onClick={onClose} className="lg:hidden p-1.5 rounded-xl hover:bg-white/5 text-zinc-400 transition-colors"> <button onClick={onClose} className="lg:hidden p-1.5 rounded-xl hover:bg-[var(--pc-hover)] text-pc-text-secondary transition-colors">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
@@ -162,7 +162,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
{sessions.length > 3 && ( {sessions.length > 3 && (
<div className="px-2 pt-2"> <div className="px-2 pt-2">
<div className="relative"> <div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" /> <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-pc-text-muted" />
<input <input
ref={searchRef} ref={searchRef}
type="text" type="text"
@@ -170,12 +170,12 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
onChange={e => updateFilter(e.target.value)} onChange={e => updateFilter(e.target.value)}
placeholder={t('sidebar.search')} placeholder={t('sidebar.search')}
aria-label={t('sidebar.search')} aria-label={t('sidebar.search')}
className="w-full pl-8 pr-3 py-1.5 rounded-xl border border-white/8 bg-zinc-800/30 text-xs text-zinc-300 placeholder:text-zinc-500 outline-none focus:ring-1 focus:ring-cyan-400/30 transition-all" className="w-full pl-8 pr-3 py-1.5 rounded-xl border border-pc-border bg-pc-elevated/30 text-xs text-pc-text placeholder:text-pc-text-muted outline-none focus:ring-1 focus:ring-[var(--pc-accent-dim)] transition-all"
/> />
{filter && ( {filter && (
<button <button
onClick={() => updateFilter('')} onClick={() => updateFilter('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300" className="absolute right-2 top-1/2 -translate-y-1/2 text-pc-text-muted hover:text-pc-text"
> >
<X size={12} /> <X size={12} />
</button> </button>
@@ -214,10 +214,10 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
}} }}
> >
{sessions.length === 0 && ( {sessions.length === 0 && (
<div className="px-3 py-8 text-center text-zinc-500 text-sm">{t('sidebar.empty')}</div> <div className="px-3 py-8 text-center text-pc-text-muted text-sm">{t('sidebar.empty')}</div>
)} )}
{sessions.length > 0 && filtered.length === 0 && ( {sessions.length > 0 && filtered.length === 0 && (
<div className="px-3 py-6 text-center text-zinc-500 text-xs">{t('sidebar.noResults')}</div> <div className="px-3 py-6 text-center text-pc-text-muted text-xs">{t('sidebar.noResults')}</div>
)} )}
{filtered.map((s, idx) => { {filtered.map((s, idx) => {
const isActive = s.key === activeSession; const isActive = s.key === activeSession;
@@ -228,7 +228,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
<div key={s.key}> <div key={s.key}>
{isFirstUnpinned && ( {isFirstUnpinned && (
<div className="flex items-center gap-2 px-3 py-1.5 mt-1 mb-1"> <div className="flex items-center gap-2 px-3 py-1.5 mt-1 mb-1">
<div className="flex-1 h-px bg-white/5" /> <div className="flex-1 h-px bg-[var(--pc-hover)]" />
</div> </div>
)} )}
<button <button
@@ -238,11 +238,11 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
onMouseEnter={() => setFocusIdx(idx)} onMouseEnter={() => setFocusIdx(idx)}
className={`group/item w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${ className={`group/item w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${
isActive isActive
? 'bg-white/5 text-cyan-200 border border-white/8 shadow-[0_0_12px_rgba(34,211,238,0.08)]' ? 'bg-[var(--pc-hover)] text-pc-accent-light border border-pc-border shadow-[0_0_12px_rgba(34,211,238,0.08)]'
: s.isActive : s.isActive
? 'bg-violet-500/5 text-violet-200 border border-violet-500/15 shadow-[0_0_10px_rgba(168,85,247,0.06)]' ? 'bg-violet-500/5 text-violet-200 border border-violet-500/15 shadow-[0_0_10px_rgba(168,85,247,0.06)]'
: 'text-zinc-400 hover:bg-white/5 border border-transparent' : 'text-pc-text-secondary hover:bg-[var(--pc-hover)] border border-transparent'
} ${isFocused && !isActive ? 'ring-1 ring-cyan-400/30' : ''}`} } ${isFocused && !isActive ? 'ring-1 ring-[var(--pc-accent-dim)]' : ''}`}
> >
<div className="relative"> <div className="relative">
<SessionIcon session={s} isActive={s.isActive} isCurrentSession={isActive} /> <SessionIcon session={s} isActive={s.isActive} isCurrentSession={isActive} />
@@ -250,7 +250,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(168,85,247,0.7)] animate-pulse" /> <span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(168,85,247,0.7)] animate-pulse" />
)} )}
{s.hasUnread && !isActive && ( {s.hasUnread && !isActive && (
<span className="absolute -top-0.5 -left-0.5 h-2 w-2 rounded-full bg-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.7)]" /> <span className="absolute -top-0.5 -left-0.5 h-2 w-2 rounded-full bg-[var(--pc-accent)] shadow-[0_0_8px_rgba(34,211,238,0.7)]" />
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -258,14 +258,14 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
<span className="flex-1 truncate">{sessionDisplayName(s)}</span> <span className="flex-1 truncate">{sessionDisplayName(s)}</span>
{(() => { {(() => {
const rel = relativeTime(s.updatedAt); const rel = relativeTime(s.updatedAt);
return rel ? <span className="text-[10px] text-zinc-500 tabular-nums shrink-0">{rel}</span> : null; return rel ? <span className="text-[10px] text-pc-text-muted tabular-nums shrink-0">{rel}</span> : null;
})()} })()}
<button <button
onClick={(e) => togglePin(s.key, e)} onClick={(e) => togglePin(s.key, e)}
className={`shrink-0 p-0.5 rounded-lg transition-all ${ className={`shrink-0 p-0.5 rounded-lg transition-all ${
isPinned isPinned
? 'text-cyan-400 opacity-80 hover:opacity-100' ? 'text-pc-accent opacity-80 hover:opacity-100'
: 'text-zinc-600 opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-zinc-400' : 'text-pc-text-faint opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-pc-text-secondary'
}`} }`}
title={isPinned ? t('sidebar.unpin') : t('sidebar.pin')} title={isPinned ? t('sidebar.unpin') : t('sidebar.pin')}
aria-label={isPinned ? t('sidebar.unpin') : t('sidebar.pin')} aria-label={isPinned ? t('sidebar.unpin') : t('sidebar.pin')}
@@ -274,20 +274,20 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); setConfirmDelete(s.key); }} onClick={(e) => { e.stopPropagation(); setConfirmDelete(s.key); }}
className="shrink-0 p-0.5 rounded-lg transition-all text-zinc-600 opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-red-400" className="shrink-0 p-0.5 rounded-lg transition-all text-pc-text-faint opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-red-400"
title={t('sidebar.delete')} title={t('sidebar.delete')}
aria-label={t('sidebar.delete')} aria-label={t('sidebar.delete')}
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
{s.messageCount != null && ( {s.messageCount != null && (
<span className={`text-[11px] px-2 py-0.5 rounded-full shrink-0 ${isActive ? 'bg-cyan-400/10 text-cyan-300' : 'bg-white/5 text-zinc-500'}`}> <span className={`text-[11px] px-2 py-0.5 rounded-full shrink-0 ${isActive ? 'bg-[var(--pc-accent-glow)] text-pc-accent-light' : 'bg-[var(--pc-hover)] text-pc-text-muted'}`}>
{s.messageCount} {s.messageCount}
</span> </span>
)} )}
</div> </div>
{s.lastMessagePreview && ( {s.lastMessagePreview && (
<p className="text-[11px] text-zinc-500 truncate mt-0.5 leading-tight">{s.lastMessagePreview.replace(/\s+/g, ' ').slice(0, 80)}</p> <p className="text-[11px] text-pc-text-muted truncate mt-0.5 leading-tight">{s.lastMessagePreview.replace(/\s+/g, ' ').slice(0, 80)}</p>
)} )}
{(() => { {(() => {
if (!s.contextTokens) return null; if (!s.contextTokens) return null;
@@ -296,10 +296,10 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
const barStyle = { width: `${pct}%`, backgroundColor: `rgba(56, 189, 248, ${barOpacity})` }; const barStyle = { width: `${pct}%`, backgroundColor: `rgba(56, 189, 248, ${barOpacity})` };
return ( return (
<div className="flex items-center gap-1.5 mt-1"> <div className="flex items-center gap-1.5 mt-1">
<div className="flex-1 h-[3px] rounded-full bg-white/5 overflow-hidden"> <div className="flex-1 h-[3px] rounded-full bg-[var(--pc-hover)] overflow-hidden">
<div className="h-full rounded-full" style={barStyle} /> <div className="h-full rounded-full" style={barStyle} />
</div> </div>
<span className="text-[9px] text-zinc-500 tabular-nums shrink-0">{Math.round(pct)}%</span> <span className="text-[9px] text-pc-text-muted tabular-nums shrink-0">{Math.round(pct)}%</span>
</div> </div>
); );
})()} })()}
@@ -310,17 +310,17 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
})} })}
</div> </div>
{/* Footer with version */} {/* Footer with version */}
<div className="px-4 py-3 border-t border-white/8 flex items-center justify-center gap-2"> <div className="px-4 py-3 border-t border-pc-border flex items-center justify-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-violet-300/60 shadow-[0_0_10px_rgba(168,85,247,0.5)]" /> <span className="h-1.5 w-1.5 rounded-full bg-violet-300/60 shadow-[0_0_10px_rgba(168,85,247,0.5)]" />
<span className="h-1.5 w-1.5 rounded-full bg-cyan-300/60 shadow-[0_0_10px_rgba(34,211,238,0.5)]" /> <span className="h-1.5 w-1.5 rounded-full bg-[var(--pc-accent-dim)] shadow-[0_0_10px_rgba(34,211,238,0.5)]" />
<span className="h-1.5 w-1.5 rounded-full bg-indigo-300/50 shadow-[0_0_10px_rgba(99,102,241,0.4)]" /> <span className="h-1.5 w-1.5 rounded-full bg-indigo-300/50 shadow-[0_0_10px_rgba(99,102,241,0.4)]" />
<span className="ml-1 text-[9px] text-zinc-600 select-all" title={`PinchChat v${__APP_VERSION__}`}>v{__APP_VERSION__}</span> <span className="ml-1 text-[9px] text-pc-text-faint select-all" title={`PinchChat v${__APP_VERSION__}`}>v{__APP_VERSION__}</span>
</div> </div>
{/* Resize drag handle */} {/* Resize drag handle */}
<div <div
onMouseDown={startDrag} onMouseDown={startDrag}
onTouchStart={startDrag} onTouchStart={startDrag}
className={`hidden lg:block absolute top-0 right-0 w-1.5 h-full cursor-col-resize group/resize z-10 ${dragging ? 'bg-cyan-400/20' : 'hover:bg-cyan-400/15'} transition-colors`} className={`hidden lg:block absolute top-0 right-0 w-1.5 h-full cursor-col-resize group/resize z-10 ${dragging ? 'bg-[var(--pc-accent-glow)]' : 'hover:bg-[var(--pc-accent-glow)]'} transition-colors`}
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"
aria-label="Resize sidebar" aria-label="Resize sidebar"
@@ -328,7 +328,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
aria-valuemin={MIN_WIDTH} aria-valuemin={MIN_WIDTH}
aria-valuemax={MAX_WIDTH} aria-valuemax={MAX_WIDTH}
> >
<div className={`absolute top-1/2 -translate-y-1/2 right-0 w-0.5 h-8 rounded-full ${dragging ? 'bg-cyan-400/50' : 'bg-white/0 group-hover/resize:bg-cyan-400/30'} transition-colors`} /> <div className={`absolute top-1/2 -translate-y-1/2 right-0 w-0.5 h-8 rounded-full ${dragging ? 'bg-[var(--pc-accent-dim)]' : 'bg-transparent group-hover/resize:bg-[var(--pc-accent-dim)]'} transition-colors`} />
</div> </div>
</aside> </aside>
{/* Prevent text selection while dragging */} {/* Prevent text selection while dragging */}
@@ -337,12 +337,12 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
{confirmDelete && ( {confirmDelete && (
<> <>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[70]" onClick={() => setConfirmDelete(null)} /> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[70]" onClick={() => setConfirmDelete(null)} />
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[80] w-72 bg-[var(--pc-bg-base)] border border-white/10 rounded-2xl p-5 shadow-2xl"> <div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[80] w-72 bg-[var(--pc-bg-base)] border border-pc-border-strong rounded-2xl p-5 shadow-2xl">
<p className="text-sm text-zinc-300 mb-4">{t('sidebar.deleteConfirm')}</p> <p className="text-sm text-pc-text mb-4">{t('sidebar.deleteConfirm')}</p>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
onClick={() => setConfirmDelete(null)} onClick={() => setConfirmDelete(null)}
className="px-3 py-1.5 text-xs rounded-xl border border-white/10 text-zinc-400 hover:bg-white/5 transition-colors" className="px-3 py-1.5 text-xs rounded-xl border border-pc-border-strong text-pc-text-secondary hover:bg-[var(--pc-hover)] transition-colors"
> >
{t('sidebar.deleteCancel')} {t('sidebar.deleteCancel')}
</button> </button>

View File

@@ -10,14 +10,14 @@ export function ThinkingBlock({ text }: { text: string }) {
<div className="my-2"> <div className="my-2">
<button <button
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1.5 rounded-2xl border border-white/8 bg-zinc-800/35 px-3 py-1.5 text-xs text-violet-300 hover:bg-white/5 transition-colors" className="inline-flex items-center gap-1.5 rounded-2xl border border-pc-border bg-pc-elevated/35 px-3 py-1.5 text-xs text-violet-300 hover:bg-[var(--pc-hover)] transition-colors"
> >
<Brain size={13} /> <Brain size={13} />
<span className="font-medium">{t('thinking.label')}</span> <span className="font-medium">{t('thinking.label')}</span>
{open ? <ChevronDown size={12} className="ml-1 text-zinc-500" /> : <ChevronRight size={12} className="ml-1 text-zinc-500" />} {open ? <ChevronDown size={12} className="ml-1 text-pc-text-muted" /> : <ChevronRight size={12} className="ml-1 text-pc-text-muted" />}
</button> </button>
{open && ( {open && (
<div className="mt-2 rounded-2xl border border-white/8 bg-zinc-800/25 p-3 text-sm italic text-zinc-400 whitespace-pre-wrap max-h-96 overflow-y-auto"> <div className="mt-2 rounded-2xl border border-pc-border bg-pc-elevated/25 p-3 text-sm italic text-pc-text-secondary whitespace-pre-wrap max-h-96 overflow-y-auto">
{text} {text}
</div> </div>
)} )}

View File

@@ -17,7 +17,7 @@ const toolColors: Record<string, ToolColor> = {
write: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' }, write: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
Edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' }, Edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' }, edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
browser: { border: 'border-cyan-500/30', bg: 'bg-cyan-500/10', text: 'text-cyan-300', icon: 'text-cyan-400', glow: 'shadow-[0_0_8px_rgba(6,182,212,0.15)]', expandBorder: 'border-cyan-500/20', expandBg: 'bg-cyan-950/20' }, browser: { border: 'border-cyan-500/30', bg: 'bg-cyan-500/10', text: 'text-pc-accent-light', icon: 'text-pc-accent', glow: 'shadow-[0_0_8px_rgba(6,182,212,0.15)]', expandBorder: 'border-cyan-500/20', expandBg: 'bg-cyan-950/20' },
image: { border: 'border-pink-500/30', bg: 'bg-pink-500/10', text: 'text-pink-300', icon: 'text-pink-400', glow: 'shadow-[0_0_8px_rgba(236,72,153,0.15)]', expandBorder: 'border-pink-500/20', expandBg: 'bg-pink-950/20' }, image: { border: 'border-pink-500/30', bg: 'bg-pink-500/10', text: 'text-pink-300', icon: 'text-pink-400', glow: 'shadow-[0_0_8px_rgba(236,72,153,0.15)]', expandBorder: 'border-pink-500/20', expandBg: 'bg-pink-950/20' },
message: { border: 'border-indigo-500/30', bg: 'bg-indigo-500/10', text: 'text-indigo-300', icon: 'text-indigo-400', glow: 'shadow-[0_0_8px_rgba(99,102,241,0.15)]', expandBorder: 'border-indigo-500/20', expandBg: 'bg-indigo-950/20' }, message: { border: 'border-indigo-500/30', bg: 'bg-indigo-500/10', text: 'text-indigo-300', icon: 'text-indigo-400', glow: 'shadow-[0_0_8px_rgba(99,102,241,0.15)]', expandBorder: 'border-indigo-500/20', expandBg: 'bg-indigo-950/20' },
memory_search: { border: 'border-rose-500/30', bg: 'bg-rose-500/10', text: 'text-rose-300', icon: 'text-rose-400', glow: 'shadow-[0_0_8px_rgba(244,63,94,0.15)]', expandBorder: 'border-rose-500/20', expandBg: 'bg-rose-950/20' }, memory_search: { border: 'border-rose-500/30', bg: 'bg-rose-500/10', text: 'text-rose-300', icon: 'text-rose-400', glow: 'shadow-[0_0_8px_rgba(244,63,94,0.15)]', expandBorder: 'border-rose-500/20', expandBg: 'bg-rose-950/20' },
@@ -26,7 +26,7 @@ const toolColors: Record<string, ToolColor> = {
sessions_spawn: { border: 'border-teal-500/30', bg: 'bg-teal-500/10', text: 'text-teal-300', icon: 'text-teal-400', glow: 'shadow-[0_0_8px_rgba(20,184,166,0.15)]', expandBorder: 'border-teal-500/20', expandBg: 'bg-teal-950/20' }, sessions_spawn: { border: 'border-teal-500/30', bg: 'bg-teal-500/10', text: 'text-teal-300', icon: 'text-teal-400', glow: 'shadow-[0_0_8px_rgba(20,184,166,0.15)]', expandBorder: 'border-teal-500/20', expandBg: 'bg-teal-950/20' },
}; };
const defaultColor: ToolColor = { border: 'border-zinc-500/30', bg: 'bg-zinc-500/10', text: 'text-zinc-300', icon: 'text-zinc-400', glow: 'shadow-[0_0_8px_rgba(161,161,170,0.1)]', expandBorder: 'border-zinc-500/20', expandBg: 'bg-zinc-800/25' }; const defaultColor: ToolColor = { border: 'border-pc-border-strong', bg: 'bg-pc-elevated/10', text: 'text-pc-text', icon: 'text-pc-text-secondary', glow: 'shadow-[0_0_8px_rgba(161,161,170,0.1)]', expandBorder: 'border-pc-border', expandBg: 'bg-pc-elevated/25' };
function getColor(name: string): ToolColor { function getColor(name: string): ToolColor {
return toolColors[name] || defaultColor; return toolColors[name] || defaultColor;
@@ -115,7 +115,7 @@ function WrapToggle({ wrap, onToggle }: { wrap: boolean; onToggle: () => void })
return ( return (
<button <button
onClick={onToggle} onClick={onToggle}
className="absolute top-2 right-8 p-1 rounded-lg bg-zinc-700/60 hover:bg-zinc-600/80 border border-white/10 text-zinc-400 hover:text-zinc-200 opacity-0 group-hover/tc-block:opacity-100 transition-opacity duration-150" className="absolute top-2 right-8 p-1 rounded-lg bg-pc-elevated/60 hover:bg-pc-elevated/80 border border-pc-border-strong text-pc-text-secondary hover:text-pc-text opacity-0 group-hover/tc-block:opacity-100 transition-opacity duration-150"
title={wrap ? 'No wrap' : 'Word wrap'} title={wrap ? 'No wrap' : 'Word wrap'}
aria-label="Toggle word wrap" aria-label="Toggle word wrap"
type="button" type="button"
@@ -138,7 +138,7 @@ function CopyButton({ text }: { text: string }) {
return ( return (
<button <button
onClick={handleCopy} onClick={handleCopy}
className="absolute top-2 right-2 p-1 rounded-lg bg-zinc-700/60 hover:bg-zinc-600/80 border border-white/10 text-zinc-400 hover:text-zinc-200 opacity-0 group-hover/tc-block:opacity-100 transition-opacity duration-150" className="absolute top-2 right-2 p-1 rounded-lg bg-pc-elevated/60 hover:bg-pc-elevated/80 border border-pc-border-strong text-pc-text-secondary hover:text-pc-text opacity-0 group-hover/tc-block:opacity-100 transition-opacity duration-150"
title={copied ? 'Copied!' : 'Copy'} title={copied ? 'Copied!' : 'Copy'}
aria-label="Copy to clipboard" aria-label="Copy to clipboard"
type="button" type="button"
@@ -266,7 +266,7 @@ export function ToolCall({ name, input, result }: { name: string; input?: Record
{/* Result summary (always visible if result exists) */} {/* Result summary (always visible if result exists) */}
{result && !open && ( {result && !open && (
<div className="mt-1 text-[11px] text-zinc-400 pl-2 truncate max-w-full"> <div className="mt-1 text-[11px] text-pc-text-secondary pl-2 truncate max-w-full">
{truncateResult(result)} {truncateResult(result)}
</div> </div>
)} )}
@@ -280,7 +280,7 @@ export function ToolCall({ name, input, result }: { name: string; input?: Record
<div className="group/tc-block relative"> <div className="group/tc-block relative">
<HighlightedPre <HighlightedPre
text={inputStr} text={inputStr}
className="text-xs bg-[var(--pc-bg-input)]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 font-mono" className="text-xs bg-[var(--pc-bg-input)]/60 border border-pc-border p-2.5 rounded-xl overflow-x-auto text-pc-text font-mono"
wrap={wrap} wrap={wrap}
/> />
<WrapToggle wrap={wrap} onToggle={() => setWrap(!wrap)} /> <WrapToggle wrap={wrap} onToggle={() => setWrap(!wrap)} />
@@ -299,7 +299,7 @@ export function ToolCall({ name, input, result }: { name: string; input?: Record
<div className="group/tc-block relative"> <div className="group/tc-block relative">
<HighlightedPre <HighlightedPre
text={imageData.remaining} text={imageData.remaining}
className="text-xs bg-[var(--pc-bg-input)]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 font-mono mb-2" className="text-xs bg-[var(--pc-bg-input)]/60 border border-pc-border p-2.5 rounded-xl overflow-x-auto text-pc-text font-mono mb-2"
wrap={wrap} wrap={wrap}
/> />
<WrapToggle wrap={wrap} onToggle={() => setWrap(!wrap)} /> <WrapToggle wrap={wrap} onToggle={() => setWrap(!wrap)} />
@@ -312,7 +312,7 @@ export function ToolCall({ name, input, result }: { name: string; input?: Record
<div className="group/tc-block relative"> <div className="group/tc-block relative">
<HighlightedPre <HighlightedPre
text={result} text={result}
className="text-xs bg-[var(--pc-bg-input)]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 max-h-64 overflow-y-auto font-mono" className="text-xs bg-[var(--pc-bg-input)]/60 border border-pc-border p-2.5 rounded-xl overflow-x-auto text-pc-text max-h-64 overflow-y-auto font-mono"
wrap={wrap} wrap={wrap}
/> />
<WrapToggle wrap={wrap} onToggle={() => setWrap(!wrap)} /> <WrapToggle wrap={wrap} onToggle={() => setWrap(!wrap)} />

View File

@@ -24,17 +24,17 @@ export function TypingIndicator() {
return ( return (
<div className="animate-fade-in flex items-start gap-3 px-4 py-3"> <div className="animate-fade-in flex items-start gap-3 px-4 py-3">
<div className="shrink-0 flex h-9 w-9 items-center justify-center rounded-2xl border border-white/10 bg-zinc-900/60"> <div className="shrink-0 flex h-9 w-9 items-center justify-center rounded-2xl border border-pc-border-strong bg-pc-input/60">
<Bot className="h-4 w-4 text-cyan-200" /> <Bot className="h-4 w-4 text-pc-accent-light" />
</div> </div>
<div className="rounded-3xl border border-white/10 bg-zinc-900/55 px-4 py-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]"> <div className="rounded-3xl border border-pc-border-strong bg-pc-input/55 px-4 py-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" /> <span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" /> <span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" /> <span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
<span className="ml-2 text-xs text-zinc-400">{t('chat.thinking')}</span> <span className="ml-2 text-xs text-pc-text-secondary">{t('chat.thinking')}</span>
{elapsed >= 2 && ( {elapsed >= 2 && (
<span className="text-[10px] text-zinc-500 tabular-nums ml-1">{formatElapsed(elapsed)}</span> <span className="text-[10px] text-pc-text-muted tabular-nums ml-1">{formatElapsed(elapsed)}</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -29,6 +29,9 @@ const themes: Record<ThemeName, Record<string, string>> = {
'--pc-scrollbar-thumb-hover': '#71717a', '--pc-scrollbar-thumb-hover': '#71717a',
'--pc-user-bubble': 'rgba(34,211,238,0.06)', '--pc-user-bubble': 'rgba(34,211,238,0.06)',
'--pc-user-border': 'rgba(34,211,238,0.15)', '--pc-user-border': 'rgba(34,211,238,0.15)',
'--pc-hover': 'rgba(255,255,255,0.05)',
'--pc-hover-strong': 'rgba(255,255,255,0.08)',
'--pc-separator': 'rgba(255,255,255,0.05)',
}, },
light: { light: {
'--pc-bg-base': '#f4f4f5', '--pc-bg-base': '#f4f4f5',
@@ -48,6 +51,9 @@ const themes: Record<ThemeName, Record<string, string>> = {
'--pc-scrollbar-thumb-hover': '#71717a', '--pc-scrollbar-thumb-hover': '#71717a',
'--pc-user-bubble': 'rgba(34,211,238,0.08)', '--pc-user-bubble': 'rgba(34,211,238,0.08)',
'--pc-user-border': 'rgba(34,211,238,0.25)', '--pc-user-border': 'rgba(34,211,238,0.25)',
'--pc-hover': 'rgba(0,0,0,0.05)',
'--pc-hover-strong': 'rgba(0,0,0,0.08)',
'--pc-separator': 'rgba(0,0,0,0.08)',
}, },
oled: { oled: {
'--pc-bg-base': '#000000', '--pc-bg-base': '#000000',
@@ -67,6 +73,9 @@ const themes: Record<ThemeName, Record<string, string>> = {
'--pc-scrollbar-thumb-hover': '#52525b', '--pc-scrollbar-thumb-hover': '#52525b',
'--pc-user-bubble': 'rgba(34,211,238,0.05)', '--pc-user-bubble': 'rgba(34,211,238,0.05)',
'--pc-user-border': 'rgba(34,211,238,0.12)', '--pc-user-border': 'rgba(34,211,238,0.12)',
'--pc-hover': 'rgba(255,255,255,0.04)',
'--pc-hover-strong': 'rgba(255,255,255,0.06)',
'--pc-separator': 'rgba(255,255,255,0.04)',
}, },
}; };

View File

@@ -1,6 +1,23 @@
@import "tailwindcss"; @import "tailwindcss";
@import "highlight.js/styles/base16/material-palenight.min.css"; @import "highlight.js/styles/base16/material-palenight.min.css";
@theme {
/* Theme-aware colors mapped to CSS custom properties */
--color-pc-base: var(--pc-bg-base);
--color-pc-surface: var(--pc-bg-surface);
--color-pc-elevated: var(--pc-bg-elevated);
--color-pc-input: var(--pc-bg-input);
--color-pc-code: var(--pc-bg-code);
--color-pc-border: var(--pc-border);
--color-pc-border-strong: var(--pc-border-strong);
--color-pc-text: var(--pc-text-primary);
--color-pc-text-secondary: var(--pc-text-secondary);
--color-pc-text-muted: var(--pc-text-muted);
--color-pc-text-faint: var(--pc-text-faint);
--color-pc-accent: var(--pc-accent);
--color-pc-accent-light: var(--pc-accent-light);
}
:root { :root {
--pc-bg-base: #1e1e24; --pc-bg-base: #1e1e24;
--pc-bg-surface: #232329; --pc-bg-surface: #232329;
@@ -24,6 +41,9 @@
--pc-accent-rgb: 34,211,238; --pc-accent-rgb: 34,211,238;
--pc-user-bubble: rgba(34,211,238,0.06); --pc-user-bubble: rgba(34,211,238,0.06);
--pc-user-border: rgba(34,211,238,0.15); --pc-user-border: rgba(34,211,238,0.15);
--pc-hover: rgba(255,255,255,0.05);
--pc-hover-strong: rgba(255,255,255,0.08);
--pc-separator: rgba(255,255,255,0.05);
} }
* { * {
@@ -61,12 +81,12 @@ textarea::-webkit-scrollbar:horizontal {
} }
textarea::-webkit-scrollbar-thumb { textarea::-webkit-scrollbar-thumb {
background: #3f3f46; background: var(--pc-scrollbar-thumb);
border-radius: 2px; border-radius: 2px;
} }
textarea::-webkit-scrollbar-thumb:hover { textarea::-webkit-scrollbar-thumb:hover {
background: #52525b; background: var(--pc-scrollbar-thumb-hover);
} }
textarea { textarea {
@@ -118,7 +138,7 @@ html, body {
/* Markdown styles */ /* Markdown styles */
.markdown-body pre { .markdown-body pre {
background: var(--pc-bg-code) !important; background: var(--pc-bg-code) !important;
border: 1px solid rgba(255,255,255,0.06); border: 1px solid var(--pc-border);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
overflow-x: auto; overflow-x: auto;
@@ -136,7 +156,7 @@ html, body {
} }
.markdown-body code { .markdown-body code {
background: rgba(255,255,255,0.07); background: var(--pc-accent-glow);
padding: 2px 6px; padding: 2px 6px;
border-radius: 6px; border-radius: 6px;
font-size: 0.85em; font-size: 0.85em;
@@ -149,12 +169,12 @@ html, body {
.markdown-body p { margin: 4px 0; } .markdown-body p { margin: 4px 0; }
.markdown-body ul, .markdown-body ol { margin: 4px 0; padding-left: 20px; } .markdown-body ul, .markdown-body ol { margin: 4px 0; padding-left: 20px; }
.markdown-body blockquote { border-left: 3px solid rgba(34,211,238,0.4); padding-left: 12px; margin: 8px 0; opacity: 0.8; } .markdown-body blockquote { border-left: 3px solid var(--pc-accent-dim); padding-left: 12px; margin: 8px 0; opacity: 0.8; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 12px 0 4px; } .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 12px 0 4px; }
.markdown-body a { color: var(--pc-accent-light); text-decoration: underline; } .markdown-body a { color: var(--pc-accent-light); text-decoration: underline; }
.markdown-body table { border-collapse: collapse; margin: 8px 0; display: block; overflow-x: auto; max-width: 100%; } .markdown-body table { border-collapse: collapse; margin: 8px 0; display: block; overflow-x: auto; max-width: 100%; }
.markdown-body th, .markdown-body td { border: 1px solid rgba(255,255,255,0.08); padding: 6px 12px; } .markdown-body th, .markdown-body td { border: 1px solid var(--pc-border); padding: 6px 12px; }
.markdown-body th { background: rgba(255,255,255,0.04); } .markdown-body th { background: var(--pc-accent-glow); }
.markdown-body img { max-width: 100%; border-radius: 8px; } .markdown-body img { max-width: 100%; border-radius: 8px; }
/* Accessibility: respect reduced-motion preferences */ /* Accessibility: respect reduced-motion preferences */