feat(theme): dark mode + UI primitives standardization (#2046)

This commit is contained in:
Steven Enamakel
2026-05-17 23:01:27 -07:00
committed by GitHub
parent fe87ba772f
commit 0ad72dccfa
159 changed files with 3814 additions and 2298 deletions
+23 -20
View File
@@ -33,6 +33,7 @@ import {
import ChatRuntimeProvider from './providers/ChatRuntimeProvider';
import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider';
import SocketProvider from './providers/SocketProvider';
import ThemeProvider from './providers/ThemeProvider';
import { trackPageView } from './services/analytics';
import { startCoreHealthMonitor, stopCoreHealthMonitor } from './services/coreHealthMonitor';
import {
@@ -83,26 +84,28 @@ function App() {
)}>
<Provider store={store}>
<PersistGate loading={<PersistRehydrationScreen />} persistor={persistor}>
<I18nProvider>
<BootCheckGate>
<CoreStateProvider>
<SocketProvider>
<ChatRuntimeProvider>
<Router>
<CommandProvider>
<ServiceBlockingGate>
<AppShell />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<AppUpdatePrompt />
</ServiceBlockingGate>
</CommandProvider>
</Router>
</ChatRuntimeProvider>
</SocketProvider>
</CoreStateProvider>
</BootCheckGate>
</I18nProvider>
<ThemeProvider>
<I18nProvider>
<BootCheckGate>
<CoreStateProvider>
<SocketProvider>
<ChatRuntimeProvider>
<Router>
<CommandProvider>
<ServiceBlockingGate>
<AppShell />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<AppUpdatePrompt />
</ServiceBlockingGate>
</CommandProvider>
</Router>
</ChatRuntimeProvider>
</SocketProvider>
</CoreStateProvider>
</BootCheckGate>
</I18nProvider>
</ThemeProvider>
</PersistGate>
</Provider>
</Sentry.ErrorBoundary>
@@ -56,9 +56,11 @@ interface PanelProps {
function Panel({ children }: PanelProps) {
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-[#f5f5f5] p-4">
<div
className="fixed inset-0 z-[10000] flex items-center justify-center p-4"
style={{ backgroundColor: 'var(--color-background)' }}>
<AppBackground />
<div className="relative z-10 w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-10 shadow-soft animate-fade-up">
<div className="relative z-10 w-full max-w-xl rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft animate-fade-up">
{children}
</div>
</div>
@@ -212,16 +214,16 @@ function ModePicker({ onConfirm }: PickerProps) {
return (
<Panel>
<BootCheckLanguageSelect />
<h2 className="text-xl font-semibold text-stone-900">
<h2 className="text-xl font-semibold text-stone-900 dark:text-neutral-100">
{isDesktop ? t('bootCheck.chooseCoreMode') : t('bootCheck.connectToCore')}
</h2>
<p className="mt-2 text-sm text-stone-600">
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
{isDesktop ? t('bootCheck.desktopDescription') : t('bootCheck.webDescription')}
</p>
{!isDesktop && (
<div
className="mt-4 rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600"
className="mt-4 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 text-xs text-stone-600 dark:text-neutral-300"
data-testid="web-download-cta">
{t('bootCheck.preferDesktop')}{' '}
<a
@@ -244,11 +246,13 @@ function ModePicker({ onConfirm }: PickerProps) {
aria-pressed={selected === 'local'}
className={`rounded-xl border-2 p-5 text-left transition-colors focus:outline-none ${
selected === 'local'
? '!border-primary-500 bg-primary-50 text-stone-900 shadow-sm'
: '!border-stone-200 bg-white text-stone-700 hover:!border-stone-300 hover:bg-stone-50'
? '!border-primary-500 bg-primary-50 dark:bg-primary-500/15 text-stone-900 dark:text-neutral-100 shadow-sm'
: '!border-stone-200 dark:!border-neutral-700 bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 hover:!border-stone-300 dark:hover:!border-neutral-600 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
}`}>
<div className="font-medium">{t('bootCheck.localRecommended')}</div>
<div className="mt-0.5 text-xs text-stone-500">{t('bootCheck.localDescription')}</div>
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('bootCheck.localDescription')}
</div>
</button>
)}
@@ -260,18 +264,20 @@ function ModePicker({ onConfirm }: PickerProps) {
aria-pressed={selected === 'cloud'}
className={`rounded-xl border-2 p-5 text-left transition-colors focus:outline-none ${
selected === 'cloud'
? '!border-primary-500 bg-primary-50 text-stone-900 shadow-sm'
: '!border-stone-200 bg-white text-stone-700 hover:!border-stone-300 hover:bg-stone-50'
? '!border-primary-500 bg-primary-50 dark:bg-primary-500/15 text-stone-900 dark:text-neutral-100 shadow-sm'
: '!border-stone-200 dark:!border-neutral-700 bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 hover:!border-stone-300 dark:hover:!border-neutral-600 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
}`}>
<div className="font-medium">{t('bootCheck.cloudMode')}</div>
<div className="mt-0.5 text-xs text-stone-500">{t('bootCheck.cloudDescription')}</div>
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('bootCheck.cloudDescription')}
</div>
</button>
)}
{selected === 'cloud' && (
<div className="mt-1 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-stone-700">
<label className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('bootCheck.coreRpcUrl')}
</label>
<input
@@ -283,12 +289,12 @@ function ModePicker({ onConfirm }: PickerProps) {
setUrlError(null);
setTestStatus({ kind: 'idle' });
}}
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
{urlError && <p className="text-xs text-red-600">{urlError}</p>}
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-stone-700">
<label className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('bootCheck.authToken')} (
<code className="text-[10px]">OPENHUMAN_CORE_TOKEN</code>)
</label>
@@ -303,10 +309,10 @@ function ModePicker({ onConfirm }: PickerProps) {
setTokenError(null);
setTestStatus({ kind: 'idle' });
}}
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
{tokenError && <p className="text-xs text-red-600">{tokenError}</p>}
<p className="text-[11px] text-stone-500 leading-snug">
<p className="text-[11px] text-stone-500 dark:text-neutral-400 leading-snug">
{t('bootCheck.storedLocally')} <code>Authorization: Bearer </code> on every RPC.
</p>
</div>
@@ -316,7 +322,7 @@ function ModePicker({ onConfirm }: PickerProps) {
type="button"
onClick={handleTestConnection}
disabled={testStatus.kind === 'testing'}
className="rounded-lg border border-stone-300 bg-white px-3 py-1.5 text-xs text-stone-700 hover:bg-stone-50 disabled:opacity-60">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
{testStatus.kind === 'testing'
? t('bootCheck.testing')
: t('bootCheck.testConnection')}
@@ -362,8 +368,10 @@ function CheckingScreen() {
return (
<Panel>
<div className="flex flex-col items-center gap-4 py-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 border-t-primary-500" />
<p className="text-sm text-stone-600">{t('bootCheck.checkingCore')}</p>
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 dark:border-neutral-700 border-t-primary-500" />
<p className="text-sm text-stone-600 dark:text-neutral-300">
{t('bootCheck.checkingCore')}
</p>
</div>
</Panel>
);
@@ -398,8 +406,10 @@ function ResultScreen({
if (result.kind === 'unreachable') {
return (
<Panel>
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.cannotReach')}</h2>
<p className="mt-2 text-sm text-stone-600">
<h2 className="text-xl font-semibold text-stone-900 dark:text-neutral-100">
{t('bootCheck.cannotReach')}
</h2>
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
{result.reason || t('bootCheck.cannotReachDesc')}
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
@@ -408,13 +418,13 @@ function ResultScreen({
type="button"
onClick={onRetry}
disabled={actionBusy}
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
{t('common.retry')}
</button>
<button
type="button"
onClick={onSwitchMode}
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
{t('bootCheck.switchMode')}
</button>
<button
@@ -431,8 +441,12 @@ function ResultScreen({
if (result.kind === 'daemonDetected') {
return (
<Panel>
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.legacyDetected')}</h2>
<p className="mt-2 text-sm text-stone-600">{t('bootCheck.legacyDescription')}</p>
<h2 className="text-xl font-semibold text-stone-900 dark:text-neutral-100">
{t('bootCheck.legacyDetected')}
</h2>
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
{t('bootCheck.legacyDescription')}
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
@@ -446,7 +460,7 @@ function ResultScreen({
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
</div>
@@ -457,8 +471,12 @@ function ResultScreen({
if (result.kind === 'outdatedLocal') {
return (
<Panel>
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.localNeedsRestart')}</h2>
<p className="mt-2 text-sm text-stone-600">{t('bootCheck.localNeedsRestartDesc')}</p>
<h2 className="text-xl font-semibold text-stone-900 dark:text-neutral-100">
{t('bootCheck.localNeedsRestart')}
</h2>
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
{t('bootCheck.localNeedsRestartDesc')}
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
@@ -472,7 +490,7 @@ function ResultScreen({
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
</div>
@@ -483,8 +501,12 @@ function ResultScreen({
if (result.kind === 'outdatedCloud') {
return (
<Panel>
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.cloudNeedsUpdate')}</h2>
<p className="mt-2 text-sm text-stone-600">{t('bootCheck.cloudNeedsUpdateDesc')}</p>
<h2 className="text-xl font-semibold text-stone-900 dark:text-neutral-100">
{t('bootCheck.cloudNeedsUpdate')}
</h2>
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
{t('bootCheck.cloudNeedsUpdateDesc')}
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
@@ -498,7 +520,7 @@ function ResultScreen({
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
</div>
@@ -509,8 +531,12 @@ function ResultScreen({
// noVersionMethod — treat like outdated, user picks which flavor of action
return (
<Panel>
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.versionCheckFailed')}</h2>
<p className="mt-2 text-sm text-stone-600">{t('bootCheck.versionCheckFailedDesc')}</p>
<h2 className="text-xl font-semibold text-stone-900 dark:text-neutral-100">
{t('bootCheck.versionCheckFailed')}
</h2>
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
{t('bootCheck.versionCheckFailedDesc')}
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
@@ -524,7 +550,7 @@ function ResultScreen({
type="button"
onClick={onSwitchMode}
disabled={actionBusy}
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
</div>
+3 -3
View File
@@ -197,7 +197,7 @@ const BottomTabBar = () => {
onBlur={e => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setRevealed(false);
}}>
<nav className="pointer-events-auto inline-flex items-center gap-1 rounded-sm border border-stone-300 bg-stone-200 shadow-soft px-1 py-1">
<nav className="pointer-events-auto inline-flex items-center gap-1 rounded-sm border border-stone-300 dark:border-neutral-700 bg-stone-200 dark:bg-neutral-900 shadow-soft px-1 py-1">
{tabs.map(tab => {
const active = isActive(tab.path);
const showBadge = tab.id === 'notifications' && unreadCount > 0;
@@ -216,8 +216,8 @@ const BottomTabBar = () => {
onClick={() => navigate(tab.path)}
className={`group relative flex items-center px-2 py-2 rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
active
? 'bg-white text-stone-900 font-semibold shadow-sm'
: 'bg-transparent text-stone-500 hover:bg-stone-300/50 hover:text-stone-700'
? 'bg-white dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-semibold shadow-sm'
: 'bg-transparent text-stone-500 dark:text-neutral-400 hover:bg-stone-300/50 dark:hover:bg-neutral-800/60 hover:text-stone-700 dark:hover:text-neutral-200'
}`}
aria-label={
tab.id === 'notifications' && unreadCount > 0
+1 -1
View File
@@ -77,7 +77,7 @@ const ConnectionIndicator = ({
return (
<div className={`${className}`}>
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-stone-50 border border-stone-200">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800">
<div
className={`w-2 h-2 ${config.color} rounded-full ${config.pulse ? 'animate-pulse' : ''}`}
/>
+1 -1
View File
@@ -28,7 +28,7 @@ interface LanguageSelectProps {
}
const DEFAULT_CLASS =
"appearance-none rounded-lg border border-stone-200 bg-white bg-[url('data:image/svg+xml;utf8,<svg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2020%2020%22%20fill=%22%2378716c%22><path%20d=%22M5.293%207.293a1%201%200%20011.414%200L10%2010.586l3.293-3.293a1%201%200%20111.414%201.414l-4%204a1%201%200%2001-1.414%200l-4-4a1%201%200%20010-1.414z%22/></svg>')] bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem_1rem] py-2 pl-3 pr-8 text-xs font-medium text-stone-700 hover:border-stone-300 hover:bg-stone-50 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 cursor-pointer";
"appearance-none rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 bg-[url('data:image/svg+xml;utf8,<svg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2020%2020%22%20fill=%22%2378716c%22><path%20d=%22M5.293%207.293a1%201%200%20011.414%200L10%2010.586l3.293-3.293a1%201%200%20111.414%201.414l-4%204a1%201%200%2001-1.414%200l-4-4a1%201%200%20010-1.414z%22/></svg>')] bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem_1rem] py-2 pl-3 pr-8 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:border-stone-300 dark:hover:border-neutral-600 hover:bg-stone-50 dark:hover:bg-neutral-800 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 cursor-pointer";
/**
* Shared language picker used by the boot-check gate and the Settings home
+37 -25
View File
@@ -86,15 +86,17 @@ const OpenhumanLinkModal = () => {
role="dialog"
aria-modal="true">
<div
className="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
className="w-full max-w-md rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-stone-100 px-5 py-3">
<h2 className="text-sm font-semibold text-stone-900">{titleForPath(activePath, t)}</h2>
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-3">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{titleForPath(activePath, t)}
</h2>
<button
type="button"
onClick={close}
aria-label="Close"
className="rounded p-1 text-stone-500 hover:bg-stone-100 hover:text-stone-800">
className="rounded p-1 text-stone-500 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800/60 hover:text-stone-800 dark:hover:text-neutral-100">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
strokeLinecap="round"
@@ -125,7 +127,7 @@ const MessagingSetupBridge = ({ onClose }: { onClose: () => void }) => {
if (loading && !telegram) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="rounded-2xl bg-white px-6 py-4 text-sm text-stone-600 shadow-xl">
<div className="rounded-2xl bg-white dark:bg-neutral-900 px-6 py-4 text-sm text-stone-600 dark:text-neutral-300 shadow-xl">
{t('app.openhumanLink.loadingChannelSetup')}
</div>
</div>
@@ -138,14 +140,14 @@ const MessagingSetupBridge = ({ onClose }: { onClose: () => void }) => {
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}>
<div
className="rounded-2xl bg-white p-6 text-sm text-stone-700 shadow-xl max-w-sm"
className="rounded-2xl bg-white dark:bg-neutral-900 p-6 text-sm text-stone-700 dark:text-neutral-200 shadow-xl max-w-sm"
onClick={e => e.stopPropagation()}>
<p>{t('app.openhumanLink.telegramUnavailable')}</p>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
{t('common.close')}
</button>
</div>
@@ -258,10 +260,10 @@ const NotificationsBody = ({ close }: { close: () => void }) => {
};
return (
<div className="space-y-4 text-sm text-stone-700">
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<p>{t('app.openhumanLink.notifications.intro')}</p>
{permissionState === 'denied' && (
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
<div className="rounded-xl border border-coral-200 bg-coral-50 dark:bg-coral-500/15 p-3 text-xs text-coral-700 dark:text-coral-300">
{t('app.openhumanLink.notifications.blocked')}
<br />
{t('app.openhumanLink.notifications.blockedStep1')}
@@ -272,7 +274,7 @@ const NotificationsBody = ({ close }: { close: () => void }) => {
</div>
)}
{(permissionState === 'prompt' || permissionState === 'unknown') && (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-700">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 text-xs text-stone-700 dark:text-neutral-200">
{t('app.openhumanLink.notifications.promptHint')}
</div>
)}
@@ -305,13 +307,15 @@ const NotificationsBody = ({ close }: { close: () => void }) => {
const BillingBody = ({ close }: { close: () => void }) => {
const { t } = useT();
return (
<div className="space-y-4 text-sm text-stone-700">
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<p className="text-xs uppercase tracking-wide text-stone-500">
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<p className="text-xs uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('app.openhumanLink.billing.trialCredit')}
</p>
<p className="mt-1 text-2xl font-semibold text-stone-900">$1.00</p>
<p className="mt-1 text-xs text-stone-500">{t('app.openhumanLink.billing.trialDesc')}</p>
<p className="mt-1 text-2xl font-semibold text-stone-900 dark:text-neutral-100">$1.00</p>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('app.openhumanLink.billing.trialDesc')}
</p>
</div>
<button
type="button"
@@ -333,9 +337,9 @@ const DISCORD_INVITE_URL = 'https://discord.tinyhumans.ai/';
const DiscordBody = ({ close }: { close: () => void }) => {
const { t } = useT();
return (
<div className="space-y-4 text-sm text-stone-700">
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<p>{t('app.openhumanLink.discord.intro')}</p>
<ul className="space-y-1.5 text-xs text-stone-600 pl-1">
<ul className="space-y-1.5 text-xs text-stone-600 dark:text-neutral-300 pl-1">
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary-400 flex-shrink-0" />
{t('app.openhumanLink.discord.perk1')}
@@ -484,7 +488,7 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
: t('app.openhumanLink.accounts.done');
return (
<div className="space-y-4 text-sm text-stone-700">
<div className="space-y-4 text-sm text-stone-700 dark:text-neutral-200">
<p>{t('app.openhumanLink.accounts.intro')}</p>
<div className="space-y-2">
{providerDescriptors.map(p => {
@@ -494,19 +498,25 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
return (
<div
key={p.id}
className="flex items-center gap-3 rounded-xl border border-stone-100 bg-white p-3">
className="flex items-center gap-3 rounded-xl border border-stone-100 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<ProviderIcon provider={p.id} className="h-5 w-5 flex-none" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-stone-900">{p.label}</div>
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{p.label}
</div>
{on && status ? (
<div className="flex items-center gap-1.5">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${statusDisplay(status).dotClass}`}
/>
<span className="text-xs text-stone-500">{statusDisplay(status).label}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">
{statusDisplay(status).label}
</span>
</div>
) : (
<p className="line-clamp-1 text-xs text-stone-500">{p.description}</p>
<p className="line-clamp-1 text-xs text-stone-500 dark:text-neutral-400">
{p.description}
</p>
)}
</div>
<button
@@ -528,7 +538,9 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
);
})}
</div>
<p className="text-xs text-stone-400">{t('app.openhumanLink.accounts.webviewNote')}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('app.openhumanLink.accounts.webviewNote')}
</p>
<DoneFooter close={close} onDone={handleDone} doneLabel={doneLabel} />
</div>
);
@@ -555,13 +567,13 @@ const DoneFooter = ({
<button
type="button"
onClick={close}
className="text-xs font-medium text-stone-500 hover:text-stone-800">
className="text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-800 dark:hover:text-neutral-100">
{resolvedSkip}
</button>
<button
type="button"
onClick={onDone ?? close}
className="rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
{resolvedDone}
</button>
</div>
@@ -54,10 +54,14 @@ function PersistRehydrationScreen() {
};
return (
<div className="fixed inset-0 flex items-center justify-center bg-canvas-50 p-6">
<div className="max-w-sm w-full space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-soft text-center">
<p className="text-sm font-medium text-stone-900">{t('app.persistRehydration.heading')}</p>
<p className="text-xs text-stone-500 leading-relaxed">{t('app.persistRehydration.body')}</p>
<div className="fixed inset-0 flex items-center justify-center bg-canvas-50 dark:bg-neutral-950 p-6">
<div className="max-w-sm w-full space-y-4 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft text-center">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('app.persistRehydration.heading')}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('app.persistRehydration.body')}
</p>
<button
type="button"
onClick={handleReset}
+2 -2
View File
@@ -16,9 +16,9 @@ interface PillTabBarProps<T extends string> {
}
export default function PillTabBar<T extends string>({
activeClassName = 'border-primary-200 bg-primary-50 text-primary-700',
activeClassName = 'border-primary-200 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300',
containerClassName = 'flex gap-2 overflow-x-auto pb-1 scrollbar-hide',
inactiveClassName = 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50',
inactiveClassName = 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60',
items,
onChange,
renderItem,
+1 -1
View File
@@ -8,7 +8,7 @@ const RouteLoadingScreen = ({ label }: RouteLoadingScreenProps) => {
const { t } = useT();
return (
<div className="h-full min-h-[280px] w-full flex items-center justify-center">
<div className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3 text-sm text-stone-700 dark:text-neutral-200">
{label ?? t('app.routeLoading.initializing')}
</div>
</div>
@@ -38,14 +38,16 @@ const AddAccountModal = ({ open, onClose, onPick, connectedProviders }: AddAccou
aria-modal="true"
onClick={onClose}>
<div
className="w-[420px] max-w-[90vw] rounded-2xl bg-white p-6 shadow-strong"
className="w-[420px] max-w-[90vw] rounded-2xl bg-white dark:bg-neutral-900 p-6 shadow-strong"
onClick={e => e.stopPropagation()}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-stone-900">{t('accounts.addModal.title')}</h2>
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{t('accounts.addModal.title')}
</h2>
<button
ref={closeBtnRef}
onClick={onClose}
className="rounded p-1 text-stone-500 hover:bg-stone-100"
className="rounded p-1 text-stone-500 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60"
aria-label={t('common.close')}>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -60,7 +62,7 @@ const AddAccountModal = ({ open, onClose, onPick, connectedProviders }: AddAccou
<div className="space-y-1">
{available.length === 0 ? (
<div className="rounded-lg border border-dashed border-stone-200 p-6 text-center text-sm text-stone-500">
<div className="rounded-lg border border-dashed border-stone-200 dark:border-neutral-800 p-6 text-center text-sm text-stone-500 dark:text-neutral-400">
{t('accounts.addModal.allConnected')}
</div>
) : (
@@ -68,9 +70,11 @@ const AddAccountModal = ({ open, onClose, onPick, connectedProviders }: AddAccou
<button
key={p.id}
onClick={() => onPick(p)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-stone-100">
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60">
<ProviderIcon provider={p.id} className="h-5 w-5 flex-none" />
<span className="text-sm font-medium text-stone-900">{p.label}</span>
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{p.label}
</span>
</button>
))
)}
@@ -36,26 +36,26 @@ export default function RespondQueuePanel({
}: RespondQueuePanelProps) {
const { t } = useT();
return (
<aside className="flex w-80 flex-none flex-col border-l border-stone-200 bg-white">
<div className="flex flex-none items-center justify-between border-b border-stone-100 px-4 py-3">
<aside className="flex w-80 flex-none flex-col border-l border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<div className="flex flex-none items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-stone-800">
<h3 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
{t('accounts.respondQueue.title')}
</h3>
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{count} {t('accounts.respondQueue.pending')}
</p>
</div>
<button
type="button"
onClick={onRefresh}
className="rounded-lg border border-stone-200 px-2 py-1 text-xs text-stone-600 hover:bg-stone-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-2 py-1 text-xs text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
{t('common.refresh')}
</button>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3">
{status === 'loading' && items.length === 0 ? (
<p className="rounded-lg bg-stone-50 px-3 py-2 text-xs text-stone-500">
<p className="rounded-lg bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 text-xs text-stone-500 dark:text-neutral-400">
{t('accounts.respondQueue.loading')}
</p>
) : null}
@@ -67,7 +67,7 @@ export default function RespondQueuePanel({
) : null}
{items.length === 0 && status !== 'loading' ? (
<p className="rounded-lg bg-stone-50 px-3 py-2 text-xs text-stone-500">
<p className="rounded-lg bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 text-xs text-stone-500 dark:text-neutral-400">
{t('accounts.respondQueue.empty')}
</p>
) : null}
@@ -82,18 +82,22 @@ export default function RespondQueuePanel({
void openUrl(item.deepLink);
}
}}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-left transition-colors hover:bg-stone-50 disabled:cursor-default"
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-left transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:cursor-default"
disabled={!item.deepLink}>
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium text-stone-800">{queueTitle(item)}</p>
<span className="rounded-full bg-stone-100 px-2 py-0.5 text-[10px] uppercase text-stone-600">
<p className="truncate text-xs font-medium text-stone-800 dark:text-neutral-100">
{queueTitle(item)}
</p>
<span className="rounded-full bg-stone-100 dark:bg-neutral-800 px-2 py-0.5 text-[10px] uppercase text-stone-600 dark:text-neutral-300">
{item.provider}
</span>
</div>
{item.snippet ? (
<p className="mt-1 line-clamp-2 text-xs text-stone-600">{item.snippet}</p>
<p className="mt-1 line-clamp-2 text-xs text-stone-600 dark:text-neutral-300">
{item.snippet}
</p>
) : null}
<div className="mt-1 flex items-center justify-between text-[10px] text-stone-500">
<div className="mt-1 flex items-center justify-between text-[10px] text-stone-500 dark:text-neutral-400">
<span>{item.senderName ?? item.senderHandle ?? item.accountId}</span>
<span>{relativeTime(item.timestamp)}</span>
</div>
+12 -8
View File
@@ -71,7 +71,7 @@ const LoadingPhaseHint = ({ accountId }: { accountId: string }) => {
return (
<span
data-testid={`webview-loading-hint-${accountId}`}
className="text-[11px] font-medium text-stone-400">
className="text-[11px] font-medium text-stone-400 dark:text-neutral-500">
{text}
</span>
);
@@ -194,7 +194,7 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
return (
<div
ref={ref}
className="relative h-full w-full overflow-hidden rounded-2xl border border-stone-200/70 bg-stone-100 shadow-soft"
className="relative h-full w-full overflow-hidden rounded-2xl border border-stone-200 dark:border-neutral-800/70 bg-stone-100 dark:bg-neutral-800 shadow-soft"
aria-label={`webview host for account ${accountId}`}>
{/* Branded placeholder + (optional) loading overlay collapsed into a
single absolute container so we never paint two stacked / offset
@@ -209,7 +209,9 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
<div
data-testid={`webview-placeholder-${accountId}`}
className={`pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 ${
isLoading ? 'text-stone-500' : 'text-stone-400'
isLoading
? 'text-stone-500 dark:text-neutral-400'
: 'text-stone-400 dark:text-neutral-500'
}`}
role={isLoading ? 'status' : undefined}
aria-live={isLoading ? 'polite' : undefined}
@@ -219,14 +221,14 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
className={`h-12 w-12 ${isLoading ? '' : 'opacity-70'}`}
/>
<span
className={`text-xs font-medium tracking-wide ${isLoading ? '' : 'text-stone-500'}`}>
className={`text-xs font-medium tracking-wide ${isLoading ? '' : 'text-stone-500 dark:text-neutral-400'}`}>
{isLoading ? `${t('accounts.webviewHost.loading')} ${providerName}...` : providerName}
</span>
{isLoading ? (
<div
data-testid={`webview-loading-${accountId}`}
className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 border-t-stone-600" />
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 dark:border-neutral-700 border-t-stone-600 dark:border-t-neutral-400" />
{/* Issue #1233 — `key={accountId}` forces React to unmount the
hint when the user switches between two still-loading
accounts so the elapsed counter doesn't carry the
@@ -240,13 +242,15 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
{isTimeout ? (
<div
data-testid={`webview-timeout-${accountId}`}
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-stone-50/95 px-6 text-center"
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-stone-50 dark:bg-neutral-800/60 px-6 text-center"
role="status"
aria-live="polite"
aria-label={t('accounts.webviewHost.loadTimeout')}>
<div className="max-w-sm space-y-1">
<p className="text-sm font-semibold text-stone-800">{`${providerName} ${t('accounts.webviewHost.takingLonger')}`}</p>
<p className="text-xs text-stone-600">{t('accounts.webviewHost.timeoutHint')}</p>
<p className="text-sm font-semibold text-stone-800 dark:text-neutral-100">{`${providerName} ${t('accounts.webviewHost.takingLonger')}`}</p>
<p className="text-xs text-stone-600 dark:text-neutral-300">
{t('accounts.webviewHost.timeoutHint')}
</p>
</div>
<button
type="button"
@@ -9,7 +9,7 @@ const ChannelCapabilities = ({ capabilities }: ChannelCapabilitiesProps) => {
{capabilities.map(cap => (
<span
key={cap}
className="px-1.5 py-0.5 text-[10px] rounded bg-stone-100 text-stone-500 border border-stone-200">
className="px-1.5 py-0.5 text-[10px] rounded bg-stone-100 dark:bg-neutral-800 text-stone-500 dark:text-neutral-400 border border-stone-200 dark:border-neutral-800">
{cap.replace(/_/g, ' ')}
</span>
))}
@@ -15,10 +15,14 @@ const ChannelConfigPanel = ({ selectedChannel, definitions }: ChannelConfigPanel
return (
<div className="space-y-4">
<section className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div>
<h3 className="text-base font-semibold text-stone-900">{definition.display_name}</h3>
<p className="text-xs text-stone-500 mt-1">{definition.description}</p>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{definition.display_name}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{definition.description}
</p>
</div>
{selectedChannel === 'telegram' && <TelegramConfig definition={definition} />}
{selectedChannel === 'discord' && <DiscordConfig definition={definition} />}
@@ -10,7 +10,7 @@ interface ChannelFieldInputProps {
const ChannelFieldInput = ({ field, value, onChange, disabled }: ChannelFieldInputProps) => {
return (
<div>
<label className="block text-xs text-stone-500 mb-1">
<label className="block text-xs text-stone-500 dark:text-neutral-400 mb-1">
{field.label}
{field.required && <span className="text-coral-500 ml-0.5">*</span>}
</label>
@@ -20,7 +20,7 @@ const ChannelFieldInput = ({ field, value, onChange, disabled }: ChannelFieldInp
onChange={e => onChange(e.target.value)}
placeholder={field.placeholder || field.label}
disabled={disabled}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-primary-500/60 disabled:opacity-50"
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:border-primary-500/60 disabled:opacity-50"
/>
</div>
);
@@ -31,11 +31,14 @@ const ChannelSelector = ({
}, [channelConnections, t]);
return (
<section className="rounded-xl border border-stone-200 bg-white p-4 space-y-4">
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-stone-900">{t('channels.title')}</h2>
<p className="text-xs text-stone-400">
{t('channels.activeRoute')}: <span className="text-primary-600">{activeRoute}</span>
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('channels.title')}
</h2>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('channels.activeRoute')}:{' '}
<span className="text-primary-600 dark:text-primary-300">{activeRoute}</span>
</p>
</div>
@@ -59,8 +62,8 @@ const ChannelSelector = ({
onClick={() => onSelectChannel(channelId)}
className={`flex-1 flex items-center justify-between gap-2 rounded-lg border px-4 py-3 text-sm transition-colors ${
isSelected
? 'border-primary-500/60 bg-primary-50 text-primary-600'
: 'border-stone-200 bg-stone-50 text-stone-600 hover:border-stone-300'
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/15 text-primary-600 dark:text-primary-300'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700'
}`}>
<span className="flex items-center gap-2">
<span className="text-base">{CHANNEL_ICONS[def.icon] ?? ''}</span>
@@ -31,7 +31,7 @@ function ChannelConfigContent({ definition }: { definition: ChannelDefinition })
return <DiscordConfig definition={definition} />;
default:
return (
<p className="text-sm text-stone-400 py-4">
<p className="text-sm text-stone-400 dark:text-neutral-500 py-4">
{t('channels.configNotAvailable')} {definition.display_name}
</p>
);
@@ -73,7 +73,7 @@ export default function ChannelSetupModal({ definition, onClose }: ChannelSetupM
aria-labelledby="channel-setup-title">
<div
ref={modalRef}
className="bg-white border border-stone-200 rounded-3xl shadow-large w-full max-w-[500px] overflow-hidden animate-fade-up focus:outline-none focus:ring-0"
className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-3xl shadow-large w-full max-w-[500px] overflow-hidden animate-fade-up focus:outline-none focus:ring-0"
style={{
animationDuration: '200ms',
animationTimingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
@@ -82,23 +82,27 @@ export default function ChannelSetupModal({ definition, onClose }: ChannelSetupM
tabIndex={-1}
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-stone-200">
<div className="px-5 pt-5 pb-4 border-b border-stone-200 dark:border-neutral-800">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 pr-2">
<div className="flex items-center gap-2">
{icon && <span className="text-base">{icon}</span>}
<h2 id="channel-setup-title" className="text-base font-semibold text-stone-900">
<h2
id="channel-setup-title"
className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{definition.display_name}
</h2>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-md bg-primary-500/15 text-primary-600">
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-md bg-primary-500/15 text-primary-600 dark:text-primary-300">
{t('channels.channel')}
</span>
</div>
<p className="text-xs text-stone-500 mt-1.5">{definition.description}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1.5">
{definition.description}
</p>
</div>
<button
onClick={onClose}
className="p-1 text-stone-400 hover:text-stone-900 transition-colors rounded-lg hover:bg-stone-100 flex-shrink-0">
className="p-1 text-stone-400 dark:text-neutral-500 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100 dark:hover:text-neutral-100 transition-colors rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 flex-shrink-0">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
+16 -12
View File
@@ -276,7 +276,7 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
return (
<div className="space-y-3">
{error && (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{error}
</div>
)}
@@ -288,13 +288,17 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
const busy = busyKeys[compositeKey] ?? false;
return (
<div key={spec.mode} className="rounded-lg border border-stone-200 bg-stone-50 p-3">
<div
key={spec.mode}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-stone-900">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{AUTH_MODE_LABELS[spec.mode] ?? spec.mode}
</p>
<p className="text-xs text-stone-500 mt-1">{spec.description}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{spec.description}
</p>
{connection?.lastError && (
<p className="text-xs text-coral-600 mt-1">{connection.lastError}</p>
)}
@@ -319,22 +323,22 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
{/* Token card — managed_dm connecting state */}
{spec.mode === 'managed_dm' && linkToken && status === 'connecting' && (
<div className="mt-3 rounded-lg border border-primary-200 bg-primary-50/60 p-3 space-y-2">
<p className="text-xs font-medium text-primary-700">
<div className="mt-3 rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50/60 dark:bg-primary-500/15 p-3 space-y-2">
<p className="text-xs font-medium text-primary-700 dark:text-primary-300">
{t('channels.discord.linkTokenLabel')}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-white border border-primary-200 px-2 py-1 text-xs font-mono text-stone-800 select-all break-all">
<code className="flex-1 rounded bg-white dark:bg-neutral-900 border border-primary-200 dark:border-primary-500/30 px-2 py-1 text-xs font-mono text-stone-800 dark:text-neutral-100 select-all break-all">
{linkToken}
</code>
<button
type="button"
onClick={copyToken}
className="shrink-0 rounded-lg border border-primary-300 px-2 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100">
className="shrink-0 rounded-lg border border-primary-300 dark:border-primary-500/40 px-2 py-1 text-xs font-medium text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-500/20">
{copied ? t('common.copied') : t('common.copy')}
</button>
</div>
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('channels.discord.linkTokenInstruction').replace('{token}', linkToken)}
</p>
<p className="text-xs text-amber-600 font-medium">
@@ -346,14 +350,14 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
{/* Connected state for managed_dm — show only Disconnect */}
{spec.mode === 'managed_dm' && status === 'connected' ? (
<div className="mt-3 flex items-center justify-between">
<p className="text-xs text-sage-700 font-medium">
<p className="text-xs text-sage-700 dark:text-sage-300 font-medium">
{t('channels.discord.accountLinked')}
</p>
<button
type="button"
disabled={busy}
onClick={() => handleDisconnect(spec.mode)}
className="rounded-lg border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:border-stone-300 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
{t('accounts.disconnect')}
</button>
</div>
@@ -373,7 +377,7 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
type="button"
disabled={busy || status === 'disconnected'}
onClick={() => handleDisconnect(spec.mode)}
className="rounded-lg border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:border-stone-300 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
{t('accounts.disconnect')}
</button>
</div>
@@ -157,20 +157,22 @@ const DiscordServerChannelPicker = ({
return (
<div className="mt-3 space-y-3">
<p className="text-xs font-medium text-stone-600">
<p className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('channels.discord.picker.serverChannelSelection')}
</p>
{/* Error banner */}
{error && (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
{/* Guild selector */}
<div>
<label htmlFor="discord-guild-select" className="block text-xs text-stone-500 mb-1">
<label
htmlFor="discord-guild-select"
className="block text-xs text-stone-500 dark:text-neutral-400 mb-1">
{t('channels.discord.picker.server')}
</label>
<select
@@ -178,7 +180,7 @@ const DiscordServerChannelPicker = ({
value={selectedGuildId}
onChange={e => handleGuildChange(e.target.value)}
disabled={isLoading || guilds.length === 0}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none disabled:opacity-50">
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none disabled:opacity-50">
<option value="">
{state === 'loading_guilds'
? t('channels.discord.picker.loadingServers')
@@ -193,7 +195,7 @@ const DiscordServerChannelPicker = ({
))}
</select>
{guilds.length === 0 && state === 'guilds_loaded' && (
<p className="mt-1 text-xs text-stone-400">
<p className="mt-1 text-xs text-stone-400 dark:text-neutral-500">
{t('channels.discord.picker.botNotInServers')}
</p>
)}
@@ -202,7 +204,9 @@ const DiscordServerChannelPicker = ({
{/* Channel selector */}
{selectedGuildId && (
<div>
<label htmlFor="discord-channel-select" className="block text-xs text-stone-500 mb-1">
<label
htmlFor="discord-channel-select"
className="block text-xs text-stone-500 dark:text-neutral-400 mb-1">
{t('channels.discord.picker.channel')}
</label>
<select
@@ -210,7 +214,7 @@ const DiscordServerChannelPicker = ({
value={selectedChannelId}
onChange={e => handleChannelChange(e.target.value)}
disabled={isLoading || channels.length === 0}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none disabled:opacity-50">
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none disabled:opacity-50">
<option value="">
{state === 'loading_channels'
? t('channels.discord.picker.loadingChannels')
@@ -244,8 +248,8 @@ const DiscordServerChannelPicker = ({
{/* Permission check result */}
{state === 'checking_permissions' && (
<div className="flex items-center gap-2 text-xs text-stone-500">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-stone-300 border-t-primary-500" />
<div className="flex items-center gap-2 text-xs text-stone-500 dark:text-neutral-400">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-stone-300 dark:border-neutral-700 border-t-primary-500" />
{t('channels.discord.picker.checkingPermissions')}
</div>
)}
@@ -254,8 +258,8 @@ const DiscordServerChannelPicker = ({
<div
className={`rounded-lg border px-3 py-2 text-xs ${
permissions.missing_permissions.length === 0
? 'border-sage-200 bg-sage-50 text-sage-700'
: 'border-amber-200 bg-amber-50 text-amber-700'
? 'border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300'
: 'border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300'
}`}>
{permissions.missing_permissions.length === 0 ? (
<span>{t('channels.discord.picker.allPermissionsOk')}</span>
@@ -297,7 +297,7 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
return (
<div className="space-y-3">
{error && (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{error}
</div>
)}
@@ -308,13 +308,17 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
const status: ChannelConnectionStatus = connection?.status ?? 'disconnected';
return (
<div key={spec.mode} className="rounded-lg border border-stone-200 bg-stone-50 p-3">
<div
key={spec.mode}
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-stone-900">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{AUTH_MODE_LABELS[spec.mode] ?? spec.mode}
</p>
<p className="text-xs text-stone-500 mt-1">{spec.description}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{spec.description}
</p>
{connection?.lastError && (
<p className="text-xs text-coral-600 mt-1">{connection.lastError}</p>
)}
@@ -350,7 +354,7 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => {
type="button"
disabled={busyKeys[compositeKey] || status === 'disconnected'}
onClick={() => handleDisconnect(spec.mode)}
className="rounded-lg border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:border-stone-300 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700 disabled:opacity-50">
{t('accounts.disconnect')}
</button>
</div>
@@ -15,7 +15,9 @@ const WebChannelConfig = ({ definition: _definition }: WebChannelConfigProps) =>
<ChannelStatusBadge status="connected" />
</div>
</div>
<p className="text-sm text-stone-500">{t('channels.web.alwaysAvailable')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('channels.web.alwaysAvailable')}
</p>
</div>
);
};
+10 -10
View File
@@ -20,24 +20,24 @@ interface PillSeverity {
function severityFromPct(pct: number): PillSeverity {
if (pct >= 0.9) {
return {
bg: 'bg-coral-50',
text: 'text-coral-700',
ring: 'ring-coral-200',
bg: 'bg-coral-50 dark:bg-coral-500/15',
text: 'text-coral-700 dark:text-coral-300',
ring: 'ring-coral-200 dark:ring-coral-500/30',
label: `${Math.round(pct * 100)}%`,
};
}
if (pct >= 0.7) {
return {
bg: 'bg-amber-50',
text: 'text-amber-700',
ring: 'ring-amber-200',
bg: 'bg-amber-50 dark:bg-amber-500/15',
text: 'text-amber-700 dark:text-amber-300',
ring: 'ring-amber-200 dark:ring-amber-500/30',
label: `${Math.round(pct * 100)}%`,
};
}
return {
bg: 'bg-sage-50',
text: 'text-sage-700',
ring: 'ring-sage-200',
bg: 'bg-sage-50 dark:bg-sage-500/15',
text: 'text-sage-700 dark:text-sage-300',
ring: 'ring-sage-200 dark:ring-sage-500/30',
label: `${Math.round(pct * 100)}%`,
};
}
@@ -67,7 +67,7 @@ const TokenUsagePill = () => {
<div className="flex items-center gap-1.5 text-[11px] leading-none">
{showSessionCounter ? (
<span
className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2 py-1 font-mono text-stone-600 ring-1 ring-stone-200/60"
className="inline-flex items-center gap-1 rounded-full bg-stone-100 dark:bg-neutral-800 px-2 py-1 font-mono text-stone-600 dark:text-neutral-300 ring-1 ring-stone-200/60 dark:ring-neutral-700"
title={t('token.sessionTokens')
.replace('{in}', sessionTokens.inputTokens.toLocaleString())
.replace('{out}', sessionTokens.outputTokens.toLocaleString())
@@ -506,7 +506,7 @@ export default function ComposioConnectModal({
aria-labelledby="composio-setup-title">
<div
ref={modalRef}
className="bg-white border border-stone-200 rounded-3xl shadow-large w-full max-w-[460px] overflow-hidden animate-fade-up focus:outline-none focus:ring-0"
className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-3xl shadow-large w-full max-w-[460px] overflow-hidden animate-fade-up focus:outline-none focus:ring-0"
style={{
animationDuration: '200ms',
animationTimingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
@@ -515,21 +515,25 @@ export default function ComposioConnectModal({
tabIndex={-1}
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="p-4 border-b border-stone-200">
<div className="p-4 border-b border-stone-200 dark:border-neutral-800">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 pr-2">
<div className="flex items-center gap-2">
{toolkit.icon}
<h2 id="composio-setup-title" className="text-base font-semibold text-stone-900">
<h2
id="composio-setup-title"
className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{headerTitle}
</h2>
</div>
<p className="text-xs text-stone-400 mt-1.5 line-clamp-2">{toolkit.description}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500 mt-1.5 line-clamp-2">
{toolkit.description}
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-stone-400 hover:text-stone-900 transition-colors rounded-lg hover:bg-stone-100 flex-shrink-0"
className="p-1 text-stone-400 dark:text-neutral-500 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100 dark:hover:text-neutral-100 transition-colors rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 flex-shrink-0"
aria-label={t('common.close')}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -547,11 +551,11 @@ export default function ComposioConnectModal({
<div className="p-4 space-y-3">
{phase === 'idle' && (
<>
<p className="text-sm text-stone-600">
<p className="text-sm text-stone-600 dark:text-neutral-300">
{`${t('composio.connect.idleDescription')} ${toolkit.name} ${t('composio.connect.idleDescriptionSuffix')}`}
</p>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3">
<p className="mt-1 text-xs leading-relaxed text-stone-600">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<p className="mt-1 text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
{toolkit.name} {t('composio.connect.permissionsNote')}{' '}
<span className="font-medium">{toolkit.permissionLabel}</span>.{' '}
{t('composio.connect.permissionsNoteSuffix')}
@@ -561,7 +565,7 @@ export default function ComposioConnectModal({
<div className="space-y-1.5">
<label
htmlFor="waba-id-input"
className="block text-xs font-medium text-stone-700">
className="block text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('composio.connect.wabaIdLabel')}
<span className="ml-1 text-coral-500">*</span>
</label>
@@ -574,9 +578,9 @@ export default function ComposioConnectModal({
if (error) setError(null);
}}
placeholder="e.g. 123456789012345"
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
/>
<p className="text-[11px] leading-relaxed text-stone-400">
<p className="text-[11px] leading-relaxed text-stone-400 dark:text-neutral-500">
Find it via <span className="font-mono">GET /me/businesses</span> then{' '}
<span className="font-mono">
GET /&#123;business_id&#125;/owned_whatsapp_business_accounts
@@ -607,7 +611,7 @@ export default function ComposioConnectModal({
{phase === 'needs-subdomain' && (
<>
<p className="text-sm text-stone-600">
<p className="text-sm text-stone-600 dark:text-neutral-300">
{`${t('composio.connect.needsSubdomain')} ${toolkit.name}, ${t('composio.connect.needsSubdomainSuffix')}`}
</p>
<AtlassianSubdomainInput
@@ -632,19 +636,21 @@ export default function ComposioConnectModal({
setSubdomainError(null);
setError(null);
}}
className="w-full rounded-xl border border-stone-200 bg-white text-stone-600 text-xs font-medium py-2 hover:bg-stone-50 transition-colors">
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 text-xs font-medium py-2 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors">
{t('common.cancel')}
</button>
</>
)}
{phase === 'authorizing' && (
<p className="text-sm text-stone-500">{t('composio.connect.requestingUrl')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('composio.connect.requestingUrl')}
</p>
)}
{phase === 'waiting' && (
<>
<div className="flex items-center gap-2 text-sm text-stone-700">
<div className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-200">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
{`${t('composio.connect.waitingFor')} ${toolkit.name} ${t('composio.connect.oauthComplete')}`}
</div>
@@ -652,11 +658,13 @@ export default function ComposioConnectModal({
<button
type="button"
onClick={() => void openUrl(connectUrl)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 text-stone-700 text-xs font-medium py-2 hover:bg-stone-100 transition-colors">
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-700 dark:text-neutral-200 text-xs font-medium py-2 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
{t('composio.connect.reopenBrowser')}
</button>
)}
<p className="text-xs text-stone-400">{t('composio.connect.waitingHint')}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('composio.connect.waitingHint')}
</p>
</>
)}
@@ -688,7 +696,7 @@ export default function ComposioConnectModal({
<div>
{`${toolkit.name} ${t('composio.connect.isConnected')}`} &nbsp;
{activeConnection && deriveConnectionLabel(activeConnection) && (
<span className="text-[11px] text-stone-400 font-mono">
<span className="text-[11px] text-stone-400 dark:text-neutral-500 font-mono">
({deriveConnectionLabel(activeConnection)})
</span>
)}
@@ -725,7 +733,9 @@ export default function ComposioConnectModal({
)}
{phase === 'disconnecting' && (
<p className="text-sm text-stone-500">{t('composio.connect.disconnecting')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('composio.connect.disconnecting')}
</p>
)}
{phase === 'error' && (
@@ -741,7 +751,7 @@ export default function ComposioConnectModal({
);
setError(null);
}}
className="w-full rounded-xl border border-stone-200 bg-white text-stone-700 text-sm font-medium py-2 hover:bg-stone-50 transition-colors">
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 text-sm font-medium py-2 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors">
{t('common.dismiss')}
</button>
</>
@@ -790,12 +800,14 @@ function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProp
const loading = scopes === null;
return (
<div className="border-t border-stone-100 pt-3 mt-1 space-y-2">
<div className="border-t border-stone-100 dark:border-neutral-800 pt-3 mt-1 space-y-2">
<div className="flex items-baseline justify-between">
<h3 className="text-xs font-semibold text-stone-700 uppercase tracking-wide">
<h3 className="text-xs font-semibold text-stone-700 dark:text-neutral-200 uppercase tracking-wide">
{t('composio.connect.permissions')}
</h3>
<p className="text-[10px] text-stone-400">{t('composio.connect.permissionsDefault')}</p>
<p className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('composio.connect.permissionsDefault')}
</p>
</div>
<ul className="space-y-1.5">
{SCOPE_ROWS.map(row => {
@@ -806,10 +818,14 @@ function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProp
return (
<li
key={row.key}
className="flex items-start justify-between gap-3 rounded-lg px-2 py-1.5 hover:bg-stone-50">
className="flex items-start justify-between gap-3 rounded-lg px-2 py-1.5 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-stone-900">{rowLabel}</span>
<p className="text-[11px] text-stone-400 leading-snug">{rowHint}</p>
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{rowLabel}
</span>
<p className="text-[11px] text-stone-400 dark:text-neutral-500 leading-snug">
{rowHint}
</p>
</div>
<button
type="button"
@@ -822,7 +838,7 @@ function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProp
enabled ? 'bg-primary-500' : 'bg-stone-300'
}`}>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
enabled ? 'translate-x-5' : 'translate-x-0.5'
} ${isSaving ? 'animate-pulse' : ''}`}
/>
@@ -863,11 +879,11 @@ function AtlassianSubdomainInput({
<div className="space-y-1.5">
<label
htmlFor="atlassian-subdomain-input"
className="block text-xs font-medium text-stone-700">
className="block text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('composio.connect.atlassianSubdomainLabel')}
<span className="ml-1 text-coral-500">*</span>
</label>
<div className="flex items-center rounded-xl border border-stone-200 bg-white focus-within:border-primary-400 focus-within:ring-2 focus-within:ring-primary-100 overflow-hidden">
<div className="flex items-center rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:border-primary-400 focus-within:ring-2 focus-within:ring-primary-100 overflow-hidden">
<input
id="atlassian-subdomain-input"
type="text"
@@ -877,9 +893,9 @@ function AtlassianSubdomainInput({
placeholder="your-subdomain"
aria-describedby="atlassian-subdomain-hint"
aria-invalid={!!error}
className="flex-1 min-w-0 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 bg-transparent focus:outline-none"
className="flex-1 min-w-0 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 bg-transparent focus:outline-none"
/>
<span className="pr-3 text-xs text-stone-400 select-none whitespace-nowrap">
<span className="pr-3 text-xs text-stone-400 dark:text-neutral-500 select-none whitespace-nowrap">
.atlassian.net
</span>
</div>
@@ -891,7 +907,9 @@ function AtlassianSubdomainInput({
{error}
</p>
) : (
<p id="atlassian-subdomain-hint" className="text-[11px] leading-relaxed text-stone-400">
<p
id="atlassian-subdomain-hint"
className="text-[11px] leading-relaxed text-stone-400 dark:text-neutral-500">
{t('composio.connect.atlassianSubdomainHint')}
</p>
)}
+25 -15
View File
@@ -136,7 +136,7 @@ export default function TriggerToggles({
if (loadError) {
return (
<div className="border-t border-stone-100 pt-3 mt-1">
<div className="border-t border-stone-100 dark:border-neutral-800 pt-3 mt-1">
<p className="text-[11px] text-coral-600">{loadError}</p>
</div>
);
@@ -144,22 +144,24 @@ export default function TriggerToggles({
if (available === null) {
return (
<div className="border-t border-stone-100 pt-3 mt-1">
<h3 className="text-xs font-semibold text-stone-700 uppercase tracking-wide">
<div className="border-t border-stone-100 dark:border-neutral-800 pt-3 mt-1">
<h3 className="text-xs font-semibold text-stone-700 dark:text-neutral-200 uppercase tracking-wide">
{t('composio.triggers.heading')}
</h3>
<p className="mt-1 text-[11px] text-stone-400">{t('composio.triggers.loading')}</p>
<p className="mt-1 text-[11px] text-stone-400 dark:text-neutral-500">
{t('composio.triggers.loading')}
</p>
</div>
);
}
if (available.length === 0) {
return (
<div className="border-t border-stone-100 pt-3 mt-1">
<h3 className="text-xs font-semibold text-stone-700 uppercase tracking-wide">
<div className="border-t border-stone-100 dark:border-neutral-800 pt-3 mt-1">
<h3 className="text-xs font-semibold text-stone-700 dark:text-neutral-200 uppercase tracking-wide">
{t('composio.triggers.heading')}
</h3>
<p className="mt-1 text-[11px] text-stone-400">
<p className="mt-1 text-[11px] text-stone-400 dark:text-neutral-500">
{`${t('composio.triggers.noneAvailable')} ${toolkitName}.`}
</p>
</div>
@@ -167,12 +169,14 @@ export default function TriggerToggles({
}
return (
<div className="border-t border-stone-100 pt-3 mt-1 space-y-2" data-testid="trigger-toggles">
<div
className="border-t border-stone-100 dark:border-neutral-800 pt-3 mt-1 space-y-2"
data-testid="trigger-toggles">
<div className="flex items-baseline justify-between">
<h3 className="text-xs font-semibold text-stone-700 uppercase tracking-wide">
<h3 className="text-xs font-semibold text-stone-700 dark:text-neutral-200 uppercase tracking-wide">
{t('composio.triggers.heading')}
</h3>
<p className="text-[10px] text-stone-400">{`${t('composio.triggers.listenFrom')} ${toolkitName}`}</p>
<p className="text-[10px] text-stone-400 dark:text-neutral-500">{`${t('composio.triggers.listenFrom')} ${toolkitName}`}</p>
</div>
<ul className="space-y-1.5 max-h-56 overflow-y-auto pr-1">
{available.map(entry => {
@@ -205,10 +209,16 @@ export default function TriggerToggles({
<li
key={sig}
data-testid={`trigger-row-${sig}`}
className="flex items-start justify-between gap-3 rounded-lg px-2 py-1.5 hover:bg-stone-50">
className="flex items-start justify-between gap-3 rounded-lg px-2 py-1.5 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-stone-900 break-all">{label}</span>
{sub && <p className="text-[11px] text-stone-400 leading-snug">{sub}</p>}
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100 break-all">
{label}
</span>
{sub && (
<p className="text-[11px] text-stone-400 dark:text-neutral-500 leading-snug">
{sub}
</p>
)}
</div>
<button
type="button"
@@ -218,10 +228,10 @@ export default function TriggerToggles({
disabled={disabled}
onClick={() => void handleToggle(entry)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 ${
enabled ? 'bg-primary-500' : 'bg-stone-300'
enabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-700'
}`}>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
enabled ? 'translate-x-5' : 'translate-x-0.5'
} ${isPending ? 'animate-pulse' : ''}`}
/>
+2 -2
View File
@@ -227,7 +227,7 @@ const PLATFORM_KEYWORDS = [
function GenericIntegrationIcon() {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-stone-100 text-stone-600 shadow-sm ring-1 ring-black/5">
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 shadow-sm ring-1 ring-black/5">
<svg className="h-[18px] w-[18px]" viewBox="0 0 24 24" aria-hidden="true" fill="none">
<path
d="M8 8h8v8H8zM5 12h3m8 0h3M12 5v3m0 8v3"
@@ -250,7 +250,7 @@ function ComposioLogoBadge({ slug, name }: { slug: string; name: string }) {
}
return (
<span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
<span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-xl bg-white dark:bg-neutral-900 shadow-sm ring-1 ring-black/5">
<img
src={logoUrl}
alt={`${name} logo`}
+26 -22
View File
@@ -22,16 +22,18 @@ export function UsageLimitBanner({
const styles =
tone === 'danger'
? {
card: 'border-coral-200 bg-gradient-to-r from-coral-50 via-rose-50 to-orange-50',
title: 'text-coral-700',
body: 'text-coral-500',
button: 'border-coral-700 text-coral-700 hover:text-coral-800',
card: 'border-coral-200 bg-gradient-to-r from-coral-50 via-rose-50 to-orange-50 dark:border-coral-500/30 dark:from-coral-900/30 dark:via-coral-900/20 dark:to-coral-900/10',
title: 'text-coral-700 dark:text-coral-300',
body: 'text-coral-500 dark:text-coral-300/80',
button:
'border-coral-700 text-coral-700 hover:text-coral-800 dark:border-coral-300 dark:text-coral-300 dark:hover:text-coral-200',
}
: {
card: 'border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50',
title: 'text-amber-700',
body: 'text-amber-600',
button: 'border-amber-700 text-amber-700 hover:text-amber-800',
card: 'border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 dark:border-amber-500/30 dark:from-amber-900/30 dark:via-amber-900/20 dark:to-amber-900/10',
title: 'text-amber-700 dark:text-amber-300',
body: 'text-amber-600 dark:text-amber-300/80',
button:
'border-amber-700 text-amber-700 hover:text-amber-800 dark:border-amber-300 dark:text-amber-300 dark:hover:text-amber-200',
};
return (
@@ -62,23 +64,23 @@ export function UsageLimitBanner({
export function PromotionalCreditsBanner({ promoCredits }: { promoCredits: number }) {
const { t } = useT();
return (
<div className="mb-3 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-4 py-4 text-left shadow-soft">
<div className="mb-3 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-4 py-4 text-left shadow-soft dark:border-amber-500/30 dark:from-amber-900/30 dark:via-amber-900/20 dark:to-amber-900/10">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-lg">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-500/20 text-lg">
🎉
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-amber-700">
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">
{t('home.banners.promoCreditsTitle').replace('{amount}', formatUsd(promoCredits))}
</p>
<p className="mt-1 text-sm leading-relaxed text-amber-600">
<p className="mt-1 text-sm leading-relaxed text-amber-600 dark:text-amber-300/80">
{t('home.banners.promoCreditsBody')}{' '}
<button
type="button"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="cursor-pointer border-b border-amber-700 border-dashed font-bold text-amber-700 hover:text-amber-800">
className="cursor-pointer border-b border-amber-700 border-dashed font-bold text-amber-700 hover:text-amber-800 dark:border-amber-300 dark:text-amber-300 dark:hover:text-amber-200">
{t('home.banners.getSubscription')}
</button>{' '}
{t('home.banners.promoCreditsUsage')}
@@ -92,27 +94,27 @@ export function PromotionalCreditsBanner({ promoCredits }: { promoCredits: numbe
export function EarlyBirdyBanner({ onDismiss }: { onDismiss?: () => void }) {
const { t } = useT();
return (
<div className="relative mb-3 mt-3 rounded-2xl border border-orange-200 bg-gradient-to-r from-orange-50 via-amber-50 to-orange-50 px-4 py-4 text-left shadow-soft">
<div className="relative mb-3 mt-3 rounded-2xl border border-orange-200 bg-gradient-to-r from-orange-50 via-amber-50 to-orange-50 px-4 py-4 text-left shadow-soft dark:border-orange-500/30 dark:from-orange-900/30 dark:via-amber-900/20 dark:to-orange-900/10">
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label={t('home.banners.earlyBirdDismiss')}
className="absolute right-3 top-3 rounded-md p-1 text-orange-500 hover:bg-orange-100 hover:text-orange-700">
className="absolute right-3 top-3 rounded-md p-1 text-orange-500 hover:bg-orange-100 hover:text-orange-700 dark:text-orange-300 dark:hover:bg-orange-500/10 dark:hover:text-orange-200">
</button>
)}
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-100 text-lg">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-500/20 text-lg">
🐦
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-orange-700">
<p className="text-sm font-semibold text-orange-700 dark:text-orange-300">
{t('home.banners.earlyBirdTitle')}
</p>
<p className="mt-1 text-sm leading-relaxed text-orange-600">
<p className="mt-1 text-sm leading-relaxed text-orange-600 dark:text-orange-300/80">
{t('home.banners.earlyBirdUseCode')}{' '}
<span className="rounded-md border border-orange-300 bg-white px-1.5 py-0.5 font-mono text-[12px] font-bold text-orange-700">
<span className="rounded-md border border-orange-300 bg-white px-1.5 py-0.5 font-mono text-[12px] font-bold text-orange-700 dark:border-orange-500/40 dark:bg-neutral-900 dark:text-orange-300">
EARLYBIRDY
</span>{' '}
{t('home.banners.earlyBirdOn')}{' '}
@@ -121,7 +123,7 @@ export function EarlyBirdyBanner({ onDismiss }: { onDismiss?: () => void }) {
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="cursor-pointer border-b border-amber-700 border-dashed font-bold text-amber-700 hover:text-amber-800">
className="cursor-pointer border-b border-amber-700 border-dashed font-bold text-amber-700 hover:text-amber-800 dark:border-amber-300 dark:text-amber-300 dark:hover:text-amber-200">
{t('home.banners.earlyBirdFirstSub')}
</button>{' '}
</p>
@@ -139,7 +141,7 @@ export function DiscordBanner() {
onClick={() => {
void openUrl(DISCORD_INVITE_URL);
}}
className="mb-3 text-left mt-3 block rounded-2xl border border-[#CDD2FF] bg-gradient-to-r from-[#F6F7FF] via-[#F1F3FF] to-[#ECEFFF] px-4 py-4 text-[#414AAE] shadow-soft transition-transform transition-colors hover:-translate-y-0.5 hover:border-[#BCC3FF] hover:from-[#EEF0FF] hover:to-[#E5E9FF]">
className="mb-3 text-left mt-3 block w-full rounded-2xl border border-[#CDD2FF] bg-gradient-to-r from-[#F6F7FF] via-[#F1F3FF] to-[#ECEFFF] px-4 py-4 text-[#414AAE] shadow-soft transition-transform transition-colors hover:-translate-y-0.5 hover:border-[#BCC3FF] hover:from-[#EEF0FF] hover:to-[#E5E9FF] dark:border-[#5865F2]/30 dark:from-[#5865F2]/10 dark:via-[#5865F2]/15 dark:to-[#5865F2]/10 dark:text-[#A5B0FF] dark:hover:border-[#5865F2]/50 dark:hover:from-[#5865F2]/15 dark:hover:to-[#5865F2]/20">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#5865F2]/12 text-[#5865F2]">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
@@ -148,7 +150,9 @@ export function DiscordBanner() {
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{t('home.banners.discordTitle')}</div>
<div className="mt-0.5 text-sm text-[#5E66BC]">{t('home.banners.discordSubtitle')}</div>
<div className="mt-0.5 text-sm text-[#5E66BC] dark:text-[#8B95DD]">
{t('home.banners.discordSubtitle')}
</div>
</div>
</div>
</button>
@@ -88,13 +88,13 @@ function SnoozeDropdownPortal({ isOpen, buttonRef, onClose, onSnooze }: SnoozeDr
<div
ref={dropdownRef}
data-snooze-dropdown
className="fixed py-1 bg-white border border-stone-200 rounded-lg shadow-xl min-w-[120px] z-[9999] animate-fade-in"
className="fixed py-1 bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-lg shadow-xl min-w-[120px] z-[9999] animate-fade-in"
style={{ top: position.top, left: position.left }}>
{SNOOZE_OPTIONS.map(option => (
<button
key={option.label}
onClick={() => onSnooze(option.duration)}
className="w-full text-left px-3 py-1.5 text-xs text-stone-900 hover:bg-stone-100 transition-colors cursor-pointer">
className="w-full text-left px-3 py-1.5 text-xs text-stone-900 dark:text-neutral-100 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors cursor-pointer">
{option.label}
</button>
))}
@@ -237,7 +237,7 @@ export function ActionableCard({
const priorityClasses = {
critical: 'border-coral-500/30 bg-coral-500/5',
important: 'border-amber-500/30 bg-amber-500/5',
normal: 'border-stone-200 bg-stone-50',
normal: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60',
};
const priorityDotClasses = {
@@ -260,13 +260,13 @@ export function ActionableCard({
<div
className={`
relative p-4 rounded-xl border backdrop-blur-sm transition-all duration-200
hover:bg-stone-50 hover:border-stone-200
hover:bg-stone-50 dark:hover:bg-neutral-800/60 hover:border-stone-200 dark:border-neutral-800
${priorityClasses[item.priority]}
`}>
{/* Main content row */}
<div className="flex items-start gap-3">
{/* Icon */}
<div className="w-8 h-8 flex items-center justify-center text-stone-600 flex-shrink-0 mt-0.5">
<div className="w-8 h-8 flex items-center justify-center text-stone-600 dark:text-neutral-300 flex-shrink-0 mt-0.5">
{sourceIcon}
</div>
@@ -274,9 +274,13 @@ export function ActionableCard({
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-stone-900 leading-snug">{item.title}</h3>
<h3 className="text-sm font-medium text-stone-900 dark:text-neutral-100 leading-snug">
{item.title}
</h3>
{item.description && (
<p className="text-xs text-stone-400 mt-1 leading-relaxed">{item.description}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500 mt-1 leading-relaxed">
{item.description}
</p>
)}
</div>
@@ -285,7 +289,7 @@ export function ActionableCard({
{/* Complete button */}
<button
onClick={handleComplete}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-sage-400 hover:bg-sage-400/10 transition-all duration-150"
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 hover:text-sage-400 hover:bg-sage-400/10 transition-all duration-150"
title={t('actionable.complete')}>
<svg
className="w-3.5 h-3.5"
@@ -304,7 +308,7 @@ export function ActionableCard({
{/* Dismiss button */}
<button
onClick={handleDismiss}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-coral-400 hover:bg-coral-400/10 transition-all duration-150"
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 hover:text-coral-400 hover:bg-coral-400/10 transition-all duration-150"
title={t('actionable.dismiss')}>
<svg
className="w-3.5 h-3.5"
@@ -325,7 +329,7 @@ export function ActionableCard({
<button
ref={snoozeButtonRef}
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-amber-400 hover:bg-amber-400/10 transition-all duration-150"
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 hover:text-amber-400 hover:bg-amber-400/10 transition-all duration-150"
title={t('actionable.snooze')}>
<svg
className="w-3.5 h-3.5"
@@ -348,13 +352,15 @@ export function ActionableCard({
<div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${priorityDotClasses[item.priority]}`} />
<span className="text-xs text-stone-500">{item.sourceLabel || item.source}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">
{item.sourceLabel || item.source}
</span>
</div>
<span className="text-xs text-stone-600"></span>
<span className="text-xs text-stone-500">{timeAgo}</span>
<span className="text-xs text-stone-600 dark:text-neutral-300"></span>
<span className="text-xs text-stone-500 dark:text-neutral-400">{timeAgo}</span>
{isNew && (
<>
<span className="text-xs text-stone-600"></span>
<span className="text-xs text-stone-600 dark:text-neutral-300"></span>
<span className="text-xs bg-sage-500 text-white px-1.5 py-0.5 rounded-sm font-medium">
{t('actionable.new')}
</span>
@@ -37,13 +37,13 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 animate-fade-in"
onClick={handleCancel}>
<div
className="bg-white rounded-2xl max-w-md w-full shadow-large border border-stone-200 animate-slide-up"
className="bg-white dark:bg-neutral-900 rounded-2xl max-w-md w-full shadow-large border border-stone-200 dark:border-neutral-800 animate-slide-up"
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-3">
{modal.destructive && (
<div className="w-10 h-10 rounded-full bg-coral-500/10 flex items-center justify-center flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-coral-50 dark:bg-coral-500/10 flex items-center justify-center flex-shrink-0">
<svg
className="w-5 h-5 text-coral-400"
fill="none"
@@ -59,8 +59,10 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
</div>
)}
<div className="flex-1">
<h2 className="text-lg font-semibold text-stone-900">{modal.title}</h2>
<p className="text-sm text-stone-600 mt-1">{modal.message}</p>
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{modal.title}
</h2>
<p className="text-sm text-stone-600 dark:text-neutral-300 mt-1">{modal.message}</p>
</div>
</div>
</div>
@@ -68,12 +70,12 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
{/* Don't show again option */}
{modal.showDontShowAgain && (
<div className="px-6 pb-2">
<label className="flex items-center gap-2 text-sm text-stone-600 cursor-pointer">
<label className="flex items-center gap-2 text-sm text-stone-600 dark:text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={dontShowAgain}
onChange={e => setDontShowAgain(e.target.checked)}
className="rounded border-stone-300 bg-stone-100 text-primary-500 focus:ring-primary-500 focus:ring-offset-0"
className="rounded border-stone-300 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 text-primary-500 focus:ring-primary-500 focus:ring-offset-0"
/>
{t('modal.dontShowAgain')}
</label>
@@ -81,10 +83,10 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 p-6 pt-4 border-t border-stone-200">
<div className="flex items-center justify-end gap-3 p-6 pt-4 border-t border-stone-200 dark:border-neutral-800">
<button
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium text-stone-600 hover:text-stone-900 rounded-lg hover:bg-stone-100 transition-colors">
className="px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100 rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
{modal.cancelText || t('common.cancel')}
</button>
<button
@@ -102,13 +102,17 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
return (
<div className="space-y-6">
<div>
<h2 className="text-base font-semibold text-stone-900">{t('calls.joinMeet')}</h2>
<p className="mt-1 text-sm text-stone-500">{t('calls.joinMeetDescription')}</p>
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('calls.joinMeet')}
</h2>
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">
{t('calls.joinMeetDescription')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block">
<span className="text-xs font-medium uppercase tracking-wide text-stone-500">
<span className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('calls.meetLink')}
</span>
<input
@@ -119,13 +123,13 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
value={meetUrl}
onChange={e => setMeetUrl(e.target.value)}
placeholder={PLACEHOLDER_URL}
className="mt-1 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100"
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100"
required
/>
</label>
<label className="block">
<span className="text-xs font-medium uppercase tracking-wide text-stone-500">
<span className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('calls.displayName')}
</span>
<input
@@ -133,7 +137,7 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
value={displayName}
onChange={e => setDisplayName(e.target.value)}
maxLength={64}
className="mt-1 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100"
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100"
required
/>
</label>
@@ -141,7 +145,7 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
{error && (
<div
role="alert"
className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-700">
className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-700 dark:text-coral-300">
{error}
</div>
)}
@@ -156,24 +160,26 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
{activeCalls.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-stone-500">
<h3 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('calls.activeCalls')}
</h3>
<ul className="space-y-2">
{activeCalls.map(call => (
<li
key={call.requestId}
className="flex items-center justify-between gap-3 rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
className="flex items-center justify-between gap-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-stone-900">
<div className="truncate text-sm font-medium text-stone-900 dark:text-neutral-100">
{call.displayName}
</div>
<div className="truncate text-xs text-stone-500">{call.meetUrl}</div>
<div className="truncate text-xs text-stone-500 dark:text-neutral-400">
{call.meetUrl}
</div>
</div>
<button
type="button"
onClick={() => handleClose(call.requestId)}
className="shrink-0 rounded-lg border border-stone-200 bg-white px-3 py-1 text-xs text-stone-600 hover:border-coral-300 hover:text-coral-600">
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-1 text-xs text-stone-600 dark:text-neutral-300 hover:border-coral-300 hover:text-coral-600 dark:text-coral-300">
{t('calls.leave')}
</button>
</li>
@@ -4,7 +4,7 @@ export default function IntelligenceDreamsTab() {
const { t } = useT();
return (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-sky-500/10">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-sky-50 dark:bg-sky-500/10">
<svg className="w-8 h-8 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@@ -20,9 +20,11 @@ export default function IntelligenceDreamsTab() {
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-stone-900 mb-2">{t('memory.tab.dreams')}</h2>
<p className="text-stone-400 text-sm mb-1">{t('dreams.description')}</p>
<p className="text-xs text-stone-500">{t('dreams.comingSoon')}</p>
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('memory.tab.dreams')}
</h2>
<p className="text-stone-400 dark:text-neutral-500 text-sm mb-1">{t('dreams.description')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">{t('dreams.comingSoon')}</p>
</div>
);
}
@@ -47,7 +47,7 @@ export default function IntelligenceMemoryTab({
placeholder={t('memory.search')}
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
className="w-full px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
className="w-full px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-900 dark:text-neutral-100 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
/>
</div>
<label htmlFor="actionable-source" className="sr-only">
@@ -57,7 +57,7 @@ export default function IntelligenceMemoryTab({
id="actionable-source"
value={sourceFilter}
onChange={e => setSourceFilter(e.target.value as ActionableItemSource | 'all')}
className="px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:border-primary-500/50 transition-colors">
className="px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-900 dark:text-neutral-100 focus:outline-none focus:border-primary-500/50 transition-colors">
<option value="all">{t('memory.sourceFilter.all')}</option>
<option value="email">{t('memory.sourceFilter.email')}</option>
<option value="calendar">{t('memory.sourceFilter.calendar')}</option>
@@ -74,16 +74,22 @@ export default function IntelligenceMemoryTab({
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="text-lg font-semibold text-stone-900 mb-2">{t('memory.loading')}</h2>
<p className="text-stone-400 text-sm">{t('memory.fetching')}</p>
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('memory.loading')}
</h2>
<p className="text-stone-400 dark:text-neutral-500 text-sm">{t('memory.fetching')}</p>
</div>
) : isRunning && items.length === 0 ? (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="text-lg font-semibold text-stone-900 mb-2">{t('memory.analyzing')}</h2>
<p className="text-stone-400 text-sm">{t('memory.analyzingHint')}</p>
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('memory.analyzing')}
</h2>
<p className="text-stone-400 dark:text-neutral-500 text-sm">
{t('memory.analyzingHint')}
</p>
</div>
) : timeGroups.length === 0 ? (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
@@ -103,22 +109,30 @@ export default function IntelligenceMemoryTab({
</div>
{searchFilter || sourceFilter !== 'all' ? (
<>
<h2 className="text-lg font-semibold text-stone-900 mb-2">{t('memory.noMatches')}</h2>
<p className="text-stone-400 text-sm">{t('memory.noMatchesHint')}</p>
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('memory.noMatches')}
</h2>
<p className="text-stone-400 dark:text-neutral-500 text-sm">
{t('memory.noMatchesHint')}
</p>
</>
) : usingMemoryData ? (
<>
<h2 className="text-lg font-semibold text-stone-900 mb-2">
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('memory.allCaughtUp')}
</h2>
<p className="text-stone-400 text-sm">{t('memory.allCaughtUpHint')}</p>
<p className="text-stone-400 dark:text-neutral-500 text-sm">
{t('memory.allCaughtUpHint')}
</p>
</>
) : (
<>
<h2 className="text-lg font-semibold text-stone-900 mb-2">
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('memory.noAnalysis')}
</h2>
<p className="text-stone-400 text-sm mb-4">{t('memory.noAnalysisHint')}</p>
<p className="text-stone-400 dark:text-neutral-500 text-sm mb-4">
{t('memory.noAnalysisHint')}
</p>
<button
onClick={() => void handleAnalyzeNow()}
disabled={isRunning}
@@ -131,7 +145,7 @@ export default function IntelligenceMemoryTab({
) : (
<div className="space-y-6">
{isRunning && (
<div className="flex items-center gap-2 text-xs text-stone-400 animate-fade-up">
<div className="flex items-center gap-2 text-xs text-stone-400 dark:text-neutral-500 animate-fade-up">
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
{t('memory.analyzing')}
</div>
@@ -142,8 +156,10 @@ export default function IntelligenceMemoryTab({
className="animate-fade-up"
style={{ animationDelay: `${groupIndex * 50}ms` }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-stone-900 opacity-80">{group.label}</h2>
<div className="text-xs bg-stone-100 text-stone-900 px-2 py-1 rounded-full">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 opacity-80">
{group.label}
</h2>
<div className="text-xs bg-stone-100 dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 px-2 py-1 rounded-full">
{group.count}
</div>
</div>
@@ -175,19 +175,19 @@ export default function IntelligenceSubconsciousTab({
return (
<div className="space-y-6 animate-fade-up">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-stone-400">
<div className="flex items-center gap-2 text-xs text-stone-400 dark:text-neutral-500">
{status && (
<>
<span>
{status.task_count} {t('subconscious.tasks')}
</span>
<span className="text-stone-300">|</span>
<span className="text-stone-300 dark:text-neutral-600">|</span>
<span>
{status.total_ticks} {t('subconscious.ticks')}
</span>
{status.last_tick_at && (
<>
<span className="text-stone-300">|</span>
<span className="text-stone-300 dark:text-neutral-600">|</span>
<span>
{t('subconscious.last')}:{' '}
{new Date(status.last_tick_at * 1000).toLocaleTimeString()}
@@ -196,7 +196,7 @@ export default function IntelligenceSubconsciousTab({
)}
{status.consecutive_failures > 0 && (
<>
<span className="text-stone-300">|</span>
<span className="text-stone-300 dark:text-neutral-600">|</span>
<span className="text-coral-500">
{status.consecutive_failures} {t('subconscious.failed')}
</span>
@@ -208,7 +208,7 @@ export default function IntelligenceSubconsciousTab({
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<svg
className="w-3 h-3 text-stone-400"
className="w-3 h-3 text-stone-400 dark:text-neutral-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -226,7 +226,7 @@ export default function IntelligenceSubconsciousTab({
}}
disabled
title={t('subconscious.tickInterval')}
className="text-xs bg-stone-50 border border-stone-200 rounded px-1.5 py-0.5 text-stone-500 cursor-not-allowed">
className="text-xs bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 rounded px-1.5 py-0.5 text-stone-500 dark:text-neutral-400 cursor-not-allowed">
<option value={5}>5 min</option>
<option value={10}>10 min</option>
<option value={15}>15 min</option>
@@ -240,7 +240,7 @@ export default function IntelligenceSubconsciousTab({
<button
onClick={() => void handleRunTick()}
disabled={triggering}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 hover:bg-stone-100 disabled:opacity-40 border border-stone-200 rounded-lg text-stone-600 transition-colors">
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-40 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-600 dark:text-neutral-300 transition-colors">
{triggering ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
) : (
@@ -265,32 +265,38 @@ export default function IntelligenceSubconsciousTab({
{escalations.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-stone-900 mb-3 flex items-center gap-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
{t('subconscious.approvalNeeded')}
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{escalations.length}
</span>
</h3>
<div className="space-y-2">
{escalations.map(esc => (
<div key={esc.id} className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div
key={esc.id}
className="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-xl p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-stone-900">{esc.title}</p>
<p className="text-xs text-stone-500 mt-1">{esc.description}</p>
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{esc.title}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{esc.description}
</p>
<div className="flex items-center gap-2 mt-2">
<span
className={`text-[10px] px-2 py-0.5 rounded-full ${
esc.priority === 'critical'
? 'bg-coral-100 text-coral-700'
? 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
: esc.priority === 'important'
? 'bg-amber-100 text-amber-700'
: 'bg-stone-100 text-stone-600'
? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300'
}`}>
{esc.priority}
</span>
<span className="text-[10px] text-stone-400">
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('subconscious.requiresApproval')}
</span>
</div>
@@ -311,7 +317,7 @@ export default function IntelligenceSubconsciousTab({
)}
<button
onClick={() => void handleDismissEscalation(esc.id)}
className="px-3 py-1.5 text-xs bg-stone-100 hover:bg-stone-200 text-stone-600 rounded-lg transition-colors">
className="px-3 py-1.5 text-xs bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 text-stone-600 dark:text-neutral-300 rounded-lg transition-colors">
{t('common.skip')}
</button>
</div>
@@ -323,24 +329,30 @@ export default function IntelligenceSubconsciousTab({
)}
<div>
<h3 className="text-sm font-semibold text-stone-900 mb-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3">
{t('subconscious.activeTasks')}
</h3>
{loading && tasks.length === 0 ? (
<div className="text-center py-4">
<div className="w-6 h-6 mx-auto border-2 border-stone-300 border-t-transparent rounded-full animate-spin" />
<div className="w-6 h-6 mx-auto border-2 border-stone-300 dark:border-neutral-700 border-t-transparent rounded-full animate-spin" />
</div>
) : tasks.filter(t => !t.completed).length === 0 ? (
<p className="text-xs text-stone-400 py-3">{t('subconscious.noActiveTasks')}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500 py-3">
{t('subconscious.noActiveTasks')}
</p>
) : (
<div className="space-y-1.5">
{tasks
.filter(t => !t.completed && t.source === 'system')
.map(task => (
<div key={task.id} className="flex items-center py-2 px-3 bg-stone-50 rounded-lg">
<div
key={task.id}
className="flex items-center py-2 px-3 bg-stone-50 dark:bg-neutral-800/60 rounded-lg">
<div className="w-1.5 h-1.5 rounded-full bg-sage-400 flex-shrink-0 mr-2.5" />
<span className="text-sm text-stone-900 truncate flex-1">{task.title}</span>
<span className="text-[10px] text-stone-400 flex-shrink-0 px-1.5 py-0.5 rounded bg-stone-100">
<span className="text-sm text-stone-900 dark:text-neutral-100 truncate flex-1">
{task.title}
</span>
<span className="text-[10px] text-stone-400 dark:text-neutral-500 flex-shrink-0 px-1.5 py-0.5 rounded bg-stone-100 dark:bg-neutral-800">
{t('subconscious.default')}
</span>
</div>
@@ -350,7 +362,7 @@ export default function IntelligenceSubconsciousTab({
.map(task => (
<div
key={task.id}
className="flex items-center justify-between py-2 px-3 bg-stone-50 rounded-lg group">
className="flex items-center justify-between py-2 px-3 bg-stone-50 dark:bg-neutral-800/60 rounded-lg group">
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<button
type="button"
@@ -361,13 +373,13 @@ export default function IntelligenceSubconsciousTab({
task.enabled ? 'bg-sage-500' : 'bg-stone-300'
}`}>
<span
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform ${
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
task.enabled ? 'translate-x-3' : 'translate-x-0'
}`}
/>
</button>
<span
className={`text-sm truncate ${task.enabled ? 'text-stone-900' : 'text-stone-400'}`}>
className={`text-sm truncate ${task.enabled ? 'text-stone-900 dark:text-neutral-100' : 'text-stone-400 dark:text-neutral-500'}`}>
{task.title}
</span>
</div>
@@ -375,7 +387,7 @@ export default function IntelligenceSubconsciousTab({
type="button"
aria-label={`${t('common.remove')} ${task.title}`}
onClick={() => void handleRemoveTask(task.id, task.title)}
className="opacity-0 group-hover:opacity-100 p-1 text-stone-400 hover:text-coral-500 transition-all">
className="opacity-0 group-hover:opacity-100 p-1 text-stone-400 dark:text-neutral-500 hover:text-coral-500 transition-all">
<svg
className="w-3.5 h-3.5"
fill="none"
@@ -400,7 +412,7 @@ export default function IntelligenceSubconsciousTab({
placeholder={t('subconscious.addTaskPlaceholder')}
value={newTaskTitle}
onChange={e => setNewTaskTitle(e.target.value)}
className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-900 dark:text-neutral-100 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
/>
<button
type="submit"
@@ -412,16 +424,18 @@ export default function IntelligenceSubconsciousTab({
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900 mb-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3">
{t('subconscious.activityLog')}
</h3>
{logEntries.length === 0 ? (
<p className="text-xs text-stone-400 py-3">{t('subconscious.noActivity')}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500 py-3">
{t('subconscious.noActivity')}
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{logEntries.map(entry => (
<div key={entry.id} className="flex items-start gap-2 py-1.5 px-2 text-xs">
<span className="text-stone-400 flex-shrink-0 w-14">
<span className="text-stone-400 dark:text-neutral-500 flex-shrink-0 w-14">
{new Date(entry.tick_at * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
@@ -441,17 +455,17 @@ export default function IntelligenceSubconsciousTab({
? 'bg-stone-300'
: entry.decision === 'dismissed'
? 'bg-stone-300'
: 'bg-stone-200'
: 'bg-stone-200 dark:bg-neutral-800'
}`}
/>
<span
className={`break-words min-w-0 ${
entry.decision === 'in_progress'
? 'text-stone-400'
? 'text-stone-400 dark:text-neutral-500'
: entry.decision === 'failed'
? 'text-coral-500'
: 'text-stone-600'
} ${entry.result && entry.result.length > 120 ? 'cursor-pointer hover:text-stone-900' : ''}`}
: 'text-stone-600 dark:text-neutral-300'
} ${entry.result && entry.result.length > 120 ? 'cursor-pointer hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100' : ''}`}
onClick={() => {
if (entry.result && entry.result.length > 120) {
setExpandedLogIds(prev => {
@@ -485,7 +499,7 @@ export default function IntelligenceSubconsciousTab({
: entry.decision}
</span>
{entry.duration_ms != null && (
<span className="text-stone-300 flex-shrink-0 ml-auto">
<span className="text-stone-300 dark:text-neutral-600 flex-shrink-0 ml-auto">
{entry.duration_ms > 1000
? `${(entry.duration_ms / 1000).toFixed(1)}s`
: `${entry.duration_ms}ms`}
@@ -118,7 +118,7 @@ export default function IntelligenceTasksTab() {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-stone-400">
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent mr-2" />
<span className="text-sm">{t('intelligence.tasks.loadingBoards')}</span>
</div>
@@ -127,7 +127,7 @@ export default function IntelligenceTasksTab() {
if (error) {
return (
<div className="rounded-xl border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700">
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{t('intelligence.tasks.failedToLoad')}: {error}
</div>
);
@@ -135,10 +135,12 @@ export default function IntelligenceTasksTab() {
if (boardEntries.length === 0) {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center text-stone-400">
<div className="flex flex-col items-center gap-3 py-12 text-center text-stone-400 dark:text-neutral-500">
<div className="text-3xl">📋</div>
<p className="text-sm font-medium">{t('intelligence.tasks.empty')}</p>
<p className="text-xs text-stone-400">{t('intelligence.tasks.emptyHint')}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('intelligence.tasks.emptyHint')}
</p>
</div>
);
}
@@ -151,11 +153,13 @@ export default function IntelligenceTasksTab() {
return (
<div className="space-y-6">
<p className="text-xs text-stone-400">{boardCountLabel}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">{boardCountLabel}</p>
{boardEntries.map(entry => (
<section key={entry.threadId} className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-stone-700" title={entry.title}>
<h3
className="truncate text-sm font-semibold text-stone-700 dark:text-neutral-200"
title={entry.title}>
{entry.title}
</h3>
{entry.live && (
+22 -14
View File
@@ -240,7 +240,7 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
if (nodes.length === 0) {
return (
<div
className="flex h-[640px] items-center justify-center rounded-lg border border-stone-100 bg-stone-50/40 text-sm text-stone-500"
className="flex h-[640px] items-center justify-center rounded-lg border border-stone-100 dark:border-neutral-800 bg-stone-50/40 text-sm text-stone-500 dark:text-neutral-400"
data-testid="memory-graph-empty">
{emptyHint ?? (mode === 'contacts' ? t('graph.noContactMentions') : t('graph.noMemory'))}
</div>
@@ -269,13 +269,13 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
];
return (
<div className="memory-graph rounded-lg border border-stone-100 bg-white">
<div className="flex items-center justify-between gap-4 border-b border-stone-100 px-4 py-2">
<div className="flex items-center gap-3 text-xs text-stone-500">
<div className="memory-graph rounded-lg border border-stone-100 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<div className="flex items-center justify-between gap-4 border-b border-stone-100 dark:border-neutral-800 px-4 py-2">
<div className="flex items-center gap-3 text-xs text-stone-500 dark:text-neutral-400">
<span>
{nodes.length} {t('graph.nodes')}
</span>
<span className="text-stone-300">·</span>
<span className="text-stone-300 dark:text-neutral-600">·</span>
<span>
{sim.edges.length}{' '}
{mode === 'tree' ? t('graph.parentChild') : t('graph.documentContact')}{' '}
@@ -284,7 +284,9 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
</div>
<div className="flex items-center gap-3">
{legend.map(item => (
<span key={item.label} className="flex items-center gap-1.5 text-xs text-stone-600">
<span
key={item.label}
className="flex items-center gap-1.5 text-xs text-stone-600 dark:text-neutral-300">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color }}
@@ -336,32 +338,38 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
</svg>
{hovered && (
<div
className="border-t border-stone-100 bg-stone-50/70 px-4 py-2 text-xs text-stone-700"
className="border-t border-stone-100 dark:border-neutral-800 bg-stone-50/70 px-4 py-2 text-xs text-stone-700 dark:text-neutral-200"
data-testid="memory-graph-tooltip">
{hovered.kind === 'summary' ? (
<>
<span className="font-mono">L{hovered.level ?? '?'}</span>
<span className="text-stone-400"> · </span>
<span className="text-stone-400 dark:text-neutral-500"> · </span>
<span className="capitalize">{hovered.tree_kind}</span>
<span className="text-stone-400"> · </span>
<span className="text-stone-400 dark:text-neutral-500"> · </span>
<span>{hovered.tree_scope}</span>
<span className="text-stone-400"> · </span>
<span className="text-stone-400 dark:text-neutral-500"> · </span>
<span>
{hovered.child_count ?? 0} {t('graph.children')}
</span>
<span className="ml-3 text-stone-400">{t('graph.clickToOpenObsidian')}</span>
<span className="ml-3 text-stone-400 dark:text-neutral-500">
{t('graph.clickToOpenObsidian')}
</span>
</>
) : hovered.kind === 'contact' ? (
<>
<span className="font-medium text-violet-700">{hovered.label}</span>
<span className="ml-3 text-stone-400">
<span className="font-medium text-violet-700 dark:text-violet-300">
{hovered.label}
</span>
<span className="ml-3 text-stone-400 dark:text-neutral-500">
{t('graph.person')} · canonical id {hovered.id.slice(0, 12)}
</span>
</>
) : (
<>
<span className="font-medium">{hovered.label || 'chunk'}</span>
<span className="ml-3 text-stone-400">{t('graph.document')}</span>
<span className="ml-3 text-stone-400 dark:text-neutral-500">
{t('graph.document')}
</span>
</>
)}
</div>
@@ -128,21 +128,23 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
if (loading) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<h3 className="text-sm font-semibold text-stone-900 mb-3">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-5">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3">
{t('memory.ingestionActivity')}
</h3>
<div className="h-28 rounded-lg bg-stone-200 animate-pulse" />
<div className="h-28 rounded-lg bg-stone-200 dark:bg-neutral-800 animate-pulse" />
</div>
);
}
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-5">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-stone-900">{t('memory.ingestionActivity')}</h3>
<p className="text-xs text-stone-500 mt-0.5">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('memory.ingestionActivity')}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{totalEvents} {totalEvents !== 1 ? t('memory.events') : t('memory.event')}{' '}
{t('memory.overTheLast')} {MONTHS} {t('memory.months')}
{maxDailyCount > 0 && (
@@ -154,7 +156,7 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
)}
</p>
</div>
<div className="flex items-center gap-1 text-[10px] text-stone-500">
<div className="flex items-center gap-1 text-[10px] text-stone-500 dark:text-neutral-400">
<span>{t('memory.less')}</span>
{INTENSITY_COLORS.map((color, i) => (
<div
@@ -240,12 +242,12 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
{/* Tooltip */}
{hoveredCell && (
<div
className="fixed z-50 px-2 py-1 rounded-md bg-white border border-stone-200 text-[11px] text-stone-900 shadow-lg pointer-events-none"
className="fixed z-50 px-2 py-1 rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 text-[11px] text-stone-900 dark:text-neutral-100 shadow-lg pointer-events-none"
style={{ left: hoveredCell.x, top: hoveredCell.y - 32, transform: 'translateX(-50%)' }}>
<span className="font-medium">
{hoveredCell.count} {hoveredCell.count !== 1 ? t('memory.events') : t('memory.event')}
</span>{' '}
<span className="text-stone-400">
<span className="text-stone-400 dark:text-neutral-500">
{t('memory.on')} {formatDate(hoveredCell.date)}
</span>
</div>
@@ -95,44 +95,44 @@ function useInsightCategoryLabels() {
facts: {
label: t('insights.knownFacts'),
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
color: 'text-emerald-600',
bgColor: 'bg-emerald-50',
borderColor: 'border-emerald-200',
color: 'text-emerald-600 dark:text-emerald-300',
bgColor: 'bg-emerald-50 dark:bg-emerald-500/10 ',
borderColor: 'border-emerald-200 dark:border-emerald-500/30',
},
preferences: {
label: t('insights.preferences'),
icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z',
color: 'text-rose-600',
bgColor: 'bg-rose-50',
borderColor: 'border-rose-200',
color: 'text-rose-600 dark:text-rose-300',
bgColor: 'bg-rose-50 dark:bg-rose-500/10 ',
borderColor: 'border-rose-200 dark:border-rose-500/30',
},
relationships: {
label: t('insights.relationships'),
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
color: 'text-primary-600',
bgColor: 'bg-primary-50',
borderColor: 'border-primary-200',
color: 'text-primary-600 dark:text-primary-300',
bgColor: 'bg-primary-50 dark:bg-primary-500/15',
borderColor: 'border-primary-200 dark:border-primary-500/30',
},
skills: {
label: t('insights.skills'),
icon: 'M13 10V3L4 14h7v7l9-11h-7z',
color: 'text-amber-600',
bgColor: 'bg-amber-100',
borderColor: 'border-amber-200',
color: 'text-amber-600 dark:text-amber-300',
bgColor: 'bg-amber-100 dark:bg-amber-500/20',
borderColor: 'border-amber-200 dark:border-amber-500/30',
},
opinions: {
label: t('insights.opinions'),
icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
color: 'text-lavender-600',
bgColor: 'bg-lavender-50',
borderColor: 'border-lavender-200',
color: 'text-lavender-600 dark:text-lavender-300',
bgColor: 'bg-lavender-50 dark:bg-lavender-500/10 ',
borderColor: 'border-lavender-200 dark:border-lavender-500/30',
},
other: {
label: t('insights.other'),
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
color: 'text-stone-600',
bgColor: 'bg-stone-100',
borderColor: 'border-stone-200',
color: 'text-stone-600 dark:text-neutral-300',
bgColor: 'bg-stone-100 dark:bg-neutral-800',
borderColor: 'border-stone-200 dark:border-neutral-800',
},
} as const;
}
@@ -140,7 +140,7 @@ function useInsightCategoryLabels() {
/** Small inline badge that displays an entity type (e.g. "person", "project"). */
function EntityTypeBadge({ type }: { type: string }) {
return (
<span className="inline-block ml-1 px-1 py-px rounded text-[9px] leading-tight font-medium bg-white/8 text-stone-400 border border-white/6 uppercase tracking-wide">
<span className="inline-block ml-1 px-1 py-px rounded text-[9px] leading-tight font-medium bg-white/8 text-stone-400 dark:text-neutral-500 border border-white/6 uppercase tracking-wide">
{type}
</span>
);
@@ -192,11 +192,16 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
if (loading) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<h3 className="text-sm font-semibold text-stone-900 mb-4">{t('insights.title')}</h3>
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-5">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
{t('insights.title')}
</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-28 rounded-lg bg-stone-200 animate-pulse" />
<div
key={i}
className="h-28 rounded-lg bg-stone-200 dark:bg-neutral-800 animate-pulse"
/>
))}
</div>
</div>
@@ -205,19 +210,23 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
if (groups.length === 0) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<h3 className="text-sm font-semibold text-stone-900 mb-2">{t('insights.title')}</h3>
<p className="text-sm text-stone-600">{t('insights.empty')}</p>
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-5">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('insights.title')}
</h3>
<p className="text-sm text-stone-600 dark:text-neutral-300">{t('insights.empty')}</p>
</div>
);
}
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">{t('insights.title')}</h3>
<p className="text-xs text-stone-500 mt-0.5">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('insights.title')}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t('insights.description').replace('{count}', String(relations.length))}
</p>
</div>
@@ -254,12 +263,12 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
</div>
<div className="min-w-0 flex-1">
<div className={`text-xs font-medium ${group.color}`}>{group.label}</div>
<div className="text-[10px] text-stone-500">
<div className="text-[10px] text-stone-500 dark:text-neutral-400">
{group.items.length} {t('insights.items')}
</div>
</div>
<svg
className={`w-3.5 h-3.5 text-stone-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
className={`w-3.5 h-3.5 text-stone-500 dark:text-neutral-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -278,25 +287,29 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
key={`${item.subject}-${item.predicate}-${item.object}-${idx}`}
className="flex items-start gap-1.5 text-[11px] leading-relaxed">
<span
className="text-stone-900 font-medium shrink-0 max-w-[30%] truncate"
className="text-stone-900 dark:text-neutral-100 font-medium shrink-0 max-w-[30%] truncate"
title={item.subject}>
{item.subject}
{item.subjectType && <EntityTypeBadge type={item.subjectType} />}
</span>
<span className="text-stone-500 shrink-0 italic">{item.predicate}</span>
<span className="text-stone-600 truncate" title={item.object}>
<span className="text-stone-500 dark:text-neutral-400 shrink-0 italic">
{item.predicate}
</span>
<span
className="text-stone-600 dark:text-neutral-300 truncate"
title={item.object}>
{item.object}
{item.objectType && <EntityTypeBadge type={item.objectType} />}
</span>
{item.evidenceCount > 1 && (
<span className="ml-auto text-[9px] text-stone-600 shrink-0 tabular-nums">
<span className="ml-auto text-[9px] text-stone-600 dark:text-neutral-300 shrink-0 tabular-nums">
x{item.evidenceCount}
</span>
)}
</div>
))}
{!isExpanded && group.items.length > 3 && (
<div className="text-[10px] text-stone-500 pt-0.5">
<div className="text-[10px] text-stone-500 dark:text-neutral-400 pt-0.5">
+{group.items.length - 3} {t('insights.more')}
</div>
)}
@@ -67,11 +67,11 @@ function useFreshnessLabel() {
function freshnessBadge(label: FreshnessLabel): string {
switch (label) {
case 'active':
return 'bg-primary-100 text-primary-700';
return 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300';
case 'recent':
return 'bg-sage-100 text-sage-700';
return 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300';
case 'idle':
return 'bg-stone-100 text-stone-700';
return 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200';
}
}
@@ -223,10 +223,12 @@ export function MemorySources({
if (loading) {
return (
<section
className="rounded-lg border border-stone-200 bg-white p-4"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4"
data-testid="memory-sources">
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 text-xs text-stone-500">{t('common.loading')}</p>
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.memorySources')}
</h3>
<p className="mt-2 text-xs text-stone-500 dark:text-neutral-400">{t('common.loading')}</p>
</section>
);
}
@@ -234,10 +236,12 @@ export function MemorySources({
if (loadError) {
return (
<section
className="rounded-lg border border-stone-200 bg-white p-4"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4"
data-testid="memory-sources">
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 break-words rounded-md bg-coral-50 p-2 text-xs text-coral-800">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.memorySources')}
</h3>
<p className="mt-2 break-words rounded-md bg-coral-50 dark:bg-coral-500/10 p-2 text-xs text-coral-800">
{loadError}
</p>
</section>
@@ -247,25 +251,31 @@ export function MemorySources({
if (rows.length === 0) {
return (
<section
className="rounded-lg border border-stone-200 bg-white p-4"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4"
data-testid="memory-sources">
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 text-xs text-stone-500">{t('sync.noConnectedSources')}</p>
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.memorySources')}
</h3>
<p className="mt-2 text-xs text-stone-500 dark:text-neutral-400">
{t('sync.noConnectedSources')}
</p>
</section>
);
}
return (
<section
className="rounded-lg border border-stone-200 bg-white p-4"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4"
data-testid="memory-sources">
<header className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<span className="text-xs text-stone-400">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.memorySources')}
</h3>
<span className="text-xs text-stone-400 dark:text-neutral-500">
{rows.length} identit{rows.length === 1 ? 'y' : 'ies'}
</span>
</header>
<ul className="divide-y divide-stone-100">
<ul className="divide-y divide-stone-100 dark:divide-neutral-800">
{rows.map(row => (
<SourceRowCard
key={row.key}
@@ -309,7 +319,9 @@ function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
data-testid={`memory-source-row-${toolkit}`}>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-stone-900">{title}</span>
<span className="truncate text-sm font-medium text-stone-900 dark:text-neutral-100">
{title}
</span>
{status && (
<span
className={`rounded-md px-2 py-0.5 text-xs font-medium ${freshnessBadge(status.freshness)}`}
@@ -318,12 +330,12 @@ function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
</span>
)}
{!isActive && (
<span className="rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
<span className="rounded-md bg-amber-100 dark:bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">
{connection.status}
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-stone-500">
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-stone-500 dark:text-neutral-400">
<span data-testid={`memory-source-chunks-${toolkit}`}>
{lifetime.toLocaleString()} {t('sync.chunks')}
</span>
@@ -340,7 +352,7 @@ function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
</div>
{showProgress && (
<div className="mt-2 max-w-md" data-testid={`memory-source-progress-${toolkit}`}>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-stone-100">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-stone-100 dark:bg-neutral-800">
<div
className="h-full bg-primary-500 transition-all"
style={{ width: `${pct}%` }}
@@ -350,7 +362,7 @@ function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
aria-valuemax={batchTotal}
/>
</div>
<div className="mt-1 text-xs text-stone-500">
<div className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{batchProcessed.toLocaleString()} / {batchTotal.toLocaleString()}{' '}
{t('sync.processed')}
</div>
@@ -67,19 +67,19 @@ export function MemoryStatsBar(props: MemoryStatsBarProps) {
label: t('stats.documents'),
value: formatNumber(totalDocs),
sub: docsToday > 0 ? `+${docsToday} ${t('stats.today')}` : undefined,
color: 'text-emerald-600',
color: 'text-emerald-600 dark:text-emerald-300',
},
{
label: t('stats.namespaces'),
value: formatNumber(totalNamespaces),
sub: undefined,
color: 'text-amber-600',
color: 'text-amber-600 dark:text-amber-300',
},
{
label: t('stats.relations'),
value: formatNumber(totalRelations),
sub: undefined,
color: 'text-lavender-600',
color: 'text-lavender-600 dark:text-lavender-300',
},
{
label: t('stats.firstMemory'),
@@ -87,13 +87,13 @@ export function MemoryStatsBar(props: MemoryStatsBarProps) {
sub: newestDocTimestamp
? `${t('stats.latest')}: ${formatTimeAgo(newestDocTimestamp)}`
: undefined,
color: 'text-sky-600',
color: 'text-sky-600 dark:text-sky-300',
},
{
label: t('stats.sessions'),
value: totalSessions !== null ? formatNumber(totalSessions) : '--',
sub: totalTokens !== null ? `${formatNumber(totalTokens)} ${t('stats.tokens')}` : undefined,
color: 'text-rose-600',
color: 'text-rose-600 dark:text-rose-300',
},
];
@@ -102,14 +102,22 @@ export function MemoryStatsBar(props: MemoryStatsBarProps) {
{stats.map(stat => (
<div
key={stat.label}
className="rounded-xl border border-stone-200 bg-stone-50 p-3 transition-colors hover:bg-stone-100">
<div className="text-[11px] uppercase tracking-wide text-stone-500 mb-1">
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800">
<div className="text-[11px] uppercase tracking-wide text-stone-500 dark:text-neutral-400 mb-1">
{stat.label}
</div>
<div className={`text-xl font-semibold ${stat.color}`}>
{loading ? <div className="h-7 w-16 rounded bg-stone-200 animate-pulse" /> : stat.value}
{loading ? (
<div className="h-7 w-16 rounded bg-stone-200 dark:bg-neutral-800 animate-pulse" />
) : (
stat.value
)}
</div>
{stat.sub && <div className="text-[11px] text-stone-500 mt-0.5">{stat.sub}</div>}
{stat.sub && (
<div className="text-[11px] text-stone-500 dark:text-neutral-400 mt-0.5">
{stat.sub}
</div>
)}
</div>
))}
</div>
@@ -47,9 +47,9 @@ function freshnessBadgeClass(label: FreshnessLabel): string {
case 'active':
return 'bg-ocean-100 text-ocean-700';
case 'recent':
return 'bg-sage-100 text-sage-700';
return 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300';
case 'idle':
return 'bg-stone-100 text-stone-700';
return 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200';
}
}
@@ -89,19 +89,19 @@ function SourceCard({ status }: SourceCardProps) {
return (
<div
className="rounded-lg border border-stone-200 bg-white p-4 shadow-sm"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-sm"
data-testid={`memory-sync-card-${status.provider}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-stone-900">{label}</span>
<span className="font-medium text-stone-900 dark:text-neutral-100">{label}</span>
<span
className={`rounded-md px-2 py-0.5 text-xs font-medium ${freshnessBadgeClass(status.freshness)}`}
data-testid={`memory-sync-freshness-${status.provider}`}>
{useFreshnessLabel()[status.freshness]}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-stone-500">
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-stone-500 dark:text-neutral-400">
<span data-testid={`memory-sync-chunks-${status.provider}`}>
{lifetime.toLocaleString()} {t('sync.chunks')}
</span>
@@ -116,7 +116,7 @@ function SourceCard({ status }: SourceCardProps) {
{showProgress && (
<div className="mt-3" data-testid={`memory-sync-progress-${status.provider}`}>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-stone-100">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-stone-100 dark:bg-neutral-800">
<div
className="h-full bg-ocean-400 transition-all"
style={{ width: `${pct}%` }}
@@ -126,13 +126,13 @@ function SourceCard({ status }: SourceCardProps) {
aria-valuemax={batchTotal}
/>
</div>
<div className="mt-1 text-xs text-stone-500">
<div className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
<span data-testid={`memory-sync-pending-${status.provider}`}>
{batchProcessed.toLocaleString()} / {batchTotal.toLocaleString()}{' '}
{t('sync.processed')}
</span>
{pending > 0 && (
<span className="text-stone-400">
<span className="text-stone-400 dark:text-neutral-500">
{' '}
· {pending.toLocaleString()} {t('sync.pending')}
</span>
@@ -187,8 +187,10 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP
if (loading) {
return (
<section className="memory-sync-connections" data-testid="memory-sync-connections">
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 text-xs text-stone-500">{t('common.loading')}</p>
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.memorySources')}
</h3>
<p className="mt-2 text-xs text-stone-500 dark:text-neutral-400">{t('common.loading')}</p>
</section>
);
}
@@ -196,8 +198,10 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP
if (loadError) {
return (
<section className="memory-sync-connections" data-testid="memory-sync-connections">
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 rounded-md bg-coral-50 p-2 text-xs text-coral-800 break-words">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.memorySources')}
</h3>
<p className="mt-2 rounded-md bg-coral-50 dark:bg-coral-500/10 p-2 text-xs text-coral-800 break-words">
{t('sync.failedToLoad')}: {loadError}
</p>
</section>
@@ -207,15 +211,19 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP
if (statuses.length === 0) {
return (
<section className="memory-sync-connections" data-testid="memory-sync-connections">
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 text-xs text-stone-500">{t('sync.noContent')}</p>
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.memorySources')}
</h3>
<p className="mt-2 text-xs text-stone-500 dark:text-neutral-400">{t('sync.noContent')}</p>
</section>
);
}
return (
<section className="memory-sync-connections" data-testid="memory-sync-connections">
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.memorySources')}
</h3>
<div className="mt-2 space-y-2">
{statuses.map(s => (
<SourceCard key={s.provider} status={s} />
@@ -251,8 +251,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
disabled={wiping || building}
data-testid="memory-wipe-all"
className="inline-flex items-center gap-2 rounded-lg
border border-coral-200 bg-white px-4 py-2 text-sm font-semibold
text-coral-700 shadow-sm transition-colors hover:bg-coral-50
border border-coral-200 dark:border-coral-500/30 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold
text-coral-700 dark:text-coral-300 shadow-sm transition-colors hover:bg-coral-50 dark:hover:bg-coral-500/10
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-coral-200"
title={t('workspace.wipeTitle')}>
@@ -272,8 +272,8 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
disabled={resetting || wiping || building}
data-testid="memory-reset-tree"
className="inline-flex items-center gap-2 rounded-lg
border border-amber-300 bg-white px-4 py-2 text-sm font-semibold
text-amber-800 shadow-sm transition-colors hover:bg-amber-50
border border-amber-300 dark:border-amber-500/30 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold
text-amber-800 dark:text-amber-300 shadow-sm transition-colors hover:bg-amber-50 dark:hover:bg-amber-500/10
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-amber-200"
title={t('workspace.resetTreeTitle')}>
@@ -325,11 +325,11 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
</div>
{error ? (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-800">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-800">
{t('workspace.graphLoadFailed')}: {error}
</div>
) : !graph ? (
<div className="flex h-[640px] items-center justify-center rounded-lg border border-stone-100 bg-stone-50/40 text-sm text-stone-500">
<div className="flex h-[640px] items-center justify-center rounded-lg border border-stone-100 dark:border-neutral-800 bg-stone-50/40 text-sm text-stone-500 dark:text-neutral-400">
{t('workspace.loadingGraph')}
</div>
) : (
@@ -354,10 +354,11 @@ function ModeToggle({ mode, onChange }: ModeToggleProps) {
const baseBtn =
'px-3 py-1.5 text-xs font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary-200';
const active = 'bg-primary-500 text-white shadow-sm';
const idle = 'bg-white text-stone-600 hover:bg-stone-50';
const idle =
'bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60';
return (
<div
className="inline-flex items-center gap-1 rounded-lg border border-stone-200 bg-stone-50 p-1"
className="inline-flex items-center gap-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-1"
role="tablist"
aria-label={t('workspace.graphViewMode')}
data-testid="memory-graph-mode-toggle">
@@ -62,18 +62,18 @@ const ScreenIntelligenceDebugPanelContent = ({
</h3>
<button
onClick={handleRefreshStatus}
className="rounded-lg border border-stone-700 bg-stone-800/60 px-3 py-1 text-xs text-stone-300 transition-colors hover:bg-stone-700/60">
className="rounded-lg border border-stone-700 bg-stone-800/60 px-3 py-1 text-xs text-stone-300 dark:text-neutral-600 transition-colors hover:bg-stone-700/60">
{t('common.refresh')}
</button>
</div>
{/* Permissions */}
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('intelligence.screenDebug.permissions')}
</h4>
{status?.platform_supported === false ? (
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('intelligence.screenDebug.platformNotSupported')}
</p>
) : (
@@ -96,13 +96,16 @@ const ScreenIntelligenceDebugPanelContent = ({
{/* Session Status */}
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('intelligence.screenDebug.session')}
</h4>
<div className="space-y-1 text-xs text-stone-300">
<div className="space-y-1 text-xs text-stone-300 dark:text-neutral-600">
<div className="flex justify-between">
<span>{t('intelligence.screenDebug.active')}</span>
<span className={session?.active ? 'text-green-400' : 'text-stone-500'}>
<span
className={
session?.active ? 'text-green-400' : 'text-stone-500 dark:text-neutral-400'
}>
{session?.active ? t('common.yes') : t('common.no')}
</span>
</div>
@@ -129,7 +132,7 @@ const ScreenIntelligenceDebugPanelContent = ({
{/* Capture Test */}
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('intelligence.screenDebug.captureTest')}
</h4>
<button
@@ -143,7 +146,7 @@ const ScreenIntelligenceDebugPanelContent = ({
{captureTestResult && (
<div className="space-y-2">
<div className="space-y-1 text-xs text-stone-300">
<div className="space-y-1 text-xs text-stone-300 dark:text-neutral-600">
<div className="flex justify-between">
<span>{t('intelligence.screenDebug.status')}</span>
<span className={captureTestResult.ok ? 'text-green-400' : 'text-red-400'}>
@@ -208,7 +211,7 @@ const ScreenIntelligenceDebugPanelContent = ({
{/* Recent Vision Summaries */}
{recentVisionSummaries.length > 0 && (
<div className="rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400">
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('intelligence.screenDebug.recentVisionSummaries')}
</h4>
<div className="space-y-2">
@@ -216,7 +219,7 @@ const ScreenIntelligenceDebugPanelContent = ({
<div
key={summary.id}
className="rounded-lg border border-stone-700/50 bg-stone-800/30 p-2 text-xs">
<div className="flex justify-between text-stone-400">
<div className="flex justify-between text-stone-400 dark:text-neutral-500">
<span>{summary.app_name ?? t('intelligence.screenDebug.unknown')}</span>
<span>
{new Date(summary.captured_at_ms).toLocaleTimeString()} &middot;{' '}
@@ -258,7 +261,7 @@ const PermissionDot = ({ label, value }: { label: string; value?: string }) => {
return (
<div className="flex items-center gap-1.5">
<div className={`h-2 w-2 rounded-full ${color}`} />
<span className="text-stone-300">{label}</span>
<span className="text-stone-300 dark:text-neutral-600">{label}</span>
</div>
);
};
@@ -173,7 +173,9 @@ export default function SubconsciousReflectionCards({
if (loading) {
return (
<div data-testid="reflection-cards-loading" className="text-xs text-stone-400 py-2">
<div
data-testid="reflection-cards-loading"
className="text-xs text-stone-400 dark:text-neutral-500 py-2">
{t('reflections.loading')}
</div>
);
@@ -181,7 +183,9 @@ export default function SubconsciousReflectionCards({
if (visible.length === 0 && !error) {
return (
<div data-testid="reflection-cards-empty" className="text-xs text-stone-400 py-3">
<div
data-testid="reflection-cards-empty"
className="text-xs text-stone-400 dark:text-neutral-500 py-3">
{t('reflections.empty')}
</div>
);
@@ -195,15 +199,17 @@ export default function SubconsciousReflectionCards({
return (
<div data-testid="reflection-cards" className="flex flex-col h-full min-h-0 overflow-hidden">
<div className="shrink-0 pb-3">
<h3 className="text-sm font-semibold text-stone-900 flex items-center gap-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary-400" />
{t('reflections.title')}
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary-50 text-primary-700">
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300">
{visible.length}
</span>
</h3>
{error && (
<div data-testid="reflection-cards-error" className="text-xs text-coral-600 mt-2">
<div
data-testid="reflection-cards-error"
className="text-xs text-coral-600 dark:text-coral-300 mt-2">
{error}
</div>
)}
@@ -229,23 +235,25 @@ export default function SubconsciousReflectionCards({
<div
key={r.id}
data-testid={`reflection-card-${r.id}`}
className="bg-white border border-stone-200 rounded-xl p-4">
className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-stone-100 text-stone-600">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300">
{kindLabel(r.kind, t)}
</span>
<span
data-testid={`reflection-timestamp-${r.id}`}
className="text-[10px] text-stone-400"
className="text-[10px] text-stone-400 dark:text-neutral-500"
title={formatAbsoluteTime(r.created_at)}>
{formatRelativeTime(r.created_at, t)}
</span>
</div>
<p className="text-sm text-stone-900 whitespace-pre-line break-words">{r.body}</p>
<p className="text-sm text-stone-900 dark:text-neutral-100 whitespace-pre-line break-words">
{r.body}
</p>
{r.proposed_action && (
<p className="text-xs text-stone-500 mt-2">
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-2">
<em>{t('reflections.proposedAction')}:</em> {r.proposed_action}
</p>
)}
@@ -262,7 +270,7 @@ export default function SubconsciousReflectionCards({
<button
data-testid={`reflection-dismiss-${r.id}`}
onClick={() => void handleDismiss(r.id)}
className="px-3 py-1.5 text-xs bg-stone-100 hover:bg-stone-200 text-stone-600 rounded-lg transition-colors">
className="px-3 py-1.5 text-xs bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 text-stone-600 dark:text-neutral-300 rounded-lg transition-colors">
{t('reflections.dismiss')}
</button>
</div>
+34 -26
View File
@@ -150,21 +150,23 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
return (
<div
className="rounded-lg border border-stone-200 bg-white p-4 shadow-sm"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-sm"
data-testid="vault-panel">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-stone-800">Knowledge vaults</h3>
<p className="text-xs text-stone-500">
<h3 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
Knowledge vaults
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400">
Point at a local folder; files are chunked and mirrored into memory.
</p>
</div>
<button
type="button"
onClick={() => setShowForm(v => !v)}
className="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-white
px-3 py-1.5 text-xs font-semibold text-primary-700 shadow-sm
transition-colors hover:bg-primary-50
className="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-white dark:bg-neutral-900
px-3 py-1.5 text-xs font-semibold text-primary-700 dark:text-primary-300 shadow-sm
transition-colors hover:bg-primary-50 dark:hover:bg-primary-500/15
focus:outline-none focus:ring-2 focus:ring-primary-200"
data-testid="vault-add-toggle">
{showForm ? 'Cancel' : '+ Add vault'}
@@ -174,34 +176,36 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
{showForm ? (
<form
onSubmit={handleCreate}
className="mb-3 space-y-2 rounded-md border border-stone-100 bg-stone-50 p-3"
className="mb-3 space-y-2 rounded-md border border-stone-100 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3"
data-testid="vault-add-form">
<label className="block">
<span className="text-xs font-medium text-stone-600">Name</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">Name</span>
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
required
placeholder="My research notes"
className="mt-1 w-full rounded border border-stone-200 bg-white px-2 py-1.5 text-sm
className="mt-1 w-full rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1.5 text-sm
focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-stone-600">Folder path (absolute)</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Folder path (absolute)
</span>
<input
type="text"
value={newPath}
onChange={e => setNewPath(e.target.value)}
required
placeholder="/Users/you/Documents/notes"
className="mt-1 w-full rounded border border-stone-200 bg-white px-2 py-1.5 font-mono text-xs
className="mt-1 w-full rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1.5 font-mono text-xs
focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-stone-600">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Excludes (comma-separated substrings, optional)
</span>
<input
@@ -209,7 +213,7 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
value={newExcludes}
onChange={e => setNewExcludes(e.target.value)}
placeholder="drafts/, .secret"
className="mt-1 w-full rounded border border-stone-200 bg-white px-2 py-1.5 text-xs
className="mt-1 w-full rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs
focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
/>
</label>
@@ -227,29 +231,33 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
) : null}
{loading ? (
<div className="py-4 text-center text-xs text-stone-400">Loading vaults</div>
<div className="py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
Loading vaults
</div>
) : loadError ? (
<div className="rounded border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-800">
<div className="rounded border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-800">
Failed to load vaults: {loadError}
</div>
) : vaults.length === 0 ? (
<div className="py-4 text-center text-xs text-stone-400">
<div className="py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
No vaults yet. Add one above to start ingesting a folder.
</div>
) : (
<ul className="divide-y divide-stone-100" data-testid="vault-list">
<ul className="divide-y divide-stone-100 dark:divide-neutral-800" data-testid="vault-list">
{vaults.map(v => {
const state = busy[v.id];
return (
<li key={v.id} className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-stone-800">{v.name}</div>
<div className="truncate text-sm font-medium text-stone-800 dark:text-neutral-100">
{v.name}
</div>
<div
className="truncate font-mono text-[11px] text-stone-500"
className="truncate font-mono text-[11px] text-stone-500 dark:text-neutral-400"
title={v.root_path}>
{v.root_path}
</div>
<div className="mt-0.5 text-[11px] text-stone-400">
<div className="mt-0.5 text-[11px] text-stone-400 dark:text-neutral-500">
{v.file_count.toLocaleString()} file(s) ·{' '}
{v.last_synced_at
? `synced ${formatRelative(v.last_synced_at)}`
@@ -261,18 +269,18 @@ export function VaultPanel({ onToast }: VaultPanelProps) {
type="button"
onClick={() => void handleSync(v)}
disabled={state === 'sync' || state === 'remove'}
className="rounded-md border border-primary-300 bg-white px-3 py-1.5 text-xs
font-semibold text-primary-700 shadow-sm transition-colors
hover:bg-primary-50 disabled:cursor-not-allowed disabled:opacity-50">
className="rounded-md border border-primary-300 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs
font-semibold text-primary-700 dark:text-primary-300 shadow-sm transition-colors
hover:bg-primary-50 dark:hover:bg-primary-500/15 disabled:cursor-not-allowed disabled:opacity-50">
{state === 'sync' ? 'Syncing…' : 'Sync'}
</button>
<button
type="button"
onClick={() => void handleRemove(v)}
disabled={state === 'sync' || state === 'remove'}
className="rounded-md border border-coral-200 bg-white px-3 py-1.5 text-xs
font-semibold text-coral-700 shadow-sm transition-colors
hover:bg-coral-50 disabled:cursor-not-allowed disabled:opacity-50">
className="rounded-md border border-coral-200 dark:border-coral-500/30 bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs
font-semibold text-coral-700 dark:text-coral-300 shadow-sm transition-colors
hover:bg-coral-50 dark:hover:bg-coral-500/10 disabled:cursor-not-allowed disabled:opacity-50">
{state === 'remove' ? 'Removing…' : 'Remove'}
</button>
</div>
@@ -47,13 +47,15 @@ export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemory
return (
<div
className="rounded-xl border border-stone-100 bg-white px-4 py-3 shadow-sm"
className="rounded-xl border border-stone-100 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3 shadow-sm"
data-testid="whatsapp-memory-section">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<WhatsAppIcon />
<span className="text-sm font-semibold text-stone-800">WhatsApp</span>
<span className="text-xs text-stone-500">
<span className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
WhatsApp
</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">
{chatCount.toLocaleString()}{' '}
{chatCount !== 1 ? t('whatsapp.chatsSynced') : t('whatsapp.chatSynced')}
{lastSyncTs !== null && <> · {relativeTime(lastSyncTs, t)}</>}
@@ -63,9 +65,9 @@ export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemory
type="button"
onClick={() => void handleSync()}
disabled={syncing}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-stone-200
bg-white px-3 py-1.5 text-xs font-semibold text-stone-700 shadow-sm
transition-colors hover:bg-stone-50
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-stone-200 dark:border-neutral-800
bg-white dark:bg-neutral-900 px-3 py-1.5 text-xs font-semibold text-stone-700 dark:text-neutral-200 shadow-sm
transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
<RefreshIcon spinning={syncing} />
@@ -33,7 +33,7 @@ function providerBadgeClass(provider: string): string {
case 'linkedin':
return 'bg-sky-100 text-sky-700 border-sky-200';
default:
return 'bg-stone-100 text-stone-700 border-stone-200';
return 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800';
}
}
@@ -69,8 +69,8 @@ const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }
return (
<div
className={`w-full p-3 border-b border-stone-100 hover:bg-stone-50 transition-colors duration-150 ${
isUnread ? 'bg-primary-50/30' : 'bg-white'
className={`w-full p-3 border-b border-stone-100 dark:border-neutral-800 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors duration-150 ${
isUnread ? 'bg-primary-50/30' : 'bg-white dark:bg-neutral-900'
}`}>
<div className="flex items-start gap-3">
{/* Unread dot — reserve space so text stays aligned whether read or unread */}
@@ -107,21 +107,27 @@ const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }
</span>
)}
<span className="ml-auto text-[11px] text-stone-400 flex-shrink-0">
<span className="ml-auto text-[11px] text-stone-400 dark:text-neutral-500 flex-shrink-0">
{relativeTime(n.received_at)}
</span>
</div>
{/* Title */}
<p className="text-sm font-medium text-stone-900 truncate">{n.title}</p>
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{n.title}
</p>
{/* Body preview */}
{n.body && <p className="text-xs text-stone-500 mt-0.5 line-clamp-2">{n.body}</p>}
{n.body && (
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
</button>
{onDismiss && (
<button
onClick={() => onDismiss(n.id)}
className="mt-0.5 ml-1 flex-shrink-0 p-0.5 rounded hover:bg-stone-200 text-stone-400 hover:text-stone-600 transition-colors"
className="mt-0.5 ml-1 flex-shrink-0 p-0.5 rounded hover:bg-stone-200 dark:hover:bg-neutral-800/60 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors"
aria-label={t('notifications.card.dismiss')}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -121,9 +121,9 @@ const NotificationCenter = () => {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-stone-200">
<div className="flex items-center justify-between px-4 py-3 border-b border-stone-200 dark:border-neutral-800">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-stone-900">
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('notifications.center.title')}
</h2>
{filteredUnreadCount > 0 && (
@@ -145,13 +145,13 @@ const NotificationCenter = () => {
{/* Provider filter pills */}
{allProviders.length > 1 && (
<div className="flex items-center gap-2 px-4 py-2 border-b border-stone-100 overflow-x-auto">
<div className="flex items-center gap-2 px-4 py-2 border-b border-stone-100 dark:border-neutral-800 overflow-x-auto">
<button
onClick={() => setSelectedProvider(undefined)}
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
selectedProvider === undefined
? 'bg-primary-500 text-white'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-800/60'
}`}>
{t('notifications.center.filterAll')}
</button>
@@ -162,7 +162,7 @@ const NotificationCenter = () => {
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
selectedProvider === p
? 'bg-primary-500 text-white'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-800/60'
}`}>
{p}
</button>
@@ -173,7 +173,7 @@ const NotificationCenter = () => {
{/* Content */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center py-12 text-stone-400 text-sm">
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500 text-sm">
{t('common.loading')}
</div>
)}
@@ -185,7 +185,7 @@ const NotificationCenter = () => {
)}
{!loading && !error && visibleItems.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-stone-400">
<div className="flex flex-col items-center justify-center py-16 text-stone-400 dark:text-neutral-500">
<svg
className="w-10 h-10 mb-3 opacity-40"
fill="none"
+12 -9
View File
@@ -48,27 +48,30 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
id: 'google',
name: 'Google',
icon: GoogleIcon,
color: 'bg-white border border-gray-200',
hoverColor: 'hover:bg-gray-50 hover:border-gray-300',
textColor: 'text-gray-900',
color: 'bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700',
hoverColor:
'hover:bg-gray-50 hover:border-gray-300 dark:hover:bg-neutral-800 dark:hover:border-neutral-600',
textColor: 'text-gray-900 dark:text-neutral-100',
showOnWelcome: true,
},
{
id: 'github',
name: 'GitHub',
icon: GitHubIcon,
color: 'bg-white border border-gray-200',
hoverColor: 'hover:bg-gray-50 hover:border-gray-300',
textColor: 'text-gray-900',
color: 'bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700',
hoverColor:
'hover:bg-gray-50 hover:border-gray-300 dark:hover:bg-neutral-800 dark:hover:border-neutral-600',
textColor: 'text-gray-900 dark:text-neutral-100',
showOnWelcome: true,
},
{
id: 'twitter',
name: 'Twitter',
icon: TwitterIcon,
color: 'bg-white border border-gray-200',
hoverColor: 'hover:bg-gray-50 hover:border-gray-300',
textColor: 'text-gray-900',
color: 'bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700',
hoverColor:
'hover:bg-gray-50 hover:border-gray-300 dark:hover:bg-neutral-800 dark:hover:border-neutral-600',
textColor: 'text-gray-900 dark:text-neutral-100',
showOnWelcome: true,
},
{
@@ -14,11 +14,11 @@ function formatUsd(n: number): string {
function statusBadgeClass(status: ReferralRelationshipStatus): string {
switch (status) {
case 'converted':
return 'bg-sage-100 text-sage-800';
return 'bg-sage-100 dark:bg-sage-500/20 text-sage-800 dark:text-sage-200';
case 'expired':
return 'bg-stone-100 text-stone-600';
return 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300';
default:
return 'bg-amber-50 text-amber-800';
return 'bg-amber-50 dark:bg-amber-500/10 text-amber-800 dark:text-amber-200';
}
}
@@ -163,23 +163,25 @@ const ReferralRewardsSection = () => {
return (
<div className="space-y-4">
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-6">
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-6 space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-stone-900">
<h2 className="text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{t('rewards.referralSection.title')}
</h2>
<p className="text-sm text-stone-600 max-w-xl">
<p className="text-sm text-stone-600 dark:text-neutral-300 max-w-xl">
{t('rewards.referralSection.subtitle')}
</p>
</div>
</div>
{loading && !stats ? (
<p className="text-sm text-stone-500">{t('rewards.referralSection.loading')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('rewards.referralSection.loading')}
</p>
) : null}
{loadError ? (
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-800 dark:text-coral-200">
{loadError}
<button
type="button"
@@ -193,35 +195,35 @@ const ReferralRewardsSection = () => {
{stats ? (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('rewards.referralSection.yourCode')}
</div>
<div className="mt-2 font-mono text-lg font-semibold text-stone-900 break-all">
<div className="mt-2 font-mono text-lg font-semibold text-stone-900 dark:text-neutral-100 break-all">
{stats.referralCode || '—'}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('rewards.referralSection.totalEarned')}
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
<div className="mt-2 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{formatUsd(stats.totals.totalRewardUsd)}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('rewards.referralSection.pendingReferrals')}
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
<div className="mt-2 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{stats.totals.pendingCount}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('rewards.referralSection.completed')}
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
<div className="mt-2 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{stats.totals.convertedCount}
</div>
</div>
@@ -239,11 +241,13 @@ const ReferralRewardsSection = () => {
type="button"
onClick={() => void handleShare()}
disabled={!referralCodeToCopy}
className="inline-flex items-center justify-center rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50 disabled:opacity-50">
className="inline-flex items-center justify-center rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3 text-sm font-medium text-stone-700 dark:text-neutral-200 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
{t('rewards.referralSection.share')}
</button>
{copyHint ? (
<span className="self-center text-sm text-sage-600">{copyHint}</span>
<span className="self-center text-sm text-sage-600 dark:text-sage-300">
{copyHint}
</span>
) : null}
</div>
</>
@@ -251,11 +255,13 @@ const ReferralRewardsSection = () => {
</div>
{stats && stats.canApplyReferral !== false && showApplyForm ? (
<div className="rounded-xl shadow-soft border border-stone-200 bg-white p-4 space-y-3">
<h2 className="text-2xl font-semibold text-stone-900">
<div className="rounded-xl shadow-soft border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h2 className="text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{t('rewards.referralSection.haveCode')}
</h2>
<p className="text-xs text-stone-600">{t('rewards.referralSection.haveCodeDesc')}</p>
<p className="text-xs text-stone-600 dark:text-neutral-300">
{t('rewards.referralSection.haveCodeDesc')}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="text"
@@ -264,7 +270,7 @@ const ReferralRewardsSection = () => {
onKeyDown={e => e.key === 'Enter' && void handleApply()}
placeholder={t('rewards.referralSection.placeholder')}
disabled={applyLoading}
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 font-mono text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
/>
<button
type="button"
@@ -276,12 +282,14 @@ const ReferralRewardsSection = () => {
: t('rewards.referralSection.apply')}
</button>
</div>
{applyError ? <p className="text-xs text-coral-600">{applyError}</p> : null}
{applyError ? (
<p className="text-xs text-coral-600 dark:text-coral-300">{applyError}</p>
) : null}
</div>
) : null}
{stats && (hasAppliedFromStats || hasAppliedFromProfile || applySuccess) && !showApplyForm ? (
<p className="text-sm text-sage-700 rounded-xl border border-sage-200 bg-sage-50 px-3 py-2">
<p className="text-sm text-sage-700 dark:text-sage-300 rounded-xl border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 px-3 py-2">
{t('rewards.referralSection.linked')}
{stats.appliedReferralCode
? ' ' +
@@ -292,19 +300,19 @@ const ReferralRewardsSection = () => {
) : null}
{stats ? (
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-6">
<div>
<h3 className="text-sm font-semibold text-stone-900 mb-2">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('rewards.referralSection.activity')}
</h3>
{stats.referrals.length === 0 ? (
<p className="text-sm text-stone-500 rounded-xl border border-dashed border-stone-200 px-4 py-6 text-center">
<p className="text-sm text-stone-500 dark:text-neutral-400 rounded-xl border border-dashed border-stone-200 dark:border-neutral-800 px-4 py-6 text-center">
{t('rewards.referralSection.noReferrals')}
</p>
) : (
<div className="overflow-x-auto rounded-xl border border-stone-200">
<div className="overflow-x-auto rounded-xl border border-stone-200 dark:border-neutral-800">
<table className="min-w-full text-sm text-left">
<thead className="bg-stone-50 text-xs uppercase tracking-wide text-stone-500">
<thead className="bg-stone-50 dark:bg-neutral-800/60 text-xs uppercase tracking-wide text-stone-500 dark:text-neutral-400">
<tr>
<th className="px-3 py-2 font-medium">
{t('rewards.referralSection.colReferredUser')}
@@ -320,10 +328,12 @@ const ReferralRewardsSection = () => {
</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
<tbody className="divide-y divide-stone-100 dark:divide-neutral-800">
{stats.referrals.map((row, idx) => (
<tr key={row.id ?? row.referredUserId ?? idx} className="bg-white">
<td className="px-3 py-2 font-mono text-stone-800">
<tr
key={row.id ?? row.referredUserId ?? idx}
className="bg-white dark:bg-neutral-900">
<td className="px-3 py-2 font-mono text-stone-800 dark:text-neutral-100">
{row.referredUserMasked || row.referredDisplayName || '—'}
</td>
<td className="px-3 py-2">
@@ -336,12 +346,12 @@ const ReferralRewardsSection = () => {
: t('rewards.referralSection.statusJoined')}
</span>
</td>
<td className="px-3 py-2 text-stone-700">
<td className="px-3 py-2 text-stone-700 dark:text-neutral-200">
{row.rewardUsd != null && row.rewardUsd > 0
? formatUsd(row.rewardUsd)
: '—'}
</td>
<td className="px-3 py-2 text-stone-500 text-xs">
<td className="px-3 py-2 text-stone-500 dark:text-neutral-400 text-xs">
{row.status === 'converted' && row.convertedAt
? new Date(row.convertedAt).toLocaleString()
: row.createdAt
@@ -13,10 +13,22 @@ function formatNumber(value: number): string {
function roleAccentTone(index: number) {
const tones = [
{ iconBg: 'bg-amber-50', iconText: 'text-amber-600', iconBorder: 'border-amber-100' },
{ iconBg: 'bg-blue-50', iconText: 'text-primary-600', iconBorder: 'border-blue-100' },
{
iconBg: 'bg-amber-50 dark:bg-amber-500/10',
iconText: 'text-amber-600 dark:text-amber-300',
iconBorder: 'border-amber-100',
},
{
iconBg: 'bg-blue-50 dark:bg-blue-500/10',
iconText: 'text-primary-600 dark:text-primary-300',
iconBorder: 'border-blue-100',
},
{ iconBg: 'bg-slate-100', iconText: 'text-slate-600', iconBorder: 'border-slate-200' },
{ iconBg: 'bg-emerald-50', iconText: 'text-emerald-600', iconBorder: 'border-emerald-100' },
{
iconBg: 'bg-emerald-50 dark:bg-emerald-500/10',
iconText: 'text-emerald-600 dark:text-emerald-300',
iconBorder: 'border-emerald-100',
},
] as const;
return tones[index % tones.length];
@@ -95,7 +107,7 @@ export default function RewardsCommunityTab({
<div className="flex flex-col gap-2 sm:flex-row">
<button
onClick={() => navigate('/settings/messaging')}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white px-4 py-3 text-sm font-semibold text-primary-700 shadow-lg transition-transform active:scale-[0.98]">
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white dark:bg-neutral-900 px-4 py-3 text-sm font-semibold text-primary-700 dark:text-primary-300 shadow-lg transition-transform active:scale-[0.98]">
<svg
className="w-4 h-4"
fill="none"
@@ -131,7 +143,7 @@ export default function RewardsCommunityTab({
<div
role="alert"
data-testid="rewards-error"
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
<span>
{t('rewards.community.syncUnavailable')} {error}
</span>
@@ -141,7 +153,7 @@ export default function RewardsCommunityTab({
data-testid="rewards-retry"
onClick={onRetry}
disabled={isLoading}
className="rounded-full border border-amber-300 bg-white px-3 py-1 text-xs font-semibold text-amber-800 shadow-sm transition-colors hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60">
className="rounded-full border border-amber-300 dark:border-amber-500/40 bg-white dark:bg-neutral-900 px-3 py-1 text-xs font-semibold text-amber-800 dark:text-amber-200 shadow-sm transition-colors hover:bg-amber-100 dark:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-60">
{isLoading ? t('rewards.community.retrying') : t('rewards.community.tryAgain')}
</button>
) : null}
@@ -149,13 +161,13 @@ export default function RewardsCommunityTab({
) : null}
<div className="space-y-4">
<section className="rounded-[1.25rem] bg-white p-6 shadow-[0_4px_20px_rgba(25,28,30,0.04)]">
<section className="rounded-[1.25rem] bg-white dark:bg-neutral-900 p-6 shadow-[0_4px_20px_rgba(25,28,30,0.04)]">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-bold text-stone-900">
<h2 className="text-lg font-bold text-stone-900 dark:text-neutral-100">
{t('rewards.community.yourProgress')}
</h2>
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{isLoading
? t('rewards.community.loadingRewards')
: t('rewards.community.achievementsUnlocked')
@@ -183,10 +195,12 @@ export default function RewardsCommunityTab({
strokeWidth="4"
strokeDasharray={ringCircumference}
strokeDashoffset={ringOffset}
className="text-primary-600 transition-all duration-300"
className="text-primary-600 dark:text-primary-300 transition-all duration-300"
/>
</svg>
<span className="absolute text-sm font-bold text-stone-900">{progressPercent}%</span>
<span className="absolute text-sm font-bold text-stone-900 dark:text-neutral-100">
{progressPercent}%
</span>
</div>
</div>
@@ -196,8 +210,8 @@ export default function RewardsCommunityTab({
key={role?.id ?? `placeholder-${index}`}
className={`flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full border-2 ${
role?.unlocked
? 'border-primary-200 bg-primary-50 text-primary-600'
: 'border-dashed border-stone-300 bg-stone-100 text-stone-400'
? 'border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
: 'border-dashed border-stone-300 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 text-stone-400 dark:text-neutral-500'
}`}>
<svg className="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
{roleGlyph(index)}
@@ -209,13 +223,15 @@ export default function RewardsCommunityTab({
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-stone-900">
<h2 className="text-lg font-bold text-stone-900 dark:text-neutral-100">
{t('rewards.community.rolesAndRewards')}
</h2>
</div>
{isLoading ? (
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
<div className="text-sm text-stone-600">{t('rewards.community.loadingRewards')}</div>
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-soft">
<div className="text-sm text-stone-600 dark:text-neutral-300">
{t('rewards.community.loadingRewards')}
</div>
</div>
) : rewardRoles.length > 0 ? (
rewardRoles.map((role, index) => {
@@ -224,7 +240,7 @@ export default function RewardsCommunityTab({
return (
<div
key={role.id}
className={`rounded-[1.25rem] bg-white p-5 shadow-sm transition-shadow hover:shadow-md ${
className={`rounded-[1.25rem] bg-white dark:bg-neutral-900 p-5 shadow-sm transition-shadow hover:shadow-md ${
role.unlocked ? 'ring-1 ring-primary-100' : 'ring-1 ring-black/[0.04]'
}`}>
<div className="flex items-start justify-between gap-4">
@@ -236,13 +252,15 @@ export default function RewardsCommunityTab({
</svg>
</div>
<div>
<h3 className="text-base font-bold text-stone-900">{role.title}</h3>
<p className="mt-1 text-xs leading-relaxed text-stone-600">
<h3 className="text-base font-bold text-stone-900 dark:text-neutral-100">
{role.title}
</h3>
<p className="mt-1 text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
{role.description}
</p>
</div>
</div>
<div className="flex items-center gap-1 text-primary-700">
<div className="flex items-center gap-1 text-primary-700 dark:text-primary-300">
<span className="text-[10px] font-bold uppercase tracking-[0.16em]">
{role.unlocked
? t('rewards.community.unlocked')
@@ -265,21 +283,21 @@ export default function RewardsCommunityTab({
);
})
) : (
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-soft">
<h2 className="text-lg font-semibold text-stone-900">
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-soft">
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{t('rewards.community.syncPending')}
</h2>
<p className="mt-2 text-sm text-stone-600">
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
{t('rewards.community.syncPendingDesc')}
</p>
</div>
)}
</section>
<section className="rounded-[1.25rem] bg-[#f2f4f6] p-4 text-sm text-stone-600">
<section className="rounded-[1.25rem] bg-[#f2f4f6] p-4 text-sm text-stone-600 dark:text-neutral-300">
<div className="flex items-center justify-between gap-3">
<span>{t('rewards.community.discordServer')}</span>
<span className="font-semibold text-stone-900">
<span className="font-semibold text-stone-900 dark:text-neutral-100">
{!snapshot
? t('rewards.community.discordWaiting')
: snapshot.discord.membershipStatus === 'member'
@@ -293,7 +311,7 @@ export default function RewardsCommunityTab({
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span>{t('rewards.community.currentStreak')}</span>
<span className="font-semibold text-stone-900">
<span className="font-semibold text-stone-900 dark:text-neutral-100">
{snapshot
? t('rewards.community.streakDays').replace(
'{n}',
@@ -304,7 +322,7 @@ export default function RewardsCommunityTab({
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span>{t('rewards.community.cumulativeTokens')}</span>
<span className="font-semibold text-stone-900">
<span className="font-semibold text-stone-900 dark:text-neutral-100">
{snapshot
? formatNumber(snapshot.metrics.cumulativeTokens)
: t('rewards.community.unknown')}
@@ -19,9 +19,10 @@ function formatDateTime(value: string | null, pendingLabel: string): string {
}
function redemptionStatusClass(coupon: RedeemedCoupon): string {
if (coupon.fulfilled) return 'bg-sage-100 text-sage-700';
if (coupon.activationType === 'CONDITIONAL') return 'bg-amber-50 text-amber-800';
return 'bg-stone-100 text-stone-700';
if (coupon.fulfilled) return 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300';
if (coupon.activationType === 'CONDITIONAL')
return 'bg-amber-50 dark:bg-amber-500/10 text-amber-800 dark:text-amber-200';
return 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200';
}
const RewardsCouponSection = () => {
@@ -139,26 +140,30 @@ const RewardsCouponSection = () => {
return (
<>
<section className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-5">
<section className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-6 space-y-5">
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-stone-900">{t('rewards.coupon.title')}</h2>
<p className="max-w-2xl text-sm text-stone-600">{t('rewards.coupon.subtitle')}</p>
<h2 className="text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{t('rewards.coupon.title')}
</h2>
<p className="max-w-2xl text-sm text-stone-600 dark:text-neutral-300">
{t('rewards.coupon.subtitle')}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('rewards.coupon.promoCredits')}
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
<div className="mt-2 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('rewards.coupon.redeemedCodes')}
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
<div className="mt-2 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{redeemedCoupons.length}
</div>
</div>
@@ -181,7 +186,7 @@ const RewardsCouponSection = () => {
}}
placeholder={t('rewards.coupon.placeholder')}
disabled={submitLoading}
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 font-mono text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
/>
<button
type="button"
@@ -192,17 +197,17 @@ const RewardsCouponSection = () => {
</button>
</div>
{submitSuccess ? (
<div className="rounded-xl border border-sage-200 bg-sage-50 px-3 py-2 text-sm text-sage-800">
<div className="rounded-xl border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 px-3 py-2 text-sm text-sage-800 dark:text-sage-200">
{submitSuccess}
</div>
) : null}
{submitError ? (
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-800 dark:text-coral-200">
{submitError}
</div>
) : null}
{loadError ? (
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-800 dark:text-coral-200">
{loadError}
<button
type="button"
@@ -214,33 +219,35 @@ const RewardsCouponSection = () => {
) : null}
</div>
</section>
<section className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-5">
<section className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-6 space-y-5">
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('rewards.coupon.recentRedemptions')}
</h3>
<button
type="button"
onClick={() => void loadCouponState()}
disabled={loading}
className="text-xs font-medium text-stone-500 transition-colors hover:text-stone-700 disabled:opacity-50">
className="text-xs font-medium text-stone-500 dark:text-neutral-400 transition-colors hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-50">
{t('common.refresh')}
</button>
</div>
{loading && redeemedCoupons.length === 0 ? (
<p className="text-sm text-stone-500">{t('rewards.coupon.loadingHistory')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('rewards.coupon.loadingHistory')}
</p>
) : null}
{redeemedCoupons.length === 0 && !loading && !loadError ? (
<p className="text-sm text-stone-500 rounded-xl border border-dashed border-stone-200 px-4 py-6 text-center">
<p className="text-sm text-stone-500 dark:text-neutral-400 rounded-xl border border-dashed border-stone-200 dark:border-neutral-800 px-4 py-6 text-center">
{t('rewards.coupon.noCodes')}
</p>
) : redeemedCoupons.length > 0 ? (
<div className="overflow-x-auto rounded-xl border border-stone-200">
<div className="overflow-x-auto rounded-xl border border-stone-200 dark:border-neutral-800">
<table className="min-w-full text-sm text-left">
<thead className="bg-stone-50 text-xs uppercase tracking-wide text-stone-500">
<thead className="bg-stone-50 dark:bg-neutral-800/60 text-xs uppercase tracking-wide text-stone-500 dark:text-neutral-400">
<tr>
<th className="px-3 py-2 font-medium">{t('rewards.coupon.colCode')}</th>
<th className="px-3 py-2 font-medium">{t('rewards.coupon.colReward')}</th>
@@ -248,13 +255,17 @@ const RewardsCouponSection = () => {
<th className="px-3 py-2 font-medium">{t('rewards.coupon.colRedeemed')}</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
<tbody className="divide-y divide-stone-100 dark:divide-neutral-800">
{redeemedCoupons.map(coupon => (
<tr
key={`${coupon.code}-${coupon.redeemedAt ?? coupon.activationType}`}
className="bg-white">
<td className="px-3 py-2 font-mono text-stone-800">{coupon.code}</td>
<td className="px-3 py-2 text-stone-700">{formatUsd(coupon.amountUsd)}</td>
className="bg-white dark:bg-neutral-900">
<td className="px-3 py-2 font-mono text-stone-800 dark:text-neutral-100">
{coupon.code}
</td>
<td className="px-3 py-2 text-stone-700 dark:text-neutral-200">
{formatUsd(coupon.amountUsd)}
</td>
<td className="px-3 py-2">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${redemptionStatusClass(coupon)}`}>
@@ -265,7 +276,7 @@ const RewardsCouponSection = () => {
: t('rewards.coupon.statusRedeemed')}
</span>
</td>
<td className="px-3 py-2 text-xs text-stone-500">
<td className="px-3 py-2 text-xs text-stone-500 dark:text-neutral-400">
{formatDateTime(coupon.redeemedAt, t('rewards.coupon.pending'))}
</td>
</tr>
+25 -7
View File
@@ -126,6 +126,22 @@ const SettingsHome = () => {
),
rightElement: <LanguageSelect ariaLabel={t('settings.language')} />,
},
{
id: 'appearance',
title: 'Appearance',
description: 'Pick light, dark, or match your system theme',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
),
onClick: () => navigateToSettings('appearance'),
},
{
id: 'mascot',
title: 'Mascot',
@@ -261,9 +277,9 @@ const SettingsHome = () => {
{/* Log Out & Clear Data Confirmation Modal */}
{showLogoutAndClearModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30">
<div className="bg-white rounded-2xl max-w-md w-full p-6 border border-stone-200">
<div className="bg-white dark:bg-neutral-900 rounded-2xl max-w-md w-full p-6 border border-stone-200 dark:border-neutral-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center">
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-500/20 flex items-center justify-center">
<svg
className="w-5 h-5 text-amber-400"
fill="none"
@@ -278,12 +294,14 @@ const SettingsHome = () => {
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-stone-900">{t('clearData.title')}</h3>
<h3 className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{t('clearData.title')}
</h3>
</div>
</div>
<div className="mb-6">
<div className="text-stone-700 text-sm leading-relaxed">
<div className="text-stone-700 dark:text-neutral-200 text-sm leading-relaxed">
<p>{t('clearData.warning')}</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>{t('clearData.bulletSettings')}</li>
@@ -295,8 +313,8 @@ const SettingsHome = () => {
</div>
{error && (
<div className="mt-3 p-3 rounded-lg bg-coral-100 border border-coral-500/20">
<p className="text-coral-600 text-sm">{error}</p>
<div className="mt-3 p-3 rounded-lg bg-coral-100 dark:bg-coral-500/20 border border-coral-500/20">
<p className="text-coral-600 dark:text-coral-300 text-sm">{error}</p>
</div>
)}
</div>
@@ -308,7 +326,7 @@ const SettingsHome = () => {
setError(null);
}}
disabled={isLoading}
className="flex-1 px-4 py-2 rounded-lg border border-stone-200 text-stone-700 hover:bg-stone-100 transition-colors disabled:opacity-50">
className="flex-1 px-4 py-2 rounded-lg border border-stone-200 dark:border-neutral-800 text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
@@ -31,7 +31,11 @@ const SettingsSectionPage = ({ title, description, items }: SettingsSectionPageP
/>
<div>
{description && <p className="mt-1 text-xs text-stone-500 px-5 pb-3">{description}</p>}
{description && (
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 px-5 pb-3">
{description}
</p>
)}
<div>
{items.map((item, index) => (
@@ -12,7 +12,7 @@ const PageBackButton = ({ label, onClick, trailingContent }: PageBackButtonProps
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 shadow-sm transition-colors hover:bg-stone-50">
className="inline-flex items-center gap-2 rounded-full border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-neutral-200 shadow-sm transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
@@ -29,10 +29,10 @@ const SettingsHeader = ({
{showBackButton && onBack && (
<button
onClick={onBack}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 transition-colors mr-2"
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2"
aria-label={t('common.back')}>
<svg
className="w-4 h-4 text-stone-500"
className="w-4 h-4 text-stone-500 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -55,15 +55,17 @@ const SettingsHeader = ({
{crumb.onClick ? (
<button
onClick={crumb.onClick}
className="text-xs text-stone-400 hover:text-stone-600 transition-colors">
className="text-xs text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:text-neutral-300 dark:hover:text-neutral-300 transition-colors">
{crumb.label}
</button>
) : (
<span className="text-xs text-stone-400">{crumb.label}</span>
<span className="text-xs text-stone-400 dark:text-neutral-500">
{crumb.label}
</span>
)}
<svg
aria-hidden="true"
className="w-3 h-3 text-stone-300"
className="w-3 h-3 text-stone-300 dark:text-neutral-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -81,7 +83,9 @@ const SettingsHeader = ({
)}
{/* Title */}
<h2 className="text-sm font-semibold text-stone-900">{title ?? t('nav.settings')}</h2>
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{title ?? t('nav.settings')}
</h2>
</div>
</div>
);
@@ -22,9 +22,13 @@ const SettingsMenuItem = ({
rightElement,
}: SettingsMenuItemProps) => {
// Color variations for dangerous items (like logout/delete)
const titleColor = dangerous ? 'text-amber-600' : 'text-stone-900';
const iconColor = dangerous ? 'text-amber-600' : 'text-stone-900';
const borderColor = 'border-stone-200'; // Use consistent border color for all items
const titleColor = dangerous
? 'text-amber-600 dark:text-amber-300'
: 'text-stone-900 dark:text-neutral-100';
const iconColor = dangerous
? 'text-amber-600 dark:text-amber-300'
: 'text-stone-900 dark:text-neutral-100';
const borderColor = 'border-stone-200 dark:border-neutral-800';
// Border classes for first/last items
const borderClasses = isLast ? '' : `border-b ${borderColor}`;
@@ -46,7 +50,7 @@ const SettingsMenuItem = ({
<button
type="button"
onClick={onClick}
className={`w-full flex items-center justify-between py-3 px-4 bg-white ${borderClasses} hover:bg-stone-50 transition-all duration-200 text-left ${roundedClasses} focus:outline-none focus:ring-0 focus:border-inherit`}>
className={`w-full flex items-center justify-between py-3 px-4 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 ${borderClasses} hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 transition-all duration-200 text-left ${roundedClasses} focus:outline-none focus:ring-0 focus:border-inherit`}>
{content}
</button>
);
@@ -54,7 +58,7 @@ const SettingsMenuItem = ({
return (
<div
className={`w-full flex items-center justify-between py-3 px-4 bg-white ${borderClasses} ${roundedClasses}`}>
className={`w-full flex items-center justify-between py-3 px-4 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 ${borderClasses} ${roundedClasses}`}>
{content}
</div>
);
@@ -32,6 +32,7 @@ export type SettingsRoute =
| 'notifications'
| 'notification-routing'
| 'mascot'
| 'appearance'
| 'intelligence'
| 'webhooks-triggers'
| 'composio-triggers'
@@ -111,6 +112,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/notification-routing')) return 'notification-routing';
if (path.includes('/settings/notifications')) return 'notifications';
if (path.includes('/settings/mascot')) return 'mascot';
if (path.includes('/settings/appearance')) return 'appearance';
return 'home';
};
@@ -231,6 +233,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'mascot':
return [settingsCrumb];
// Appearance (theme) panel sits at the top level of Settings.
case 'appearance':
return [settingsCrumb];
case 'home':
default:
return [];
+191 -131
View File
@@ -87,11 +87,26 @@ type RoutingMap = Record<WorkloadId, ProviderRef>;
// Slug-keyed display metadata for built-in provider slugs. Used only for
// chip rendering (label, tone). Custom providers use `provider.label` directly.
const BUILTIN_PROVIDER_META: Record<string, { tone: string; label: string }> = {
openhuman: { label: 'OpenHuman', tone: 'bg-primary-50 ring-primary-200 text-primary-900' },
openai: { label: 'OpenAI', tone: 'bg-emerald-50 ring-emerald-200 text-emerald-900' },
anthropic: { label: 'Anthropic', tone: 'bg-orange-50 ring-orange-200 text-orange-900' },
openrouter: { label: 'OpenRouter', tone: 'bg-slate-100 ring-slate-300 text-slate-900' },
custom: { label: 'Custom', tone: 'bg-stone-100 ring-stone-300 text-stone-900' },
openhuman: {
label: 'OpenHuman',
tone: 'bg-primary-50 dark:bg-primary-500/10 ring-primary-200 text-primary-900 dark:text-primary-100',
},
openai: {
label: 'OpenAI',
tone: 'bg-emerald-50 dark:bg-emerald-500/10 ring-emerald-200 text-emerald-900 dark:text-emerald-100',
},
anthropic: {
label: 'Anthropic',
tone: 'bg-orange-50 dark:bg-orange-500/10 ring-orange-200 text-orange-900 dark:text-orange-100',
},
openrouter: {
label: 'OpenRouter',
tone: 'bg-slate-100 dark:bg-slate-500/15 ring-slate-300 text-slate-900 dark:text-slate-100',
},
custom: {
label: 'Custom',
tone: 'bg-stone-100 dark:bg-neutral-800 ring-stone-300 text-stone-900 dark:text-neutral-100',
},
};
const WORKLOADS: Workload[] = [
@@ -361,14 +376,17 @@ type LocalChipSlug = 'lmstudio' | 'ollama';
// Tints per local-runtime chip slug.
const LOCAL_CHIP_TONE: Record<LocalChipSlug, string> = {
lmstudio: 'bg-cyan-50 ring-cyan-200 text-cyan-900',
ollama: 'bg-violet-50 ring-violet-200 text-violet-900',
lmstudio: 'bg-cyan-50 dark:bg-cyan-500/10 ring-cyan-200 text-cyan-900 dark:text-cyan-100',
ollama: 'bg-violet-50 dark:bg-violet-500/10 ring-violet-200 text-violet-900 dark:text-violet-100',
};
const LOCAL_CHIP_LABEL: Record<LocalChipSlug, string> = { lmstudio: 'LM Studio', ollama: 'Ollama' };
function slugTone(slug: string): string {
return BUILTIN_PROVIDER_META[slug]?.tone ?? 'bg-stone-100 ring-stone-300 text-stone-900';
return (
BUILTIN_PROVIDER_META[slug]?.tone ??
'bg-stone-100 dark:bg-neutral-800 ring-stone-300 text-stone-900 dark:text-neutral-100'
);
}
const ProviderToggleChip = ({
@@ -387,7 +405,7 @@ const ProviderToggleChip = ({
const tone = slugTone(slug);
return (
<div
className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors ${tone}`}>
className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors dark:ring-neutral-700 ${tone}`}>
<span>{label}</span>
<button
type="button"
@@ -396,10 +414,10 @@ const ProviderToggleChip = ({
aria-label={`${enabled ? 'Disconnect' : 'Connect'} ${label}`}
disabled={busy}
onClick={onToggle}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 ${enabled ? 'bg-primary-500' : 'bg-stone-300'}`}>
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 ${enabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-700'}`}>
<span
aria-hidden
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`}
className={`inline-block h-3 w-3 transform rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`}
/>
</button>
</div>
@@ -458,14 +476,18 @@ const ProviderKeyDialog = ({
aria-modal="true"
aria-label={`Connect ${label}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
<div className="w-full max-w-md rounded-2xl border border-stone-200 bg-white p-6 shadow-soft">
<div className="w-full max-w-md rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft">
<div className="mb-4">
<h3 className="text-base font-semibold text-stone-900">{`${t('settings.ai.connectProvider')} ${label}`}</h3>
<p className="mt-0.5 text-xs text-stone-500">{t('settings.ai.apiKeyStoredEncrypted')}</p>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">{`${t('settings.ai.connectProvider')} ${label}`}</h3>
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('settings.ai.apiKeyStoredEncrypted')}
</p>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="provider-key-input" className="text-xs font-medium text-stone-700">
<label
htmlFor="provider-key-input"
className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('settings.ai.apiKeyFieldLabel')}
</label>
<input
@@ -485,9 +507,11 @@ const ProviderKeyDialog = ({
setApiKey(e.target.value);
setError(null);
}}
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60"
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60"
/>
{error ? <p className="text-xs font-medium text-red-600">{error}</p> : null}
{error ? (
<p className="text-xs font-medium text-red-600 dark:text-red-300">{error}</p>
) : null}
</div>
<div className="mt-6 flex justify-end gap-2">
@@ -495,7 +519,7 @@ const ProviderKeyDialog = ({
type="button"
onClick={onCancel}
disabled={busy}
className="rounded-lg border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
{t('common.cancel')}
</button>
<button
@@ -635,10 +659,10 @@ const LoopToggle = ({
busy: boolean;
onToggle: () => void;
}) => (
<div className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 bg-white px-3 py-2">
<div className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium text-stone-900">{label}</div>
<div className="text-xs text-stone-500">{description}</div>
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">{label}</div>
<div className="text-xs text-stone-500 dark:text-neutral-400">{description}</div>
</div>
<button
type="button"
@@ -647,10 +671,10 @@ const LoopToggle = ({
aria-checked={checked}
disabled={busy}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 ${checked ? 'bg-primary-500' : 'bg-stone-300'}`}>
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 ${checked ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-700'}`}>
<span
aria-hidden
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${checked ? 'translate-x-4' : 'translate-x-0.5'}`}
className={`inline-block h-4 w-4 transform rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${checked ? 'translate-x-4' : 'translate-x-0.5'}`}
/>
</button>
</div>
@@ -665,20 +689,24 @@ const MetricTile = ({
value: string;
detail?: string;
}) => (
<div className="rounded-md bg-stone-50 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400">{label}</div>
<div className="mt-1 text-sm font-semibold text-stone-900">{value}</div>
{detail ? <div className="mt-0.5 text-[11px] text-stone-500">{detail}</div> : null}
<div className="rounded-md bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{label}
</div>
<div className="mt-1 text-sm font-semibold text-stone-900 dark:text-neutral-100">{value}</div>
{detail ? (
<div className="mt-0.5 text-[11px] text-stone-500 dark:text-neutral-400">{detail}</div>
) : null}
</div>
);
const FormulaRow = ({ label, value, detail }: { label: string; value: string; detail: string }) => (
<div className="rounded-md border border-stone-200 bg-white px-3 py-2">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-stone-800">{label}</span>
<span className="font-mono text-xs text-stone-600">{value}</span>
<span className="text-xs font-medium text-stone-800 dark:text-neutral-100">{label}</span>
<span className="font-mono text-xs text-stone-600 dark:text-neutral-300">{value}</span>
</div>
<div className="mt-1 text-[11px] text-stone-500">{detail}</div>
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">{detail}</div>
</div>
);
@@ -898,27 +926,31 @@ const BackgroundLoopControls = ({
return (
<div className="space-y-4">
<div className="border-b border-stone-200 pb-2">
<h2 className="text-base font-semibold text-stone-900">Background loops</h2>
<p className="mt-0.5 text-xs text-stone-500">
<div className="border-b border-stone-200 dark:border-neutral-800 pb-2">
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
Background loops
</h2>
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
See what runs without a chat message, pause heartbeat work, and inspect recent credit
ledger rows.
</p>
</div>
{error && (
<div className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
<div className="rounded-md border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
<section className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(300px,0.8fr)]">
<div className="space-y-3">
<div className="rounded-lg border border-stone-200 bg-stone-50 p-3">
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-stone-900">Heartbeat controls</div>
<div className="text-xs text-stone-500">
<div className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Heartbeat controls
</div>
<div className="text-xs text-stone-500 dark:text-neutral-400">
Defaults off. Enabling starts the loop; disabling aborts the running task.
</div>
</div>
@@ -926,7 +958,7 @@ const BackgroundLoopControls = ({
type="button"
onClick={() => void refresh()}
disabled={loading}
className="rounded-md border border-stone-200 bg-white px-2 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50">
className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
Refresh
</button>
</div>
@@ -958,8 +990,8 @@ const BackgroundLoopControls = ({
void applyHeartbeatPatch({ notify_meetings: !settings.notify_meetings })
}
/>
<div className="grid gap-2 rounded-lg border border-stone-200 bg-white px-3 py-2 md:grid-cols-3">
<label className="space-y-1 text-xs font-medium text-stone-700">
<div className="grid gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 md:grid-cols-3">
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
<span>Calendar cap</span>
<select
value={maxCalendarConnectionsPerTick}
@@ -969,7 +1001,7 @@ const BackgroundLoopControls = ({
max_calendar_connections_per_tick: Number(e.target.value),
})
}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1 text-xs text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{[1, 2, 3, 5, 10].map(count => (
<option key={count} value={count}>
{count} conn/tick
@@ -977,7 +1009,7 @@ const BackgroundLoopControls = ({
))}
</select>
</label>
<label className="space-y-1 text-xs font-medium text-stone-700">
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
<span>Meeting lookahead</span>
<select
value={settings.meeting_lookahead_minutes}
@@ -987,7 +1019,7 @@ const BackgroundLoopControls = ({
meeting_lookahead_minutes: Number(e.target.value),
})
}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1 text-xs text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{[15, 30, 60, 120, 240].map(minutes => (
<option key={minutes} value={minutes}>
{minutes} min
@@ -995,7 +1027,7 @@ const BackgroundLoopControls = ({
))}
</select>
</label>
<label className="space-y-1 text-xs font-medium text-stone-700">
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
<span>Reminder lookahead</span>
<select
value={settings.reminder_lookahead_minutes}
@@ -1005,7 +1037,7 @@ const BackgroundLoopControls = ({
reminder_lookahead_minutes: Number(e.target.value),
})
}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1 text-xs text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{[5, 15, 30, 60, 120].map(minutes => (
<option key={minutes} value={minutes}>
{minutes} min
@@ -1046,9 +1078,9 @@ const BackgroundLoopControls = ({
}
/>
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-stone-200 bg-white px-3 py-2">
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
<label
className="text-xs font-medium text-stone-700"
className="text-xs font-medium text-stone-700 dark:text-neutral-200"
htmlFor="heartbeat-interval">
Interval
</label>
@@ -1059,7 +1091,7 @@ const BackgroundLoopControls = ({
onChange={e =>
void applyHeartbeatPatch({ interval_minutes: Number(e.target.value) })
}
className="rounded-md border border-stone-200 bg-white px-2 py-1 text-xs text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{[5, 10, 15, 30, 60].map(minutes => (
<option key={minutes} value={minutes}>
{minutes} min
@@ -1070,13 +1102,13 @@ const BackgroundLoopControls = ({
type="button"
onClick={() => void runPlannerNow()}
disabled={runningTick}
className="ml-auto rounded-md border border-stone-200 bg-white px-2 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50">
className="ml-auto rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
{runningTick ? 'Running...' : 'Planner tick now'}
</button>
</div>
{plannerSummary && (
<div className="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-xs text-primary-900">
<div className="rounded-md border border-primary-100 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-primary-900">
Planner: {plannerSummary.source_events} source events,{' '}
{plannerSummary.deliveries_sent} sent, {plannerSummary.deliveries_skipped_dedup}{' '}
deduped.
@@ -1084,32 +1116,34 @@ const BackgroundLoopControls = ({
)}
</div>
) : (
<div className="text-xs text-stone-500">
<div className="text-xs text-stone-500 dark:text-neutral-400">
{loading ? 'Loading heartbeat controls...' : 'Heartbeat controls unavailable.'}
</div>
)}
</div>
<div className="overflow-hidden rounded-lg border border-stone-200 bg-stone-50">
<div className="border-b border-stone-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-stone-400">
<div className="overflow-hidden rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60">
<div className="border-b border-stone-200 dark:border-neutral-800 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
Loop map
</div>
<div className="divide-y divide-stone-200">
<div className="divide-y divide-stone-200 dark:divide-neutral-800">
{loops.map(loop => (
<div key={loop.name} className="grid gap-2 px-3 py-3 md:grid-cols-[150px_1fr]">
<div>
<div className="text-sm font-medium text-stone-900">{loop.name}</div>
<div className="mt-0.5 flex flex-wrap gap-1 text-[11px] text-stone-500">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{loop.name}
</div>
<div className="mt-0.5 flex flex-wrap gap-1 text-[11px] text-stone-500 dark:text-neutral-400">
<span>{loop.enabled ? 'on' : 'off'}</span>
<span>{loop.cadence}</span>
</div>
</div>
<div className="text-xs text-stone-600">
<div className="text-xs text-stone-600 dark:text-neutral-300">
<div>{loop.work}</div>
<div className="mt-1 font-mono text-[11px] text-stone-500">
<div className="mt-1 font-mono text-[11px] text-stone-500 dark:text-neutral-400">
route: {loop.route}
</div>
<div className="mt-1 text-stone-500">{loop.risk}</div>
<div className="mt-1 text-stone-500 dark:text-neutral-400">{loop.risk}</div>
</div>
</div>
))}
@@ -1117,11 +1151,13 @@ const BackgroundLoopControls = ({
</div>
</div>
<div className="rounded-lg border border-stone-200 bg-white p-3">
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-stone-900">Recent usage ledger</div>
<div className="text-xs text-stone-500">
<div className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Recent usage ledger
</div>
<div className="text-xs text-stone-500 dark:text-neutral-400">
Backend rows expose action/time today; source tags need backend support.
</div>
</div>
@@ -1129,7 +1165,7 @@ const BackgroundLoopControls = ({
type="button"
onClick={() => void refresh()}
disabled={loading}
className="rounded-md border border-stone-200 px-2 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50">
className="rounded-md border border-stone-200 dark:border-neutral-800 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
Reload
</button>
</div>
@@ -1171,8 +1207,8 @@ const BackgroundLoopControls = ({
/>
</div>
<div className="mt-3 rounded-lg border border-stone-200 bg-stone-50 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400">
<div className="mt-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
Budget math
</div>
<div className="mt-2 grid gap-2">
@@ -1232,8 +1268,8 @@ const BackgroundLoopControls = ({
</div>
</div>
<div className="mt-3 rounded-lg border border-stone-200 bg-stone-50 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400">
<div className="mt-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
Loop call budget
</div>
<div className="mt-2 grid gap-2">
@@ -1284,7 +1320,7 @@ const BackgroundLoopControls = ({
</div>
{latestSpend && (
<div className="mt-3 rounded-md border border-stone-200 bg-stone-50 px-3 py-2 text-xs text-stone-600">
<div className="mt-3 rounded-md border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 text-xs text-stone-600 dark:text-neutral-300">
Latest spend: {formatUsd(spendAmount(latestSpend))} at{' '}
{new Date(latestSpend.createdAt).toLocaleString()} ({latestSpend.action})
</div>
@@ -1292,7 +1328,7 @@ const BackgroundLoopControls = ({
<div className="mt-3 space-y-3">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
Top actions
</div>
<div className="mt-1 space-y-1">
@@ -1300,21 +1336,23 @@ const BackgroundLoopControls = ({
actionSummary.map(([action, count, total]) => (
<div
key={action}
className="flex items-center justify-between gap-2 text-xs text-stone-600">
className="flex items-center justify-between gap-2 text-xs text-stone-600 dark:text-neutral-300">
<span className="truncate font-mono">{action}</span>
<span className="shrink-0 text-stone-500">
<span className="shrink-0 text-stone-500 dark:text-neutral-400">
{count} / {formatUsd(total)}
</span>
</div>
))
) : (
<div className="text-xs text-stone-500">No spend rows loaded.</div>
<div className="text-xs text-stone-500 dark:text-neutral-400">
No spend rows loaded.
</div>
)}
</div>
</div>
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
Top hours
</div>
<div className="mt-1 space-y-1">
@@ -1322,13 +1360,17 @@ const BackgroundLoopControls = ({
hourSummary.map(([hour, total]) => (
<div
key={hour}
className="flex items-center justify-between gap-2 text-xs text-stone-600">
className="flex items-center justify-between gap-2 text-xs text-stone-600 dark:text-neutral-300">
<span>{hour}</span>
<span className="font-mono text-stone-500">{formatUsd(total)}</span>
<span className="font-mono text-stone-500 dark:text-neutral-400">
{formatUsd(total)}
</span>
</div>
))
) : (
<div className="text-xs text-stone-500">No hourly spend yet.</div>
<div className="text-xs text-stone-500 dark:text-neutral-400">
No hourly spend yet.
</div>
)}
</div>
</div>
@@ -1389,17 +1431,25 @@ const WorkloadRow = ({
const segmentBase =
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors cursor-pointer';
const activeSegment = 'bg-white text-stone-900 shadow-subtle ring-1 ring-stone-200';
const inactiveSegment = 'text-stone-500 hover:text-stone-800';
const activeSegment =
'bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 shadow-subtle ring-1 ring-stone-200 dark:ring-neutral-600';
const inactiveSegment =
'text-stone-500 dark:text-neutral-400 hover:text-stone-800 dark:text-neutral-100 dark:hover:text-neutral-200';
return (
<div className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-stone-900">{workload.label}</div>
<div className="truncate text-xs text-stone-500">{workload.description}</div>
<div className="mt-0.5 font-mono text-[11px] text-stone-400 truncate"> {resolved}</div>
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{workload.label}
</div>
<div className="truncate text-xs text-stone-500 dark:text-neutral-400">
{workload.description}
</div>
<div className="mt-0.5 font-mono text-[11px] text-stone-400 dark:text-neutral-500 truncate">
{resolved}
</div>
</div>
<div className="inline-flex shrink-0 items-center rounded-lg bg-stone-100 p-0.5">
<div className="inline-flex shrink-0 items-center rounded-lg bg-stone-100 dark:bg-neutral-800 p-0.5">
<button
type="button"
onClick={() => onChange({ kind: 'openhuman' })}
@@ -1492,18 +1542,18 @@ const CustomRoutingDialog = ({
aria-modal="true"
aria-label={`Custom routing for ${workload.label}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
<div className="w-full max-w-md rounded-2xl border border-stone-200 bg-white p-6 shadow-soft">
<div className="w-full max-w-md rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h3 className="text-base font-semibold text-stone-900">
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.ai.customRouting')}
</h3>
<p className="mt-0.5 text-xs text-stone-500">{workload.label}</p>
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">{workload.label}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-700">
className="rounded-md p-1 text-stone-400 dark:text-neutral-500 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200">
<span className="sr-only">{t('common.close')}</span>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -1517,13 +1567,13 @@ const CustomRoutingDialog = ({
</div>
{noProviders ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3 text-xs text-amber-800 dark:text-amber-200">
{t('settings.ai.noCustomProviders')}
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-stone-700">
<label className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('settings.ai.providerLabel')}
</label>
<select
@@ -1544,7 +1594,7 @@ const CustomRoutingDialog = ({
setModel('');
}
}}
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{customCloud.map(p => (
<option key={p.slug} value={`cloud:${p.slug}`}>
{p.label}
@@ -1555,14 +1605,14 @@ const CustomRoutingDialog = ({
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-stone-700">
<label className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('settings.ai.modelLabel')}
</label>
{source?.kind === 'local' ? (
<select
value={model}
onChange={e => setModel(e.target.value)}
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
{localModels.map(m => (
<option key={m.id} value={m.id}>
{m.id}
@@ -1575,7 +1625,7 @@ const CustomRoutingDialog = ({
value={model}
onChange={e => setModel(e.target.value)}
placeholder={selectedCloud ? `${selectedCloud.slug} model id` : 'model-id'}
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm font-mono text-stone-900 placeholder-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
)}
</div>
@@ -1586,7 +1636,7 @@ const CustomRoutingDialog = ({
<button
type="button"
onClick={onClose}
className="rounded-lg border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
{t('common.cancel')}
</button>
<button
@@ -1620,24 +1670,24 @@ const SaveBar = ({
const { t } = useT();
return (
<div className="pointer-events-none sticky bottom-3 z-20 flex justify-center px-4">
<div className="pointer-events-auto flex w-full items-center gap-2 rounded-lg border border-stone-200 bg-white/95 px-3 py-2 shadow-float backdrop-blur-md animate-fade-up">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-amber-50 text-amber-600">
<div className="pointer-events-auto flex w-full items-center gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white/95 dark:bg-neutral-900/95 px-3 py-2 shadow-float backdrop-blur-md animate-fade-up">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-300">
<LuCircleAlert className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-stone-900">
<div className="text-xs font-medium text-stone-900 dark:text-neutral-100">
{changeCount === 1
? t('settings.ai.unsavedChange')
: `${String(changeCount)} ${t('settings.ai.unsavedChanges')}`}
</div>
<div className="truncate font-mono text-[10px] text-stone-500">
<div className="truncate font-mono text-[10px] text-stone-500 dark:text-neutral-400">
{diffSummary.slice(0, 2).join(' · ')}
{diffSummary.length > 2 ? ` · +${diffSummary.length - 2}` : ''}
</div>
</div>
<button
onClick={onDiscard}
className="rounded-md border border-stone-200 px-2 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50">
className="rounded-md border border-stone-200 dark:border-neutral-800 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
{t('settings.ai.discard')}
</button>
<button
@@ -1724,18 +1774,24 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
setup). Everything the user needs to wire a model up.
═══════════════════════════════════════════════════════════════ */}
<div className="space-y-4">
<div className="border-b border-stone-200 pb-2">
<h2 className="text-base font-semibold text-stone-900">
<div className="border-b border-stone-200 dark:border-neutral-800 pb-2">
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.ai.llmProviders')}
</h2>
<p className="text-xs text-stone-500 mt-0.5">{t('settings.ai.llmProvidersDesc')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t('settings.ai.llmProvidersDesc')}
</p>
</div>
{/* ─── Provider chip-toggle list ────────────────────────────────── */}
<section className="space-y-3">
{loading && <div className="text-xs text-stone-500">{t('common.loading')}</div>}
{loading && (
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('common.loading')}
</div>
)}
{error && (
<div className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
<div className="rounded-md border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
@@ -1821,10 +1877,10 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
setPendingLocalLabel(label);
}
}}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 ${enabled ? 'bg-primary-500' : 'bg-stone-300'}`}>
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 ${enabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-700'}`}>
<span
aria-hidden
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`}
className={`inline-block h-3 w-3 transform rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`}
/>
</button>
</div>
@@ -1841,18 +1897,22 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
a popup to choose provider + model).
═══════════════════════════════════════════════════════════════ */}
<div className="space-y-4">
<div className="border-b border-stone-200 pb-2">
<h2 className="text-base font-semibold text-stone-900">{t('settings.ai.routing')}</h2>
<p className="text-xs text-stone-500 mt-0.5">{t('settings.ai.routingDesc')}</p>
<div className="border-b border-stone-200 dark:border-neutral-800 pb-2">
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.ai.routing')}
</h2>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t('settings.ai.routingDesc')}
</p>
</div>
<section className="space-y-3">
<div className="overflow-hidden rounded-lg border border-stone-200 bg-stone-50 px-3">
<div className="overflow-hidden rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3">
<div className="pt-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('settings.ai.workloadGroupChat')}
</div>
<div className="divide-y divide-stone-200">
<div className="divide-y divide-stone-200 dark:divide-neutral-800">
{chatRows.map(w => (
<WorkloadRow
key={w.id}
@@ -1868,10 +1928,10 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
</div>
</div>
<div className="pb-3 pt-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400">
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('settings.ai.workloadGroupBackground')}
</div>
<div className="divide-y divide-stone-200">
<div className="divide-y divide-stone-200 dark:divide-neutral-800">
{bgRows.map(w => (
<WorkloadRow
key={w.id}
@@ -1888,9 +1948,9 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
</div>
</div>
<div className="text-[11px] text-stone-500">
<div className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.ai.defaultResolvesTo')}{' '}
<span className="font-mono text-stone-700">OpenHuman</span>.
<span className="font-mono text-stone-700 dark:text-neutral-200">OpenHuman</span>.
</div>
</section>
</div>
@@ -2068,21 +2128,21 @@ const CloudProviderEditor = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/30 p-4">
<div className="w-full max-w-md rounded-lg border border-stone-200 bg-white shadow-float">
<div className="border-b border-stone-200 px-4 py-3">
<div className="text-sm font-semibold text-stone-900">
<div className="w-full max-w-md rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-float">
<div className="border-b border-stone-200 dark:border-neutral-800 px-4 py-3">
<div className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{initial
? `${t('settings.ai.editProvider')} ${initial.label}`
: t('settings.ai.addCloudProvider')}
</div>
<div className="mt-0.5 text-xs text-stone-500">
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('settings.ai.apiKeysEncrypted')}{' '}
<span className="font-mono">auth-profiles.json</span>.
</div>
</div>
<div className="space-y-3 px-4 py-3">
<div>
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500">
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Provider slug
</label>
<select
@@ -2096,7 +2156,7 @@ const CloudProviderEditor = ({
}
}}
disabled={!!initial}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200">
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200">
{(['openai', 'anthropic', 'openrouter', 'custom'] as const)
.filter(s => s === slug || !existingSlugs.includes(s))
.map(s => (
@@ -2107,36 +2167,36 @@ const CloudProviderEditor = ({
</select>
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500">
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Display label
</label>
<input
value={label}
onChange={e => setLabel(e.target.value)}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
placeholder="My Provider"
/>
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500">
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Endpoint
</label>
<input
value={endpoint}
onChange={e => setEndpoint(e.target.value)}
disabled={isOpenHuman}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 font-mono text-xs text-stone-900 placeholder:text-stone-400 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
placeholder="https://api.example.com/v1"
/>
</div>
{!isOpenHuman && (
<div>
<label className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-stone-500">
<label className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
<span>API key</span>
{hasExistingKey && (
<button
onClick={() => void onClearKey(slug)}
className="text-[10px] font-medium normal-case text-coral-600 hover:text-coral-700">
className="text-[10px] font-medium normal-case text-coral-600 dark:text-coral-300 hover:text-coral-700 dark:text-coral-300">
{t('settings.ai.clearStoredKey')}
</button>
)}
@@ -2145,17 +2205,17 @@ const CloudProviderEditor = ({
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 font-mono text-xs text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
placeholder={hasExistingKey ? 'Leave blank to keep existing key' : 'sk-...'}
/>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-stone-200 px-4 py-3">
<div className="flex items-center justify-end gap-2 border-t border-stone-200 dark:border-neutral-800 px-4 py-3">
<button
onClick={onClose}
disabled={saving}
className="rounded-lg border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 disabled:opacity-50">
{t('common.cancel')}
</button>
<button
@@ -42,9 +42,13 @@ const AboutPanel = () => {
/>
<div className="p-4 space-y-4">
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="text-xs text-stone-500">{t('settings.about.version')}</div>
<div className="mt-1 text-lg font-semibold text-stone-900">v{APP_VERSION}</div>
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.about.version')}
</div>
<div className="mt-1 text-lg font-semibold text-stone-900 dark:text-neutral-100">
v{APP_VERSION}
</div>
{info?.available && info.available_version && (
<div className="mt-1 text-xs text-primary-500">
v{info.available_version} {t('settings.about.updateAvailable')}
@@ -52,15 +56,17 @@ const AboutPanel = () => {
)}
</div>
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-900">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.about.softwareUpdates')}
</div>
<div className="mt-1 text-xs text-stone-500 leading-relaxed">{summary}</div>
<div className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{summary}
</div>
{lastCheckedAt && (
<div className="mt-1 text-[11px] text-stone-400">
<div className="mt-1 text-[11px] text-stone-400 dark:text-neutral-500">
{t('settings.about.lastChecked')} {formatRelative(lastCheckedAt, t)}
</div>
)}
@@ -75,9 +81,11 @@ const AboutPanel = () => {
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="text-sm font-medium text-stone-900">{t('settings.about.releases')}</div>
<p className="mt-1 text-xs text-stone-500 leading-relaxed">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.about.releases')}
</div>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('settings.about.releasesDesc')}
</p>
<button
@@ -85,7 +93,7 @@ const AboutPanel = () => {
onClick={() => {
void openUrl(LATEST_APP_DOWNLOAD_URL);
}}
className="mt-3 px-3 py-1.5 rounded-lg border border-stone-200 text-stone-700 hover:bg-stone-100 text-xs transition-colors">
className="mt-3 px-3 py-1.5 rounded-lg border border-stone-200 dark:border-neutral-800 text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 text-xs transition-colors">
{t('settings.about.openReleases')}
</button>
</div>
@@ -84,22 +84,24 @@ const AgentChatPanel = () => {
<div className="p-4 space-y-4">
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">{t('chat.overrides')}</h3>
<p className="text-sm text-stone-400">{t('chat.agentChatDesc')}</p>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('chat.overrides')}
</h3>
<p className="text-sm text-stone-400 dark:text-neutral-500">{t('chat.agentChatDesc')}</p>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-2 text-sm text-stone-600">
<label className="space-y-2 text-sm text-stone-600 dark:text-neutral-300">
{t('chat.model')}
<input
className="input input-bordered w-full text-slate-900 bg-white"
className="input input-bordered w-full text-slate-900 bg-white dark:bg-neutral-900"
placeholder="gpt-4o"
value={modelOverride}
onChange={event => setModelOverride(event.target.value)}
/>
</label>
<label className="space-y-2 text-sm text-stone-600">
<label className="space-y-2 text-sm text-stone-600 dark:text-neutral-300">
{t('chat.temperature')}
<input
className="input input-bordered w-full text-slate-900 bg-white"
className="input input-bordered w-full text-slate-900 bg-white dark:bg-neutral-900"
placeholder="0.7"
value={temperature}
onChange={event => setTemperature(event.target.value)}
@@ -109,24 +111,30 @@ const AgentChatPanel = () => {
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">{t('chat.conversation')}</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('chat.conversation')}
</h3>
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="rounded-lg border border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 space-y-3">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-3">
{messages.length === 0 && (
<div className="text-sm text-stone-400">{t('chat.startAgentConversation')}</div>
<div className="text-sm text-stone-400 dark:text-neutral-500">
{t('chat.startAgentConversation')}
</div>
)}
{messages.map((message, index) => (
<div key={`${message.role}-${index}`} className="space-y-1">
<div className="text-[11px] uppercase tracking-wide text-stone-500">
<div className="text-[11px] uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{message.role === 'user' ? t('chat.you') : t('chat.agent')}
</div>
<div
className={`text-sm whitespace-pre-wrap ${
message.role === 'user' ? 'text-stone-900' : 'text-emerald-700'
message.role === 'user'
? 'text-stone-900 dark:text-neutral-100'
: 'text-emerald-700 dark:text-emerald-300'
}`}>
{message.text}
</div>
@@ -135,7 +143,7 @@ const AgentChatPanel = () => {
</div>
<div className="space-y-2">
<textarea
className="textarea textarea-bordered w-full min-h-[140px] text-slate-900 bg-white"
className="textarea textarea-bordered w-full min-h-[140px] text-slate-900 bg-white dark:bg-neutral-900"
placeholder={t('chat.askAgent')}
value={input}
onChange={event => setInput(event.target.value)}
@@ -0,0 +1,148 @@
import type { ReactElement } from 'react';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setThemeMode, type ThemeMode } from '../../../store/themeSlice';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface ModeOption {
id: ThemeMode;
label: string;
description: string;
icon: ReactElement;
}
const SunIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v2m0 14v2m9-9h-2M5 12H3m15.364-6.364l-1.414 1.414M7.05 16.95l-1.414 1.414m12.728 0l-1.414-1.414M7.05 7.05L5.636 5.636M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
const MoonIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
);
const SystemIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 17v2m6-2v2m-9-2h12a2 2 0 002-2V7a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
);
const OPTIONS: ModeOption[] = [
{ id: 'light', label: 'Light', description: 'Bright surfaces, dark text.', icon: SunIcon },
{
id: 'dark',
label: 'Dark',
description: 'Dim surfaces, easier on the eyes after dusk.',
icon: MoonIcon,
},
{
id: 'system',
label: 'Match system',
description: 'Follow your OS appearance setting.',
icon: SystemIcon,
},
];
const AppearancePanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const dispatch = useAppDispatch();
const mode = useAppSelector(state => state.theme.mode);
return (
<div>
<SettingsHeader
title="Appearance"
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
Theme
</h3>
<div
className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden"
role="radiogroup"
aria-label="Theme">
{OPTIONS.map((opt, idx) => {
const selected = opt.id === mode;
return (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => dispatch(setThemeMode(opt.id))}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors focus:outline-none focus-visible:bg-primary-50 dark:bg-primary-500/10 dark:focus-visible:bg-primary-900/30 ${
idx !== 0 ? 'border-t border-neutral-100 dark:border-neutral-800' : ''
} ${
selected
? 'bg-primary-50 dark:bg-primary-500/10'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'
}`}>
<span
className={`flex items-center justify-center w-9 h-9 rounded-lg ${
selected
? 'bg-primary-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300'
}`}>
{opt.icon}
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{opt.label}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{opt.description}
</span>
</span>
{selected && (
<svg
className="w-5 h-5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
);
})}
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
Dark mode switches the entire app chat, settings, panels to a dim palette. "Match
system" follows your OS appearance and updates live.
</p>
</div>
</div>
</div>
);
};
export default AppearancePanel;
@@ -468,9 +468,9 @@ const AutocompleteDebugPanel = () => {
{/* ------------------------------------------------------------------ */}
{/* Runtime section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Runtime</h3>
<div className="text-sm text-stone-700 space-y-1">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">Runtime</h3>
<div className="text-sm text-stone-700 dark:text-neutral-200 space-y-1">
<div>Platform supported: {status?.platform_supported ? 'yes' : 'no'}</div>
<div>Enabled: {status?.enabled ? 'yes' : 'no'}</div>
<div>Running: {status?.running ? 'yes' : 'no'}</div>
@@ -486,21 +486,21 @@ const AutocompleteDebugPanel = () => {
type="button"
onClick={() => void refreshStatus(true)}
disabled={isLoading}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700 disabled:opacity-50">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 px-3 py-2 text-sm text-stone-700 dark:text-neutral-200 disabled:opacity-50">
{isLoading ? 'Refreshing…' : 'Refresh Status'}
</button>
<button
type="button"
onClick={() => void start()}
disabled={!status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
className="rounded-lg border border-green-500/60 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-300 disabled:opacity-50">
Start
</button>
<button
type="button"
onClick={() => void stop()}
disabled={!status?.running}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
className="rounded-lg border border-red-500/60 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300 disabled:opacity-50">
Stop
</button>
</div>
@@ -509,39 +509,41 @@ const AutocompleteDebugPanel = () => {
{/* ------------------------------------------------------------------ */}
{/* Test section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Test</h3>
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">Test</h3>
<div className="space-y-1">
<div className="text-xs text-stone-600">Context Override (optional)</div>
<div className="text-xs text-stone-600 dark:text-neutral-300">
Context Override (optional)
</div>
<textarea
value={contextOverride}
onChange={event => setContextOverride(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void testCurrent()}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600">
className="rounded-lg border border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-600 dark:text-primary-300">
Get Suggestion
</button>
<button
type="button"
onClick={() => void acceptSuggestion()}
className="rounded-lg border border-emerald-500/60 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
className="rounded-lg border border-emerald-500/60 bg-emerald-50 dark:bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
Accept Suggestion
</button>
<button
type="button"
onClick={() => void debugFocus()}
className="rounded-lg border border-amber-500/60 bg-amber-50 px-3 py-2 text-sm text-amber-700">
className="rounded-lg border border-amber-500/60 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300">
Debug Focus
</button>
</div>
{focusDebug && (
<pre className="max-h-48 overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700">
<pre className="max-h-48 overflow-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200">
{focusDebug}
</pre>
)}
@@ -550,17 +552,19 @@ const AutocompleteDebugPanel = () => {
{/* ------------------------------------------------------------------ */}
{/* Live Logs section */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Live Logs</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Live Logs
</h3>
<button
type="button"
onClick={clearLogs}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-1.5 text-xs text-stone-700">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200">
Clear
</button>
</div>
<pre className="max-h-56 overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700">
<pre className="max-h-56 overflow-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200">
{logs.length > 0 ? logs.join('\n') : 'No logs yet.'}
</pre>
</section>
@@ -568,10 +572,12 @@ const AutocompleteDebugPanel = () => {
{/* ------------------------------------------------------------------ */}
{/* Advanced settings */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Advanced Settings</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Debounce Ms</span>
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Advanced Settings
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">Debounce Ms</span>
<input
type="number"
min={50}
@@ -579,11 +585,11 @@ const AutocompleteDebugPanel = () => {
step={10}
value={debounceMs}
onChange={event => setDebounceMs(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Max Characters</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">Max Characters</span>
<input
type="number"
min={32}
@@ -591,11 +597,11 @@ const AutocompleteDebugPanel = () => {
step={8}
value={maxChars}
onChange={event => setMaxChars(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Overlay TTL Ms</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">Overlay TTL Ms</span>
<input
type="number"
min={300}
@@ -603,32 +609,34 @@ const AutocompleteDebugPanel = () => {
step={100}
value={overlayTtlMs}
onChange={event => setOverlayTtlMs(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">Style Instructions</div>
<div className="text-xs text-stone-600 dark:text-neutral-300">Style Instructions</div>
<textarea
value={styleInstructions}
onChange={event => setStyleInstructions(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600">Style Examples (one per line)</div>
<div className="text-xs text-stone-600 dark:text-neutral-300">
Style Examples (one per line)
</div>
<textarea
value={styleExamplesText}
onChange={event => setStyleExamplesText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<button
type="button"
onClick={() => void saveAdvancedConfig()}
disabled={isSaving}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
className="rounded-lg border border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-600 dark:text-primary-300 disabled:opacity-50">
{isSaving ? 'Saving…' : 'Save'}
</button>
</section>
@@ -636,18 +644,20 @@ const AutocompleteDebugPanel = () => {
{/* ------------------------------------------------------------------ */}
{/* Personalization History */}
{/* ------------------------------------------------------------------ */}
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Personalization History</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Personalization History
</h3>
<button
type="button"
onClick={() => void clearHistory()}
disabled={isClearingHistory || historyEntries.length === 0}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-1.5 text-xs text-red-600 disabled:opacity-40">
className="rounded-lg border border-red-500/60 bg-red-50 dark:bg-red-500/10 px-3 py-1.5 text-xs text-red-600 dark:text-red-300 disabled:opacity-40">
{isClearingHistory ? 'Clearing…' : 'Clear History'}
</button>
</div>
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{isHistoryLoading
? 'Loading…'
: historyEntries.length === 0
@@ -655,25 +665,27 @@ const AutocompleteDebugPanel = () => {
: `${String(historyEntries.length)} accepted completion${historyEntries.length === 1 ? '' : 's'} stored — used to personalise future suggestions.`}
</p>
{historyEntries.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-xl border border-stone-200 bg-stone-50 p-2 space-y-1">
<div className="max-h-48 overflow-y-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 space-y-1">
{historyEntries.map((entry, idx) => (
<div
key={`${String(entry.timestamp_ms)}-${String(idx)}`}
className="flex flex-col gap-0.5 rounded-lg bg-white px-2 py-1.5 text-xs border border-stone-100">
<div className="flex items-center gap-2 text-stone-500">
className="flex flex-col gap-0.5 rounded-lg bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs border border-stone-100 dark:border-neutral-800">
<div className="flex items-center gap-2 text-stone-500 dark:text-neutral-400">
<span className="shrink-0">
{new Date(entry.timestamp_ms).toLocaleString()}
</span>
{entry.app_name && (
<span className="rounded bg-stone-100 px-1 text-stone-600">
<span className="rounded bg-stone-100 dark:bg-neutral-800 px-1 text-stone-600 dark:text-neutral-300">
{entry.app_name}
</span>
)}
</div>
<div className="flex items-baseline gap-1 text-stone-700 truncate">
<span className="shrink-0 text-stone-400"></span>
<span className="truncate text-stone-500">{entry.context.slice(-40)}</span>
<span className="shrink-0 text-stone-400"></span>
<div className="flex items-baseline gap-1 text-stone-700 dark:text-neutral-200 truncate">
<span className="shrink-0 text-stone-400 dark:text-neutral-500"></span>
<span className="truncate text-stone-500 dark:text-neutral-400">
{entry.context.slice(-40)}
</span>
<span className="shrink-0 text-stone-400 dark:text-neutral-500"></span>
<span className="font-medium text-primary-500 truncate">
{entry.suggestion}
</span>
@@ -687,8 +699,8 @@ const AutocompleteDebugPanel = () => {
{/* ------------------------------------------------------------------ */}
{/* Feedback messages */}
{/* ------------------------------------------------------------------ */}
{message && <div className="text-xs text-green-700">{message}</div>}
{error && <div className="text-xs text-red-600">{error}</div>}
{message && <div className="text-xs text-green-700 dark:text-green-300">{message}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-300">{error}</div>}
</div>
</div>
);
@@ -198,11 +198,15 @@ const AutocompletePanel = () => {
/>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">{t('autocomplete.settings')}</h3>
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('autocomplete.settings')}
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">{t('common.enabled')}</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('common.enabled')}
</span>
<input
type="checkbox"
checked={enabled}
@@ -210,8 +214,10 @@ const AutocompletePanel = () => {
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">{t('autocomplete.acceptWithTab')}</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.acceptWithTab')}
</span>
<input
type="checkbox"
checked={acceptWithTab}
@@ -219,12 +225,14 @@ const AutocompletePanel = () => {
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">{t('autocomplete.stylePreset')}</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.stylePreset')}
</span>
<select
value={stylePreset}
onChange={event => setStylePreset(event.target.value)}
className="rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700">
className="rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200">
<option value="balanced">{t('autocomplete.style.balanced')}</option>
<option value="concise">{t('autocomplete.style.concise')}</option>
<option value="formal">{t('autocomplete.style.formal')}</option>
@@ -234,12 +242,14 @@ const AutocompletePanel = () => {
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">{t('autocomplete.disabledApps')}</div>
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('autocomplete.disabledApps')}
</div>
<textarea
value={disabledAppsText}
onChange={event => setDisabledAppsText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
@@ -247,14 +257,16 @@ const AutocompletePanel = () => {
type="button"
onClick={() => void saveConfig()}
disabled={isSaving || !configLoaded}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
className="rounded-lg border border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-600 dark:text-primary-300 disabled:opacity-50">
{isSaving ? t('autocomplete.saving') : t('autocomplete.saveSettings')}
</button>
</section>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">{t('autocomplete.runtime')}</h3>
<div className="text-sm text-stone-600 space-y-1">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('autocomplete.runtime')}
</h3>
<div className="text-sm text-stone-600 dark:text-neutral-300 space-y-1">
<div>
{t('autocomplete.running')}: {status?.running ? t('common.yes') : t('common.no')}
</div>
@@ -267,26 +279,26 @@ const AutocompletePanel = () => {
type="button"
onClick={() => void start()}
disabled={!configLoaded || !status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
className="rounded-lg border border-green-500/60 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-300 disabled:opacity-50">
{t('autocomplete.start')}
</button>
<button
type="button"
onClick={() => void stop()}
disabled={!status?.running}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
className="rounded-lg border border-red-500/60 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300 disabled:opacity-50">
{t('autocomplete.stop')}
</button>
</div>
</section>
{message && <div className="text-xs text-green-700">{message}</div>}
{error && <div className="text-xs text-red-600">{error}</div>}
{message && <div className="text-xs text-green-700 dark:text-green-300">{message}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-300">{error}</div>}
<button
type="button"
onClick={() => navigateToSettings('autocomplete-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
className="flex items-center gap-1.5 text-xs text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:text-neutral-300 dark:hover:text-neutral-300 transition-colors">
{t('autocomplete.advancedSettings')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
@@ -47,13 +47,13 @@ const BillingPanel = () => {
onClick={navigateBack}
trailingContent={
breadcrumbs.length > 0 ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-stone-500">
<div className="flex flex-wrap items-center gap-2 text-xs text-stone-500 dark:text-neutral-400">
{breadcrumbs.map((crumb, index) => (
<button
key={`${crumb.label}-${index}`}
type="button"
onClick={crumb.onClick}
className="rounded-full border border-stone-200 bg-white px-3 py-1 font-medium text-stone-600 transition-colors hover:bg-stone-50">
className="rounded-full border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-1 font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
{crumb.label}
</button>
))}
@@ -62,16 +62,16 @@ const BillingPanel = () => {
}
/>
<div className="mt-6 rounded-3xl border border-stone-200 bg-white p-6 shadow-soft">
<div className="mt-6 rounded-3xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft">
<div className="max-w-xl space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-neutral-400">
{t('settings.billing.movedToWeb')}
</p>
<h1 className="mt-2 text-2xl font-semibold text-stone-900">
<h1 className="mt-2 text-2xl font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.billing.openDashboard')}
</h1>
<p className="mt-2 text-sm leading-6 text-stone-600">
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-neutral-300">
{t('settings.billing.movedToWebDesc')}
</p>
</div>
@@ -88,19 +88,25 @@ const BillingPanel = () => {
<button
type="button"
onClick={navigateBack}
className="inline-flex items-center rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition-colors hover:bg-stone-50">
className="inline-flex items-center rounded-full border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-neutral-200 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
{t('settings.billing.backToSettings')}
</button>
</div>
{status === 'opening' && (
<p className="text-xs text-stone-500">{t('settings.billing.openingBrowser')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.openingBrowser')}
</p>
)}
{status === 'idle' && (
<p className="text-xs text-stone-500">{t('settings.billing.browserNotOpen')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.browserNotOpen')}
</p>
)}
{status === 'error' && (
<p className="text-xs text-coral-600">{t('settings.billing.browserOpenFailed')}</p>
<p className="text-xs text-coral-600 dark:text-coral-300">
{t('settings.billing.browserOpenFailed')}
</p>
)}
</div>
</div>
@@ -204,7 +204,9 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
/>
)}
<div className={embedded ? '' : 'p-4'}>
<p className="text-sm text-stone-500">{t('settings.composio.loading')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('settings.composio.loading')}
</p>
</div>
</div>
);
@@ -222,12 +224,14 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
)}
<div className={embedded ? 'space-y-5' : 'p-4 space-y-5'}>
<p className="text-sm text-stone-500">{t('settings.composio.intro')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('settings.composio.intro')}
</p>
{/* Mode toggle */}
<div className="rounded-2xl border border-stone-200 bg-stone-50/60 p-4 space-y-3">
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-3">
<fieldset>
<legend className="text-sm font-medium text-stone-900 mb-2">
<legend className="text-sm font-medium text-stone-900 dark:text-neutral-100 mb-2">
{t('settings.composio.routingMode')}
</legend>
<div className="space-y-2">
@@ -244,10 +248,10 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
className="mt-1"
/>
<div className="text-left">
<span className="text-sm font-medium text-stone-900">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.composio.modeManaged')}
</span>
<p className="text-xs text-stone-500 mt-0.5">
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t('settings.composio.modeManagedDesc')}
</p>
</div>
@@ -265,10 +269,10 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
className="mt-1"
/>
<div className="text-left">
<span className="text-sm font-medium text-stone-900">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.composio.modeDirect')}
</span>
<p className="text-xs text-stone-500 mt-0.5">
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t('settings.composio.modeDirectDesc')}
</p>
</div>
@@ -280,10 +284,14 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
{/* API key field — only when Direct is selected */}
{mode === 'direct' && (
<div className="space-y-2">
<label className="block text-sm font-medium text-stone-800" htmlFor="composio-api-key">
<label
className="block text-sm font-medium text-stone-800 dark:text-neutral-100"
htmlFor="composio-api-key">
{t('settings.composio.apiKeyLabel')}
</label>
<p className="text-xs text-stone-500">{t('settings.composio.apiKeyDesc')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.composio.apiKeyDesc')}
</p>
<input
id="composio-api-key"
type="password"
@@ -295,10 +303,12 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
? t('settings.composio.apiKeyStoredPlaceholder')
: 'ck_live_xxxxxxxxxxxxxxxx'
}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm font-mono text-stone-900 placeholder-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
{apiKeyStored && (
<p className="text-xs text-green-600">{t('settings.composio.apiKeyStored')}</p>
<p className="text-xs text-green-600 dark:text-green-300">
{t('settings.composio.apiKeyStored')}
</p>
)}
</div>
)}
@@ -313,7 +323,7 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
<div
role="alertdialog"
aria-labelledby="composio-confirm-title"
className="rounded-2xl border border-amber-200 bg-amber-50/80 p-4 space-y-3">
className="rounded-2xl border border-amber-200 dark:border-amber-500/30 bg-amber-50/80 p-4 space-y-3">
<p id="composio-confirm-title" className="text-sm font-medium text-amber-900">
{t('settings.composio.confirmTitle')}
</p>
@@ -331,7 +341,7 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
type="button"
onClick={handleCancelTransition}
disabled={saving}
className="flex-1 py-2 rounded-xl border border-stone-300 bg-white text-stone-800 text-sm font-medium hover:bg-stone-50 transition-colors disabled:opacity-50">
className="flex-1 py-2 rounded-xl border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-stone-800 dark:text-neutral-100 text-sm font-medium hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
@@ -354,10 +364,12 @@ const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
)}
{saveStatus === 'saved' && (
<p className="text-xs text-center text-green-600">{t('composio.settingsSaved')}</p>
<p className="text-xs text-center text-green-600 dark:text-green-300">
{t('composio.settingsSaved')}
</p>
)}
{saveStatus === 'cleared' && (
<p className="text-xs text-center text-green-600">
<p className="text-xs text-center text-green-600 dark:text-green-300">
{t('settings.composio.clearedToBackend')}
</p>
)}
@@ -83,7 +83,7 @@ const ComposioTriagePanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="p-4">
<p className="text-sm text-stone-500">Loading</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">Loading</p>
</div>
</div>
);
@@ -99,13 +99,13 @@ const ComposioTriagePanel = () => {
/>
<div className="p-4 space-y-5">
<p className="text-sm text-stone-500">
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('composio.triageDesc')}{' '}
<span className="font-mono">OPENHUMAN_TRIGGER_TRIAGE_DISABLED</span>{' '}
{t('composio.envVarOverrides')}
</p>
<div className="rounded-2xl border border-stone-200 bg-stone-50/60 p-4 space-y-1">
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4 space-y-1">
<button
type="button"
role="switch"
@@ -114,17 +114,19 @@ const ComposioTriagePanel = () => {
onClick={() => setTriageDisabled(v => !v)}
className="w-full flex items-center justify-between">
<div className="text-left">
<span className="text-sm font-medium text-stone-900">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('composio.disableAllTriage')}
</span>
<p className="text-xs text-stone-500 mt-0.5">{t('composio.triggersStillRecorded')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{t('composio.triggersStillRecorded')}
</p>
</div>
<div
className={`ml-3 flex-shrink-0 w-9 h-5 rounded-full transition-colors relative ${
triageDisabled ? 'bg-coral-400' : 'bg-stone-200'
triageDisabled ? 'bg-coral-400' : 'bg-stone-200 dark:bg-neutral-800'
}`}>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
triageDisabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
@@ -133,10 +135,12 @@ const ComposioTriagePanel = () => {
</div>
<div className={`space-y-2 ${triageDisabled ? 'opacity-40 pointer-events-none' : ''}`}>
<label className="block text-sm font-medium text-stone-800" htmlFor="disabled-toolkits">
<label
className="block text-sm font-medium text-stone-800 dark:text-neutral-100"
htmlFor="disabled-toolkits">
{t('composio.disableSpecificIntegrations')}
</label>
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
Comma-separated integration slugs, e.g. <span className="font-mono">gmail, slack</span>.
Case-insensitive.
</p>
@@ -147,7 +151,7 @@ const ComposioTriagePanel = () => {
onChange={e => setDisabledToolkits(e.target.value)}
placeholder="gmail, slack, ..."
disabled={triageDisabled}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:cursor-not-allowed"
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:cursor-not-allowed"
/>
</div>
@@ -160,7 +164,9 @@ const ComposioTriagePanel = () => {
</button>
{saveStatus === 'saved' && (
<p className="text-xs text-center text-green-600">{t('composio.settingsSaved')}</p>
<p className="text-xs text-center text-green-600 dark:text-green-300">
{t('composio.settingsSaved')}
</p>
)}
{saveStatus === 'error' && (
<p className="text-xs text-center text-red-500">{t('composio.saveFailed')}</p>
@@ -36,15 +36,15 @@ function ConnectionOptionRow({
const isDisabled = option.comingSoon;
const badge = option.comingSoon ? (
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-stone-100 text-stone-500 border border-stone-200">
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-stone-100 dark:bg-neutral-800 text-stone-500 dark:text-neutral-400 border border-stone-200 dark:border-neutral-800">
{t('connections.comingSoon')}
</span>
) : option.statusLabel ? (
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-sage-50 text-sage-700 border border-sage-200">
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border border-sage-200 dark:border-sage-500/30">
{option.statusLabel}
</span>
) : (
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-primary-50 text-primary-600 border border-primary-100">
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300 border border-primary-100 dark:border-primary-500/30">
{t('connections.setUp')}
</span>
);
@@ -53,20 +53,24 @@ function ConnectionOptionRow({
<button
onClick={() => onConnect(option)}
disabled={isDisabled}
className={`group w-full flex items-center justify-between p-4 bg-white text-left transition-colors duration-150 ${
isLast ? '' : 'border-b border-stone-200'
className={`group w-full flex items-center justify-between p-4 bg-white dark:bg-neutral-900 text-left transition-colors duration-150 ${
isLast ? '' : 'border-b border-stone-200 dark:border-neutral-800'
} ${isFirst ? 'rounded-t-2xl' : ''} ${isLast ? 'rounded-b-2xl' : ''} ${
isDisabled ? 'opacity-70 cursor-not-allowed' : 'hover:bg-stone-50 focus-visible:bg-stone-50'
isDisabled
? 'opacity-70 cursor-not-allowed'
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 focus-visible:bg-stone-50 dark:bg-neutral-800/60 dark:focus-visible:bg-neutral-800/60'
} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40`}>
<div
className={`w-5 h-5 flex-shrink-0 mr-3 text-stone-700 ${
className={`w-5 h-5 flex-shrink-0 mr-3 text-stone-700 dark:text-neutral-200 ${
isDisabled ? 'opacity-50' : 'opacity-80 group-hover:opacity-100'
} transition-opacity`}>
{option.icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-stone-900 leading-snug">{option.name}</div>
<p className="text-xs text-stone-500 mt-0.5 leading-relaxed truncate">
<div className="font-medium text-sm text-stone-900 dark:text-neutral-100 leading-snug">
{option.name}
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5 leading-relaxed truncate">
{option.description}
</p>
</div>
@@ -169,7 +173,7 @@ const ConnectionsPanel = () => {
<div>
<div className="p-4 space-y-4">
<div className="rounded-2xl border border-stone-200 overflow-hidden bg-white">
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 overflow-hidden bg-white dark:bg-neutral-900">
{connectOptions.map((option, index) => (
<ConnectionOptionRow
key={option.id}
@@ -183,23 +187,25 @@ const ConnectionsPanel = () => {
</div>
{walletConfigured && walletStatus ? (
<div className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div>
<p className="font-medium text-stone-900 text-sm">
<p className="font-medium text-stone-900 dark:text-neutral-100 text-sm">
{t('connections.walletIdentities')}
</p>
<p className="text-xs text-stone-500 mt-1">{t('connections.walletDerived')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t('connections.walletDerived')}
</p>
</div>
<div className="grid gap-2">
{walletStatus.accounts.map(account => (
<div
key={account.chain}
className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-wide text-stone-500">
<span className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{account.chain}
</span>
<span className="text-xs font-mono text-stone-700 truncate">
<span className="text-xs font-mono text-stone-700 dark:text-neutral-200 truncate">
{account.address}
</span>
</div>
@@ -209,10 +215,10 @@ const ConnectionsPanel = () => {
</div>
) : null}
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
<div className="p-4 bg-stone-50 dark:bg-neutral-800/60 rounded-xl border border-stone-200 dark:border-neutral-800">
<div className="flex items-start space-x-3">
<svg
className="w-5 h-5 text-stone-400 mt-0.5 flex-shrink-0"
className="w-5 h-5 text-stone-400 dark:text-neutral-500 mt-0.5 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20">
<path
@@ -222,10 +228,10 @@ const ConnectionsPanel = () => {
/>
</svg>
<div>
<p className="font-medium text-stone-900 text-sm">
<p className="font-medium text-stone-900 dark:text-neutral-100 text-sm">
{t('connections.privacySecurity')}
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{t('connections.privacySecurityDesc')}
</p>
</div>
@@ -141,12 +141,14 @@ const CronJobsPanel = () => {
<div className="p-4 space-y-4">
<section className="space-y-1">
<h3 className="text-sm font-semibold text-stone-900">{t('cron.scheduledJobs')}</h3>
<p className="text-xs text-stone-400">{t('cron.manageCronJobs')}</p>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('cron.scheduledJobs')}
</h3>
<p className="text-xs text-stone-400 dark:text-neutral-500">{t('cron.manageCronJobs')}</p>
</section>
{coreError && (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700">
<div className="rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
{coreError}
</div>
)}
@@ -245,48 +245,58 @@ const CoreModeBadge = () => {
if (mode.kind === 'unset') {
return (
<div className="px-4 py-3 rounded-lg border border-coral-300 bg-coral-50">
<div className="text-sm font-semibold text-coral-900">{t('devOptions.coreModeNotSet')}</div>
<div className="text-xs text-coral-800 mt-0.5">{t('devOptions.coreModeNotSetDesc')}</div>
<div className="px-4 py-3 rounded-lg border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10 dark:border-coral-500/30">
<div className="text-sm font-semibold text-coral-900 dark:text-coral-300">
{t('devOptions.coreModeNotSet')}
</div>
<div className="text-xs text-coral-800 dark:text-coral-200 mt-0.5">
{t('devOptions.coreModeNotSetDesc')}
</div>
</div>
);
}
if (mode.kind === 'local') {
return (
<div className="px-4 py-3 rounded-lg border border-primary-300 bg-primary-50">
<div className="px-4 py-3 rounded-lg border border-primary-300 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/10 dark:border-primary-500/30">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-primary-600 text-white text-[11px] font-medium">
{t('devOptions.local')}
</span>
<span className="text-sm font-semibold text-primary-900">
<span className="text-sm font-semibold text-primary-900 dark:text-primary-200">
{t('devOptions.embeddedCoreSidecar')}
</span>
</div>
<div className="text-xs text-primary-800 mt-1">{t('devOptions.sidecarSpawned')}</div>
<div className="text-xs text-primary-800 dark:text-primary-200 mt-1">
{t('devOptions.sidecarSpawned')}
</div>
</div>
);
}
return (
<div className="px-4 py-3 rounded-lg border border-sage-300 bg-sage-50">
<div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10 dark:border-sage-500/30">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-sage-600 text-white text-[11px] font-medium">
{t('devOptions.cloud')}
</span>
<span className="text-sm font-semibold text-sage-900">{t('devOptions.remoteCoreRpc')}</span>
<span className="text-sm font-semibold text-sage-900 dark:text-sage-200">
{t('devOptions.remoteCoreRpc')}
</span>
</div>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
<dt className="text-sage-700">URL:</dt>
<dd className="font-mono text-sage-900 truncate" title={mode.url}>
<dt className="text-sage-700 dark:text-sage-300">URL:</dt>
<dd className="font-mono text-sage-900 dark:text-sage-200 truncate" title={mode.url}>
{mode.url}
</dd>
<dt className="text-sage-700">{t('devOptions.token')}:</dt>
<dd className="text-sage-900">
<dt className="text-sage-700 dark:text-sage-300">{t('devOptions.token')}:</dt>
<dd className="text-sage-900 dark:text-sage-200">
{mode.token ? (
<span className="font-mono">{mode.token.slice(-4)}</span>
) : (
<span className="text-coral-600">{t('devOptions.tokenNotSet')}</span>
<span className="text-coral-600 dark:text-coral-300">
{t('devOptions.tokenNotSet')}
</span>
)}
</dd>
</dl>
@@ -315,13 +325,13 @@ const SentryTestRow = () => {
};
return (
<div className="px-4 py-3 rounded-lg border border-amber-300 bg-amber-50">
<div className="px-4 py-3 rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 dark:border-amber-500/30">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-900">
<div className="text-sm font-semibold text-amber-900 dark:text-amber-300">
{t('devOptions.triggerSentryTest')}
</div>
<div className="text-xs text-amber-800 mt-0.5">
<div className="text-xs text-amber-800 dark:text-amber-200 mt-0.5">
{t('devOptions.triggerSentryTestDesc')}
</div>
</div>
@@ -334,7 +344,7 @@ const SentryTestRow = () => {
</div>
<div role="status" aria-live="polite" aria-atomic="true" className="mt-2 text-xs">
{status.kind === 'sent' && (
<span className="text-amber-900">
<span className="text-amber-900 dark:text-amber-300">
{t('devOptions.eventSent')}.{' '}
{status.eventId ? (
<span className="font-mono">id: {status.eventId}</span>
@@ -344,7 +354,7 @@ const SentryTestRow = () => {
</span>
)}
{status.kind === 'error' && (
<span className="text-coral-600">
<span className="text-coral-600 dark:text-coral-300">
{t('devOptions.failed')}: {status.message}
</span>
)}
@@ -379,12 +389,20 @@ const LogsFolderRow = () => {
if (!isTauri()) return null;
return (
<div className="px-4 py-3 rounded-lg border border-slate-200 bg-slate-50">
<div className="px-4 py-3 rounded-lg border border-slate-200 dark:border-neutral-800 bg-slate-50 dark:bg-neutral-800/60">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">{t('devOptions.appLogs')}</div>
<div className="text-xs text-slate-700 mt-0.5">{t('devOptions.appLogsDesc')}</div>
{path && <div className="text-[11px] text-slate-500 mt-1 font-mono truncate">{path}</div>}
<div className="text-sm font-semibold text-slate-900 dark:text-neutral-100">
{t('devOptions.appLogs')}
</div>
<div className="text-xs text-slate-700 dark:text-neutral-300 mt-0.5">
{t('devOptions.appLogsDesc')}
</div>
{path && (
<div className="text-[11px] text-slate-500 dark:text-neutral-400 mt-1 font-mono truncate">
{path}
</div>
)}
</div>
<button
onClick={onClick}
@@ -393,7 +411,10 @@ const LogsFolderRow = () => {
</button>
</div>
{error && (
<div role="status" aria-live="polite" className="mt-2 text-xs text-coral-600">
<div
role="status"
aria-live="polite"
className="mt-2 text-xs text-coral-600 dark:text-coral-300">
{error}
</div>
)}
@@ -35,17 +35,17 @@ import ModelStatusSection from './local-model/ModelStatusSection';
const statusTone = (state: string): string => {
switch (state) {
case 'ready':
return 'text-green-600';
return 'text-green-600 dark:text-green-300';
case 'downloading':
case 'installing':
case 'loading':
return 'text-primary-600';
return 'text-primary-600 dark:text-primary-300';
case 'degraded':
return 'text-amber-700';
return 'text-amber-700 dark:text-amber-300';
case 'disabled':
return 'text-stone-500';
return 'text-stone-500 dark:text-neutral-400';
default:
return 'text-stone-700';
return 'text-stone-700 dark:text-neutral-200';
}
};
@@ -120,12 +120,14 @@ const MascotPanel = () => {
<div className="p-4 space-y-4">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-2 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.mascot.colorHeading')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
{available.length === 0 ? (
<p className="p-4 text-sm text-stone-500">{t('settings.mascot.noColorVariants')}</p>
<p className="p-4 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.mascot.noColorVariants')}
</p>
) : (
<div
className="grid grid-cols-5 gap-3 p-4"
@@ -144,55 +146,67 @@ const MascotPanel = () => {
onClick={() => handleSelect(opt.id)}
data-testid={`mascot-color-${opt.id}`}
className={`flex flex-col items-center gap-2 rounded-lg p-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 ${
selected ? 'bg-stone-100' : 'hover:bg-stone-50'
selected
? 'bg-stone-100 dark:bg-neutral-800'
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60'
}`}>
<span
className={`w-10 h-10 rounded-full border-2 transition-shadow ${
selected ? 'border-primary-500 shadow-soft' : 'border-stone-200'
selected
? 'border-primary-500 shadow-soft'
: 'border-stone-200 dark:border-neutral-800'
}`}
style={{ backgroundColor: palette.bodyFill }}
/>
<span className="text-xs text-stone-700">{opt.label}</span>
<span className="text-xs text-stone-700 dark:text-neutral-200">
{opt.label}
</span>
</button>
);
})}
</div>
)}
</div>
<p className="text-xs text-stone-500 leading-relaxed px-1 mt-2">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.mascot.colorDesc')}
</p>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-2 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.mascot.characterHeading')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
{backendListError && (
<p className="p-4 text-sm text-coral-700">
<p className="p-4 text-sm text-coral-700 dark:text-coral-300">
OpenHuman library unavailable: {backendListError}
</p>
)}
{!backendListError && backendList === null && (
<p className="p-4 text-sm text-stone-500">{t('settings.mascot.loadingLibrary')}</p>
<p className="p-4 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.mascot.loadingLibrary')}
</p>
)}
{backendList && backendList.length === 0 && !backendListError && (
<p className="p-4 text-sm text-stone-500">{t('settings.mascot.noCharacters')}</p>
<p className="p-4 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.mascot.noCharacters')}
</p>
)}
{backendList && backendList.length > 0 && (
<ul className="divide-y divide-stone-100">
<ul className="divide-y divide-stone-100 dark:divide-neutral-800">
<li>
<button
type="button"
onClick={() => handleSelectBackend(null)}
aria-pressed={selectedMascotId == null}
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-stone-50 ${
selectedMascotId == null ? 'bg-stone-50 font-medium' : ''
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 ${
selectedMascotId == null
? 'bg-stone-50 dark:bg-neutral-800/60 font-medium'
: ''
}`}>
<span>{t('settings.mascot.localDefault')}</span>
{selectedMascotId == null && (
<span className="text-[10px] uppercase text-primary-600">
<span className="text-[10px] uppercase text-primary-600 dark:text-primary-300">
{t('settings.mascot.active')}
</span>
)}
@@ -207,18 +221,18 @@ const MascotPanel = () => {
onClick={() => handleSelectBackend(summary.id)}
aria-pressed={active}
data-testid={`backend-mascot-${summary.id}`}
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-stone-50 ${
active ? 'bg-stone-50 font-medium' : ''
className={`flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 ${
active ? 'bg-stone-50 dark:bg-neutral-800/60 font-medium' : ''
}`}>
<span className="flex flex-col">
<span>{summary.name}</span>
<span className="text-[10px] text-stone-500">
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
v{summary.version} · {summary.states.length} states
{summary.hasVisemes ? ' · visemes' : ''}
</span>
</span>
{active && (
<span className="text-[10px] uppercase text-primary-600">
<span className="text-[10px] uppercase text-primary-600 dark:text-primary-300">
{t('settings.mascot.active')}
</span>
)}
@@ -231,8 +245,8 @@ const MascotPanel = () => {
</div>
{activeDetail && (
<div className="mt-3 rounded-xl border border-stone-200 bg-stone-50 p-4">
<p className="text-[11px] font-medium uppercase tracking-wide text-stone-500 mb-2">
<div className="mt-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<p className="text-[11px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400 mb-2">
Preview · {activeDetail.name}
</p>
<div className="flex justify-center">
@@ -242,8 +256,10 @@ const MascotPanel = () => {
</div>
</div>
)}
{detailError && <p className="mt-2 text-xs text-coral-700 px-1">{detailError}</p>}
<p className="text-xs text-stone-500 leading-relaxed px-1 mt-2">
{detailError && (
<p className="mt-2 text-xs text-coral-700 dark:text-coral-300 px-1">{detailError}</p>
)}
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.mascot.characterDesc')}
</p>
</div>
@@ -183,47 +183,53 @@ const MemoryDebugPanel = () => {
<div className="p-4 space-y-4">
{/* Documents */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900">Documents</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">Documents</h3>
<div className="flex gap-2">
<input
value={documentsNamespaceFilter}
onChange={e => setDocumentsNamespaceFilter(e.target.value)}
className="flex-1 rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-700 placeholder:text-stone-400"
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500"
placeholder="Filter by namespace..."
/>
<button
type="button"
onClick={() => void loadDocuments()}
disabled={documentsLoading}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-100 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{documentsLoading ? '...' : 'Refresh'}
</button>
</div>
{documentsError && (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{documentsError}
</div>
)}
{documents.length === 0 && !documentsLoading ? (
<p className="text-xs text-stone-400">No documents found.</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">No documents found.</p>
) : (
<div className="space-y-1">
{documents.map(doc => (
<div
key={`${doc.namespace}:${doc.documentId}`}
className="flex items-start justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 p-2">
className="flex items-start justify-between gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2">
<div className="min-w-0">
<div className="text-xs font-medium text-stone-900 break-all">
<div className="text-xs font-medium text-stone-900 dark:text-neutral-100 break-all">
{doc.documentId}
</div>
<div className="text-[11px] text-stone-500 break-all">{doc.namespace}</div>
{doc.title && <div className="text-[11px] text-stone-400">{doc.title}</div>}
<div className="text-[11px] text-stone-500 dark:text-neutral-400 break-all">
{doc.namespace}
</div>
{doc.title && (
<div className="text-[11px] text-stone-400 dark:text-neutral-500">
{doc.title}
</div>
)}
</div>
<button
type="button"
disabled={Boolean(deleteLoadingId)}
onClick={() => void handleDelete(doc)}
className="shrink-0 rounded border border-stone-200 px-2 py-1 text-[10px] text-stone-500 hover:bg-stone-100 disabled:opacity-50">
className="shrink-0 rounded border border-stone-200 dark:border-neutral-800 px-2 py-1 text-[10px] text-stone-500 dark:text-neutral-400 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{deleteLoadingId === doc.documentId ? '...' : 'Delete'}
</button>
</div>
@@ -231,8 +237,10 @@ const MemoryDebugPanel = () => {
</div>
)}
<details className="text-xs">
<summary className="cursor-pointer text-stone-400">Raw response</summary>
<pre className="mt-1 max-h-32 overflow-auto rounded-lg border border-stone-200 bg-stone-950 p-2 text-[11px] text-stone-100 whitespace-pre-wrap break-words">
<summary className="cursor-pointer text-stone-400 dark:text-neutral-500">
Raw response
</summary>
<pre className="mt-1 max-h-32 overflow-auto rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-950 dark:bg-neutral-50 p-2 text-[11px] text-stone-100 whitespace-pre-wrap break-words">
{JSON.stringify(documentsRaw, null, 2)}
</pre>
</details>
@@ -241,17 +249,19 @@ const MemoryDebugPanel = () => {
{/* Namespaces */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Namespaces</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Namespaces
</h3>
<button
type="button"
onClick={() => void loadNamespaces()}
disabled={namespacesLoading}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-100 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1 text-[11px] font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{namespacesLoading ? '...' : 'Refresh'}
</button>
</div>
{namespacesError && (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{namespacesError}
</div>
)}
@@ -260,29 +270,31 @@ const MemoryDebugPanel = () => {
{namespaces.map(ns => (
<span
key={ns}
className="rounded-full bg-stone-100 px-2 py-0.5 text-[11px] text-stone-600">
className="rounded-full bg-stone-100 dark:bg-neutral-800 px-2 py-0.5 text-[11px] text-stone-600 dark:text-neutral-300">
{ns}
</span>
))}
</div>
) : (
<p className="text-xs text-stone-400">No namespaces found.</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">No namespaces found.</p>
)}
</section>
{/* Query & Recall */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900">Query & Recall</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Query & Recall
</h3>
<input
value={namespaceInput}
onChange={e => setNamespaceInput(e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-700 placeholder:text-stone-400"
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500"
placeholder="Namespace"
/>
<textarea
value={queryInput}
onChange={e => setQueryInput(e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-700 placeholder:text-stone-400"
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500"
rows={2}
placeholder="Query text..."
/>
@@ -290,47 +302,55 @@ const MemoryDebugPanel = () => {
<input
value={maxChunksInput}
onChange={e => setMaxChunksInput(e.target.value)}
className="w-16 rounded-lg border border-stone-200 bg-stone-50 px-2 py-1.5 text-xs text-stone-700"
className="w-16 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-2 py-1.5 text-xs text-stone-700 dark:text-neutral-200"
placeholder="10"
/>
<span className="text-[11px] text-stone-400">max chunks</span>
<span className="text-[11px] text-stone-400 dark:text-neutral-500">max chunks</span>
<div className="flex-1" />
<button
type="button"
onClick={() => void handleQuery()}
disabled={queryLoading || !namespaceInput.trim() || !queryInput.trim()}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-100 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{queryLoading ? '...' : 'Query'}
</button>
<button
type="button"
onClick={() => void handleRecall()}
disabled={recallLoading || !namespaceInput.trim()}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-100 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{recallLoading ? '...' : 'Recall'}
</button>
</div>
{queryError && <div className="text-xs text-coral-600">Query: {queryError}</div>}
{recallError && <div className="text-xs text-coral-600">Recall: {recallError}</div>}
{queryError && (
<div className="text-xs text-coral-600 dark:text-coral-300">Query: {queryError}</div>
)}
{recallError && (
<div className="text-xs text-coral-600 dark:text-coral-300">Recall: {recallError}</div>
)}
{(queryResult || recallResult) && (
<div className="space-y-2">
{queryResult && (
<div>
<div className="text-[11px] font-medium text-stone-500 mb-1">Query result</div>
<div className="text-[11px] font-medium text-stone-500 dark:text-neutral-400 mb-1">
Query result
</div>
<MemoryTextWithEntities
text={queryResult.text ?? ''}
entities={queryResult.entities}
className="rounded-lg border border-stone-200 bg-stone-50 p-2 text-[11px] leading-5 min-h-12 whitespace-pre-wrap"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-[11px] leading-5 min-h-12 whitespace-pre-wrap"
/>
</div>
)}
{recallResult && (
<div>
<div className="text-[11px] font-medium text-stone-500 mb-1">Recall result</div>
<div className="text-[11px] font-medium text-stone-500 dark:text-neutral-400 mb-1">
Recall result
</div>
<MemoryTextWithEntities
text={recallResult.text ?? ''}
entities={recallResult.entities}
className="rounded-lg border border-stone-200 bg-stone-50 p-2 text-[11px] leading-5 min-h-12 whitespace-pre-wrap"
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-[11px] leading-5 min-h-12 whitespace-pre-wrap"
/>
</div>
)}
@@ -340,8 +360,10 @@ const MemoryDebugPanel = () => {
{/* Clear Namespace */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900">Clear Namespace</h3>
<p className="text-xs text-stone-400">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Clear Namespace
</h3>
<p className="text-xs text-stone-400 dark:text-neutral-500">
Permanently delete all documents within a namespace.
</p>
<div className="flex gap-2">
@@ -349,7 +371,7 @@ const MemoryDebugPanel = () => {
<select
value={clearNamespaceInput}
onChange={e => setClearNamespaceInput(e.target.value)}
className="flex-1 rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-700">
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200">
<option value="">Select namespace...</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
@@ -361,7 +383,7 @@ const MemoryDebugPanel = () => {
<input
value={clearNamespaceInput}
onChange={e => setClearNamespaceInput(e.target.value)}
className="flex-1 rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-700 placeholder:text-stone-400"
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500"
placeholder="e.g. skill:gmail:user@example.com"
/>
)}
@@ -369,12 +391,16 @@ const MemoryDebugPanel = () => {
type="button"
onClick={() => void handleClearNamespace()}
disabled={clearLoading || !clearNamespaceInput.trim()}
className="rounded-lg border border-coral-200 bg-coral-50 px-3 py-1.5 text-xs font-medium text-coral-600 hover:bg-coral-100 disabled:opacity-50">
className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-1.5 text-xs font-medium text-coral-600 dark:text-coral-300 hover:bg-coral-100 dark:bg-coral-500/20 disabled:opacity-50">
{clearLoading ? '...' : 'Clear'}
</button>
</div>
{clearSuccess && <div className="text-xs text-sage-600">{clearSuccess}</div>}
{clearError && <div className="text-xs text-coral-600">{clearError}</div>}
{clearSuccess && (
<div className="text-xs text-sage-600 dark:text-sage-300">{clearSuccess}</div>
)}
{clearError && (
<div className="text-xs text-coral-600 dark:text-coral-300">{clearError}</div>
)}
</section>
</div>
</div>
@@ -26,7 +26,7 @@ function statusDot(status: ChannelConnectionStatus): string {
case 'error':
return 'bg-coral-500';
default:
return 'bg-stone-300';
return 'bg-stone-300 dark:bg-neutral-700';
}
}
@@ -46,13 +46,13 @@ function statusLabel(status: ChannelConnectionStatus, t: (key: string) => string
function statusColor(status: ChannelConnectionStatus): string {
switch (status) {
case 'connected':
return 'text-sage-600';
return 'text-sage-600 dark:text-sage-300';
case 'connecting':
return 'text-amber-600';
return 'text-amber-600 dark:text-amber-300';
case 'error':
return 'text-coral-600';
return 'text-coral-600 dark:text-coral-300';
default:
return 'text-stone-400';
return 'text-stone-400 dark:text-neutral-500';
}
}
@@ -115,8 +115,10 @@ const MessagingPanel = () => {
<div className="p-4 space-y-4">
{/* Default channel selector */}
<section className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">{t('channels.defaultMessaging')}</h3>
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('channels.defaultMessaging')}
</h3>
<div className="grid grid-cols-2 gap-2">
{definitions.map(def => {
const channelId = def.id as ChannelType;
@@ -130,17 +132,17 @@ const MessagingPanel = () => {
disabled={busy[busyKey]}
className={`rounded-lg border px-3 py-2 text-sm transition-colors ${
selected
? 'border-primary-500/60 bg-primary-50 text-primary-600'
: 'border-stone-200 bg-stone-50 text-stone-600 hover:border-stone-300'
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:border-neutral-700 dark:hover:border-neutral-700'
}`}>
{def.display_name}
</button>
);
})}
</div>
<p className="text-xs text-stone-400">
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('channels.activeRoute')}:{' '}
<span className="text-primary-600">{recommendedRoute}</span>
<span className="text-primary-600 dark:text-primary-300">{recommendedRoute}</span>
</p>
</section>
@@ -151,18 +153,20 @@ const MessagingPanel = () => {
)}
{loading && (
<div className="rounded-xl border border-stone-200 bg-white p-4 text-sm text-stone-400">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 text-sm text-stone-400 dark:text-neutral-500">
{t('channels.loadingDefinitions')}
</div>
)}
{/* Channel cards — click to open the shared ChannelSetupModal */}
{!loading && (
<section className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('channels.channelConnections')}
</h3>
<p className="text-xs text-stone-400">{t('channels.configureAuthModes')}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('channels.configureAuthModes')}
</p>
<div className="space-y-2">
{configurableChannels.map(def => {
const channelId = def.id as ChannelType;
@@ -174,12 +178,12 @@ const MessagingPanel = () => {
key={channelId}
type="button"
onClick={() => setChannelModalDef(def)}
className="w-full rounded-lg border border-stone-200 bg-stone-50 p-3 text-left transition-colors hover:bg-white hover:border-stone-300">
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 text-left transition-colors hover:bg-white dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:border-stone-300 dark:border-neutral-700 dark:hover:border-neutral-700">
<div className="flex items-center gap-3">
<span className="text-lg flex-shrink-0">{icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-stone-900">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{def.display_name}
</span>
<div
@@ -189,10 +193,12 @@ const MessagingPanel = () => {
{statusLabel(status, t)}
</span>
</div>
<p className="text-xs text-stone-500 mt-0.5">{def.description}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{def.description}
</p>
</div>
<svg
className="w-4 h-4 text-stone-400 flex-shrink-0"
className="w-4 h-4 text-stone-400 dark:text-neutral-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -113,19 +113,23 @@ const NotificationRoutingPanel = () => {
<div className="p-4 space-y-4">
{stats && (
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100">
<p className="text-sm font-medium text-stone-900">Pipeline stats</p>
<div className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100 dark:border-neutral-800">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
Pipeline stats
</p>
</div>
<div className="grid grid-cols-3 divide-x divide-stone-100">
<div className="grid grid-cols-3 divide-x divide-stone-100 dark:divide-neutral-800">
{[
{ label: 'Total', value: stats.total },
{ label: 'Unread', value: stats.unread },
{ label: 'Unscored', value: stats.unscored },
].map(({ label, value }) => (
<div key={label} className="px-4 py-3 text-center">
<p className="text-lg font-semibold text-stone-900">{value}</p>
<p className="text-xs text-stone-500">{label}</p>
<p className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{value}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">{label}</p>
</div>
))}
</div>
@@ -133,10 +137,10 @@ const NotificationRoutingPanel = () => {
)}
{/* Info card */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl">
<div className="p-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-xl">
<div className="flex items-start space-x-3">
<svg
className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5"
className="w-5 h-5 text-blue-600 dark:text-blue-300 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -148,8 +152,10 @@ const NotificationRoutingPanel = () => {
/>
</svg>
<div>
<p className="font-medium text-blue-800 text-sm">Notification Intelligence</p>
<p className="text-blue-700 text-xs mt-1 leading-relaxed">
<p className="font-medium text-blue-800 dark:text-blue-200 text-sm">
Notification Intelligence
</p>
<p className="text-blue-700 dark:text-blue-300 text-xs mt-1 leading-relaxed">
Every notification from your connected accounts is scored by a local AI model.
High-importance notifications are automatically routed to your orchestrator agent so
nothing critical slips through.
@@ -159,31 +165,31 @@ const NotificationRoutingPanel = () => {
</div>
{/* How it works */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100">
<p className="text-sm font-medium text-stone-900">How it works</p>
<div className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100 dark:border-neutral-800">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">How it works</p>
</div>
<div className="divide-y divide-stone-100">
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{[
{
label: 'Drop',
desc: 'Noise / spam — stored but not surfaced',
color: 'bg-stone-100 text-stone-600',
color: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300',
},
{
label: 'Acknowledge',
desc: 'Low-priority — shown in notification center',
color: 'bg-blue-100 text-blue-700',
color: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
},
{
label: 'React',
desc: 'Medium-priority — triggers a focused agent response',
color: 'bg-amber-100 text-amber-700',
color: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
},
{
label: 'Escalate',
desc: 'High-priority — forwarded to orchestrator agent',
color: 'bg-red-100 text-red-700',
color: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300',
},
].map(row => (
<div key={row.label} className="flex items-center gap-3 px-4 py-3">
@@ -191,17 +197,19 @@ const NotificationRoutingPanel = () => {
className={`flex-shrink-0 px-2 py-0.5 rounded text-[11px] font-semibold ${row.color}`}>
{row.label}
</span>
<span className="text-xs text-stone-600">{row.desc}</span>
<span className="text-xs text-stone-600 dark:text-neutral-300">{row.desc}</span>
</div>
))}
</div>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100">
<p className="text-sm font-medium text-stone-900">Per-provider routing</p>
<div className="bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100 dark:border-neutral-800">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
Per-provider routing
</p>
</div>
<div className="divide-y divide-stone-100">
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{providers.map(provider => {
const hasLoadError = Boolean(loadErrors[provider]);
const isLoaded = Boolean(loadedProviders[provider]);
@@ -214,8 +222,10 @@ const NotificationRoutingPanel = () => {
return (
<div key={provider} className="px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-stone-800 capitalize">{provider}</p>
<label className="text-xs text-stone-600 flex items-center gap-2">
<p className="text-sm font-medium text-stone-800 dark:text-neutral-100 capitalize">
{provider}
</p>
<label className="text-xs text-stone-600 dark:text-neutral-300 flex items-center gap-2">
Enabled
<input
type="checkbox"
@@ -227,7 +237,7 @@ const NotificationRoutingPanel = () => {
/>
</label>
</div>
<label className="flex items-center gap-2 text-xs text-stone-600">
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
Threshold
<input
className="flex-1"
@@ -245,7 +255,7 @@ const NotificationRoutingPanel = () => {
/>
<span>{s.importance_threshold.toFixed(2)}</span>
</label>
<label className="text-xs text-stone-600 flex items-center gap-2">
<label className="text-xs text-stone-600 dark:text-neutral-300 flex items-center gap-2">
Route to orchestrator
<input
type="checkbox"
@@ -257,7 +267,7 @@ const NotificationRoutingPanel = () => {
/>
</label>
{hasLoadError ? (
<p className="text-xs text-red-600">
<p className="text-xs text-red-600 dark:text-red-300">
Failed to load settings. Reopen this panel to retry.
</p>
) : null}
@@ -89,21 +89,21 @@ const NotificationsPanel = () => {
<div className="p-4 space-y-4">
{/* Do Not Disturb */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-2 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.notifications.doNotDisturb')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.notifications.suppressAll')}
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{t('settings.notifications.suppressAllDesc')}
</p>
</div>
{dndLoading ? (
<div className="w-11 h-6 rounded-full bg-stone-200 animate-pulse" />
<div className="w-11 h-6 rounded-full bg-stone-200 dark:bg-neutral-800 animate-pulse" />
) : (
<button
onClick={() => {
@@ -111,13 +111,13 @@ const NotificationsPanel = () => {
}}
disabled={dndSaving}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 disabled:opacity-70 ${
dnd ? 'bg-primary-500' : 'bg-stone-400'
dnd ? 'bg-primary-500' : 'bg-stone-400 dark:bg-neutral-600'
}`}
role="switch"
aria-checked={dnd}
aria-label={t('settings.notifications.toggleDnd')}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white dark:bg-neutral-900 shadow ring-0 transition duration-200 ease-in-out ${
dnd ? 'translate-x-5' : 'translate-x-0'
}`}
/>
@@ -129,30 +129,32 @@ const NotificationsPanel = () => {
{/* Categories */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-2 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.notifications.categories')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden divide-y divide-stone-100">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden divide-y divide-stone-100 dark:divide-neutral-800">
{CATEGORIES.map(cat => {
const enabled = preferences[cat.id];
return (
<div key={cat.id} className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900">{cat.title}</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{cat.title}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{cat.description}
</p>
</div>
<button
onClick={() => handleToggle(cat.id)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 ${
enabled ? 'bg-primary-500' : 'bg-stone-400'
enabled ? 'bg-primary-500' : 'bg-stone-400 dark:bg-neutral-600'
}`}
role="switch"
aria-checked={enabled}
aria-label={`Toggle ${cat.title} notifications`}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white dark:bg-neutral-900 shadow ring-0 transition duration-200 ease-in-out ${
enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
@@ -162,7 +164,7 @@ const NotificationsPanel = () => {
})}
</div>
<p className="text-xs text-stone-500 leading-relaxed px-1 mt-2">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.notifications.categoryFooter')}
</p>
</div>
@@ -19,11 +19,15 @@ interface AnnotatedCapability extends Capability {
}
const KIND_BADGE_CLASS: Record<PrivacyDataKind, string> = {
raw: 'bg-sage-50 text-sage-700 border-sage-200',
derived: 'bg-amber-50 text-amber-700 border-amber-200',
credentials: 'bg-stone-100 text-stone-700 border-stone-200',
diagnostics: 'bg-primary-50 text-primary-700 border-primary-200',
metadata: 'bg-stone-50 text-stone-600 border-stone-200',
raw: 'bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border-sage-200 dark:border-sage-500/30',
derived:
'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30',
credentials:
'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800',
diagnostics:
'bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 border-primary-200 dark:border-primary-500/30',
metadata:
'bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 border-stone-200 dark:border-neutral-800',
};
function kindLabel(kind: PrivacyDataKind, t: (key: string) => string): string {
@@ -105,33 +109,43 @@ const PrivacyPanel = () => {
<div className="p-4 space-y-4">
{/* What leaves my computer */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-3 px-1">
{t('privacy.whatLeavesComputer')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
{loadState === 'loading' && (
<p className="p-4 text-xs text-stone-500">{t('privacy.loading')}</p>
<p className="p-4 text-xs text-stone-500 dark:text-neutral-400">
{t('privacy.loading')}
</p>
)}
{loadState === 'error' && (
<p className="p-4 text-xs text-stone-500" data-testid="privacy-load-error">
<p
className="p-4 text-xs text-stone-500 dark:text-neutral-400"
data-testid="privacy-load-error">
{t('privacy.loadError')}
</p>
)}
{loadState === 'ready' && capabilities.length === 0 && (
<p className="p-4 text-xs text-stone-500">{t('privacy.noCapabilities')}</p>
<p className="p-4 text-xs text-stone-500 dark:text-neutral-400">
{t('privacy.noCapabilities')}
</p>
)}
{loadState === 'ready' && capabilities.length > 0 && (
<ul className="divide-y divide-stone-100" data-testid="privacy-capability-list">
<ul
className="divide-y divide-stone-100 dark:divide-neutral-800"
data-testid="privacy-capability-list">
{capabilities.map(cap => (
<li key={cap.id} className="p-4" data-testid={`privacy-row-${cap.id}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-stone-900">{cap.name}</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{cap.name}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{cap.description}
</p>
{cap.privacy.destinations.length > 0 && (
<p className="text-xs text-stone-400 mt-1">
<p className="text-xs text-stone-400 dark:text-neutral-500 mt-1">
{t('privacy.sentTo')}: {cap.privacy.destinations.join(', ')}
</p>
)}
@@ -141,7 +155,7 @@ const PrivacyPanel = () => {
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded border ${KIND_BADGE_CLASS[cap.privacy.data_kind]}`}>
{kindLabel(cap.privacy.data_kind, t)}
</span>
<span className="text-[10px] text-stone-500">
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
{cap.privacy.leaves_device
? t('privacy.leavesDevice')
: t('privacy.staysLocal')}
@@ -157,16 +171,16 @@ const PrivacyPanel = () => {
{/* Analytics Section */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-3 px-1">
{t('privacy.anonymizedAnalytics')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('privacy.shareAnonymizedData')}
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{t('privacy.shareAnonymizedDataDesc')}
</p>
</div>
@@ -175,12 +189,12 @@ const PrivacyPanel = () => {
onClick={handleToggleAnalytics}
data-testid="privacy-analytics-toggle"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
analyticsEnabled ? 'bg-primary-500' : 'bg-stone-600'
analyticsEnabled ? 'bg-primary-500' : 'bg-stone-600 dark:bg-neutral-600'
}`}
role="switch"
aria-checked={analyticsEnabled}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white dark:bg-neutral-900 shadow ring-0 transition duration-200 ease-in-out ${
analyticsEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
@@ -191,16 +205,16 @@ const PrivacyPanel = () => {
{/* Meeting Follow-ups Section (#1299) */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-3 px-1">
{t('privacy.meetingFollowUps')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('privacy.autoHandoffMeet')}
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1 leading-relaxed">
{t('privacy.autoHandoffMeetDesc')}
</p>
</div>
@@ -209,12 +223,12 @@ const PrivacyPanel = () => {
onClick={handleToggleMeetAutoHandoff}
aria-label="Auto-handoff Google Meet transcripts to the orchestrator"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
meetAutoHandoff ? 'bg-primary-500' : 'bg-stone-600'
meetAutoHandoff ? 'bg-primary-500' : 'bg-stone-600 dark:bg-neutral-600'
}`}
role="switch"
aria-checked={meetAutoHandoff}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white dark:bg-neutral-900 shadow ring-0 transition duration-200 ease-in-out ${
meetAutoHandoff ? 'translate-x-5' : 'translate-x-0'
}`}
/>
@@ -224,10 +238,10 @@ const PrivacyPanel = () => {
</div>
{/* Info Box */}
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
<div className="p-4 bg-stone-50 dark:bg-neutral-800/60 rounded-xl border border-stone-200 dark:border-neutral-800">
<div className="flex items-start space-x-3">
<svg
className="w-5 h-5 text-stone-400 mt-0.5 flex-shrink-0"
className="w-5 h-5 text-stone-400 dark:text-neutral-500 mt-0.5 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20">
<path
@@ -237,7 +251,7 @@ const PrivacyPanel = () => {
/>
</svg>
<div>
<p className="text-xs text-stone-500 leading-relaxed">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('privacy.analyticsDisclaimer')}
All analytics and bug reports are fully anonymized. When enabled, we collect crash
information and device type (via Sentry), plus anonymous usage analytics such as
@@ -223,20 +223,22 @@ const RecoveryPhrasePanel = () => {
</svg>
</div>
<p className="text-sm font-medium text-sage-500">{t('mnemonic.phraseSaved')}</p>
<p className="text-xs text-stone-500">{t('mnemonic.walletReady')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('mnemonic.walletReady')}
</p>
</div>
) : (
<>
{mode === 'generate' ? (
<>
<div className="mb-4 space-y-3">
<p className="text-sm text-stone-600 leading-relaxed">
<p className="text-sm text-stone-600 dark:text-neutral-300 leading-relaxed">
{t('mnemonic.writeDownWords')} {MNEMONIC_GENERATE_WORD_COUNT}{' '}
{t('mnemonic.wordsInOrder')}
</p>
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-amber-50 border border-amber-200/70">
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30">
<svg
className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5"
className="w-4 h-4 text-amber-600 dark:text-amber-300 flex-shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -247,19 +249,19 @@ const RecoveryPhrasePanel = () => {
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<p className="text-xs text-amber-800 leading-relaxed">
<p className="text-xs text-amber-800 dark:text-amber-200 leading-relaxed">
{t('mnemonic.cannotRecover')}
</p>
</div>
</div>
<div className="bg-stone-50 rounded-2xl p-4 mb-4 border border-stone-200">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-2xl p-4 mb-4 border border-stone-200 dark:border-neutral-800">
<div className="grid grid-cols-3 gap-2">
{words.map((word, index) => (
<div
key={index}
className="flex items-center gap-2 bg-white rounded-lg px-3 py-2 text-sm border border-stone-200">
<span className="text-stone-500 font-mono text-xs w-5 text-right">
className="flex items-center gap-2 bg-white dark:bg-neutral-900 rounded-lg px-3 py-2 text-sm border border-stone-200 dark:border-neutral-800">
<span className="text-stone-500 dark:text-neutral-400 font-mono text-xs w-5 text-right">
{index + 1}.
</span>
<span className="font-mono font-medium">{word}</span>
@@ -270,7 +272,7 @@ const RecoveryPhrasePanel = () => {
<button
onClick={handleCopy}
className="w-full flex items-center justify-center gap-2 border border-stone-200 hover:border-stone-300 font-medium py-2.5 text-sm rounded-xl text-stone-700 transition-all duration-200 mb-3">
className="w-full flex items-center justify-center gap-2 border border-stone-200 dark:border-neutral-800 hover:border-stone-300 dark:border-neutral-700 dark:hover:border-neutral-700 font-medium py-2.5 text-sm rounded-xl text-stone-700 dark:text-neutral-200 transition-all duration-200 mb-3">
{copied ? (
<>
<svg
@@ -304,7 +306,7 @@ const RecoveryPhrasePanel = () => {
<button
onClick={() => switchMode('import')}
className="w-full text-center text-sm text-primary-400 hover:text-primary-600 transition-colors mb-3">
className="w-full text-center text-sm text-primary-400 hover:text-primary-600 dark:text-primary-300 transition-colors mb-3">
{t('mnemonic.alreadyHavePhrase')}
</button>
@@ -315,19 +317,23 @@ const RecoveryPhrasePanel = () => {
onChange={e => setConfirmed(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-stone-500 text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-stone-700">{t('mnemonic.consentSaved')}</span>
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('mnemonic.consentSaved')}
</span>
</label>
</>
) : (
<>
<div className="mb-4">
<p className="text-sm text-stone-600 leading-relaxed">
<p className="text-sm text-stone-600 dark:text-neutral-300 leading-relaxed">
{t('mnemonic.enterPhraseToRestore')}
</p>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-stone-500">{t('mnemonic.words')}:</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">
{t('mnemonic.words')}:
</span>
{BIP39_IMPORT_LENGTHS.map(len => (
<button
key={len}
@@ -335,19 +341,19 @@ const RecoveryPhrasePanel = () => {
onClick={() => handleWordCountChange(len)}
className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
selectedWordCount === len
? 'bg-primary-500/20 border-primary-500/40 text-primary-600 border'
: 'border border-stone-200 text-stone-500 hover:border-stone-300'
? 'bg-primary-500/20 border-primary-500/40 text-primary-600 dark:text-primary-300 border'
: 'border border-stone-200 dark:border-neutral-800 text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:border-neutral-700'
}`}>
{len}
</button>
))}
</div>
<div className="bg-stone-50 rounded-2xl p-4 mb-4 border border-stone-200">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-2xl p-4 mb-4 border border-stone-200 dark:border-neutral-800">
<div className="grid grid-cols-3 gap-2">
{importWords.map((word, index) => (
<div key={index} className="flex items-center gap-1.5">
<span className="text-stone-500 font-mono text-xs w-5 text-right shrink-0">
<span className="text-stone-500 dark:text-neutral-400 font-mono text-xs w-5 text-right shrink-0">
{index + 1}.
</span>
<input
@@ -361,12 +367,12 @@ const RecoveryPhrasePanel = () => {
onKeyDown={e => handleImportKeyDown(index, e)}
autoComplete="off"
spellCheck={false}
className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white text-stone-900 outline-none transition-colors ${
className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 outline-none transition-colors ${
importValid === false && word.trim()
? 'border-coral-400 focus:border-coral-300'
? 'border-coral-400 focus:border-coral-300 dark:border-coral-500/40'
: importValid === true
? 'border-sage-400 focus:border-sage-300'
: 'border-stone-200 focus:border-primary-400'
? 'border-sage-400 focus:border-sage-300 dark:border-sage-500/40'
: 'border-stone-200 dark:border-neutral-800 focus:border-primary-400'
}`}
/>
</div>
@@ -390,7 +396,7 @@ const RecoveryPhrasePanel = () => {
<button
onClick={() => switchMode('generate')}
className="w-full text-center text-sm text-primary-400 hover:text-primary-600 transition-colors mb-3">
className="w-full text-center text-sm text-primary-400 hover:text-primary-600 dark:text-primary-300 transition-colors mb-3">
{t('mnemonic.generateNewPhrase')}
</button>
</>
@@ -399,7 +405,7 @@ const RecoveryPhrasePanel = () => {
{error && (
<div
role="alert"
className="flex items-start gap-2.5 p-3 mb-3 rounded-xl bg-coral-50 border border-coral-200/70">
className="flex items-start gap-2.5 p-3 mb-3 rounded-xl bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30">
<svg
className="w-4 h-4 text-coral-500 flex-shrink-0 mt-0.5"
fill="none"
@@ -412,7 +418,9 @@ const RecoveryPhrasePanel = () => {
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<p className="text-xs text-coral-700 leading-relaxed">{error}</p>
<p className="text-xs text-coral-700 dark:text-coral-300 leading-relaxed">
{error}
</p>
</div>
)}
@@ -19,9 +19,11 @@ const DebugSection = ({
<button
type="button"
onClick={() => setIsOpen(prev => !prev)}
className="flex w-full items-center justify-between text-sm font-semibold text-stone-900">
className="flex w-full items-center justify-between text-sm font-semibold text-stone-900 dark:text-neutral-100">
<span>Debug & Diagnostics</span>
<span className="text-xs text-stone-400">{isOpen ? 'Collapse' : 'Expand'}</span>
<span className="text-xs text-stone-400 dark:text-neutral-500">
{isOpen ? 'Collapse' : 'Expand'}
</span>
</button>
{isOpen && <ScreenIntelligenceDebugPanel state={state} />}
</section>
@@ -110,10 +112,12 @@ const ScreenAwarenessDebugPanel = () => {
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
{/* Advanced policy settings */}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Screen Intelligence Policy</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Screen Intelligence Policy
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Baseline FPS</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">Baseline FPS</span>
<input
type="number"
min={0.2}
@@ -121,14 +125,14 @@ const ScreenAwarenessDebugPanel = () => {
step={0.1}
value={baselineFps}
onChange={event => setBaselineFps(event.target.value)}
className="w-24 rounded border border-stone-200 bg-white px-2 py-1 text-xs text-stone-700"
className="w-24 rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<div>
<span className="text-sm text-stone-700">Use Vision Model</span>
<p className="text-xs text-stone-400">
<span className="text-sm text-stone-700 dark:text-neutral-200">Use Vision Model</span>
<p className="text-xs text-stone-400 dark:text-neutral-500">
Send screenshots to a vision LLM for richer context. When off, only OCR text is used
with a text LLM faster and no vision model required.
</p>
@@ -140,10 +144,10 @@ const ScreenAwarenessDebugPanel = () => {
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<div>
<span className="text-sm text-stone-700">Keep Screenshots</span>
<p className="text-xs text-stone-400">
<span className="text-sm text-stone-700 dark:text-neutral-200">Keep Screenshots</span>
<p className="text-xs text-stone-400 dark:text-neutral-500">
Save captured screenshots to the workspace instead of deleting after processing
</p>
</div>
@@ -155,22 +159,26 @@ const ScreenAwarenessDebugPanel = () => {
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">Allowlist (one rule per line)</div>
<div className="text-xs text-stone-600 dark:text-neutral-300">
Allowlist (one rule per line)
</div>
<textarea
value={allowlistText}
onChange={event => setAllowlistText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600">Denylist (one rule per line)</div>
<div className="text-xs text-stone-600 dark:text-neutral-300">
Denylist (one rule per line)
</div>
<textarea
value={denylistText}
onChange={event => setDenylistText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
@@ -178,16 +186,20 @@ const ScreenAwarenessDebugPanel = () => {
type="button"
onClick={() => void saveConfig()}
disabled={isSavingConfig}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isSavingConfig ? 'Saving…' : 'Save Screen Intelligence Settings'}
</button>
{configError && <div className="text-xs text-red-600">{configError}</div>}
{configError && (
<div className="text-xs text-red-600 dark:text-red-300">{configError}</div>
)}
</section>
{/* Session stats */}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Session Stats</h3>
<div className="text-sm text-stone-600 space-y-1">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Session Stats
</h3>
<div className="text-sm text-stone-600 dark:text-neutral-300 space-y-1">
<div>Frames (ephemeral): {status?.session.frames_in_memory ?? 0}</div>
<div>Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</div>
<div>Vision: {status?.session.vision_state ?? 'idle'}</div>
@@ -204,30 +216,34 @@ const ScreenAwarenessDebugPanel = () => {
{/* Vision summaries */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">Vision Summaries</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Vision Summaries
</h3>
<button
type="button"
onClick={() => void refreshVision(10)}
disabled={isLoadingVision}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 text-xs text-stone-600 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 text-xs text-stone-600 dark:text-neutral-300 disabled:opacity-50">
{isLoadingVision ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{recentVisionSummaries.length === 0 ? (
<div className="text-xs text-stone-500">No summaries yet.</div>
<div className="text-xs text-stone-500 dark:text-neutral-400">No summaries yet.</div>
) : (
<div className="space-y-2">
{recentVisionSummaries.map(summary => (
<div
key={summary.id}
className="rounded-xl border border-stone-200 bg-white p-3 text-xs text-stone-200">
<div className="text-stone-500">
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 text-xs text-stone-200">
<div className="text-stone-500 dark:text-neutral-400">
{new Date(summary.captured_at_ms).toLocaleTimeString()} ·{' '}
{summary.app_name ?? 'Unknown App'}
{summary.window_title ? ` · ${summary.window_title}` : ''}
</div>
<div className="mt-1 text-stone-800">{summary.actionable_notes}</div>
<div className="mt-1 text-stone-800 dark:text-neutral-100">
{summary.actionable_notes}
</div>
</div>
))}
</div>
@@ -250,14 +266,14 @@ const ScreenAwarenessDebugPanel = () => {
{/* Platform unsupported notice */}
{status !== null && !status.platform_supported && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
<div className="rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
Screen Intelligence V1 is currently supported on macOS only.
</div>
)}
{/* Error notice */}
{lastError && (
<div className="rounded-xl border border-red-300 bg-red-50 p-3 text-sm text-red-600">
<div className="rounded-xl border border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10 p-3 text-sm text-red-600 dark:text-red-300">
{lastError}
</div>
)}
@@ -136,12 +136,14 @@ const ScreenIntelligencePanel = () => {
)}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.features.screenAwareness')}
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">{t('common.enabled')}</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('common.enabled')}
</span>
<input
type="checkbox"
checked={enabled}
@@ -149,8 +151,10 @@ const ScreenIntelligencePanel = () => {
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">{t('settings.screenAwareness.mode')}</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.screenAwareness.mode')}
</span>
<select
value={policyMode}
onChange={event =>
@@ -160,7 +164,7 @@ const ScreenIntelligencePanel = () => {
: 'all_except_blacklist'
)
}
className="rounded border border-stone-200 bg-white px-2 py-1 text-xs text-stone-700">
className="rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200">
<option value="all_except_blacklist">
{t('settings.screenAwareness.allExceptBlacklist')}
</option>
@@ -168,8 +172,8 @@ const ScreenIntelligencePanel = () => {
</select>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.screenAwareness.screenMonitoring')}
</span>
<input
@@ -188,17 +192,19 @@ const ScreenIntelligencePanel = () => {
type="button"
onClick={() => void saveConfig()}
disabled={isSavingConfig}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isSavingConfig ? 'Saving…' : t('settings.screenAwareness.saveSettings')}
</button>
{configError && <div className="text-xs text-red-600">{configError}</div>}
{configError && (
<div className="text-xs text-red-600 dark:text-red-300">{configError}</div>
)}
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.screenAwareness.session')}
</h3>
<div className="text-sm text-stone-600 space-y-1">
<div className="text-sm text-stone-600 dark:text-neutral-300 space-y-1">
<div>
{t('settings.screenAwareness.status')}:{' '}
{status?.session.active
@@ -221,34 +227,34 @@ const ScreenIntelligencePanel = () => {
})
}
disabled={startDisabled}
className="rounded-lg border border-green-400 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
className="rounded-lg border border-green-400 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-300 disabled:opacity-50">
{isStartingSession ? 'Starting…' : t('settings.screenAwareness.startSession')}
</button>
<button
type="button"
onClick={() => void stopSession('manual_stop')}
disabled={stopDisabled}
className="rounded-lg border border-red-400 bg-red-50 px-3 py-2 text-sm text-red-700 disabled:opacity-50">
className="rounded-lg border border-red-400 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300 disabled:opacity-50">
{isStoppingSession ? 'Stopping…' : t('settings.screenAwareness.stopSession')}
</button>
<button
type="button"
onClick={() => void flushVision()}
disabled={isFlushingVision || !status?.session.active}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isFlushingVision ? 'Analyzing…' : t('settings.screenAwareness.analyzeNow')}
</button>
</div>
</section>
{status !== null && !status.platform_supported && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
<div className="rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 dark:border-amber-500/30 p-3 text-sm text-amber-700 dark:text-amber-300">
{t('settings.screenAwareness.macosOnly')}
</div>
)}
{lastError && (
<div className="rounded-xl border border-red-300 bg-red-50 p-3 text-sm text-red-600">
<div className="rounded-xl border border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10 p-3 text-sm text-red-600 dark:text-red-300">
{lastError}
</div>
)}
@@ -185,7 +185,10 @@ const TeamInvitesPanel = () => {
{/* Invites list */}
{isLoadingInvites && invites.length === 0 ? (
<div className="flex items-center justify-center py-8">
<svg className="w-5 h-5 text-stone-500 animate-spin" fill="none" viewBox="0 0 24 24">
<svg
className="w-5 h-5 text-stone-500 dark:text-neutral-400 animate-spin"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -200,7 +203,9 @@ const TeamInvitesPanel = () => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="ml-3 text-sm text-stone-500">Loading invites...</span>
<span className="ml-3 text-sm text-stone-500 dark:text-neutral-400">
Loading invites...
</span>
</div>
) : invites.length > 0 ? (
<div className="space-y-2">
@@ -213,8 +218,8 @@ const TeamInvitesPanel = () => {
key={invite._id}
className={`rounded-xl border p-3 ${
isInactive
? 'border-stone-200 bg-stone-50 opacity-60'
: 'border-stone-200 bg-white'
? 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 opacity-60'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900'
}`}>
<div className="flex items-center justify-between mb-2">
{/* Code with status label */}
@@ -222,8 +227,8 @@ const TeamInvitesPanel = () => {
<code
className={`text-sm font-mono px-2 py-1 rounded-lg ${
isInactive
? 'text-stone-500 bg-stone-100'
: 'text-stone-900 bg-stone-200'
? 'text-stone-500 dark:text-neutral-400 bg-stone-100 dark:bg-neutral-800'
: 'text-stone-900 dark:text-neutral-100 bg-stone-200 dark:bg-neutral-800'
}`}>
{invite.code}
</code>
@@ -245,8 +250,8 @@ const TeamInvitesPanel = () => {
disabled={status !== 'active'}
className={`p-1.5 rounded-lg transition-colors ${
status === 'active'
? 'text-stone-500 hover:text-stone-900 hover:bg-stone-100'
: 'text-stone-600 cursor-not-allowed'
? 'text-stone-500 dark:text-neutral-400 hover:text-stone-900 dark:hover:text-neutral-100 hover:bg-stone-100 dark:hover:bg-neutral-800'
: 'text-stone-600 dark:text-neutral-300 cursor-not-allowed'
}`}
aria-label="Copy invite code">
{copiedId === invite._id ? (
@@ -282,7 +287,7 @@ const TeamInvitesPanel = () => {
<button
onClick={() => handleRevoke(invite._id, invite.code)}
disabled={revokingId === invite._id}
className="p-1.5 rounded-lg text-stone-500 hover:text-coral-400 hover:bg-coral-500/10 transition-colors disabled:opacity-50"
className="p-1.5 rounded-lg text-stone-500 dark:text-neutral-400 hover:text-coral-400 hover:bg-coral-500/10 transition-colors disabled:opacity-50"
aria-label="Revoke invite">
<svg
className="w-4 h-4"
@@ -300,7 +305,7 @@ const TeamInvitesPanel = () => {
)}
</div>
</div>
<div className="flex items-center gap-3 text-xs text-stone-500">
<div className="flex items-center gap-3 text-xs text-stone-500 dark:text-neutral-400">
<span>
Uses: {invite.currentUses}
{invite.maxUses > 0 ? `/${invite.maxUses}` : ''}
@@ -318,7 +323,7 @@ const TeamInvitesPanel = () => {
) : (
<div className="text-center py-8">
<svg
className="w-10 h-10 mx-auto text-stone-600 mb-3"
className="w-10 h-10 mx-auto text-stone-600 dark:text-neutral-300 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -329,8 +334,8 @@ const TeamInvitesPanel = () => {
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<p className="text-sm text-stone-500">No invites yet</p>
<p className="text-xs text-stone-600 mt-1">
<p className="text-sm text-stone-500 dark:text-neutral-400">No invites yet</p>
<p className="text-xs text-stone-600 dark:text-neutral-300 mt-1">
Generate an invite code to share with others
</p>
</div>
@@ -339,8 +344,10 @@ const TeamInvitesPanel = () => {
{/* Revoke Invite Confirmation Modal */}
{inviteToRevoke && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md border border-stone-200">
<h3 className="text-sm font-semibold text-stone-900 mb-4">Revoke Invite Code</h3>
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
Revoke Invite Code
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
@@ -349,10 +356,10 @@ const TeamInvitesPanel = () => {
)}
<div className="space-y-4">
<div className="text-sm text-stone-400">
<div className="text-sm text-stone-400 dark:text-neutral-500">
<p>
Are you sure you want to revoke the invite code{' '}
<code className="text-stone-900 bg-stone-100 px-1.5 py-0.5 rounded font-mono text-xs">
<code className="text-stone-900 dark:text-neutral-100 bg-stone-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded font-mono text-xs">
{inviteToRevoke.code}
</code>
?
@@ -366,7 +373,7 @@ const TeamInvitesPanel = () => {
<button
onClick={() => setInviteToRevoke(null)}
disabled={revokingId === inviteToRevoke.id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors disabled:opacity-50">
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:hover:bg-neutral-700 text-stone-700 dark:text-neutral-200 transition-colors disabled:opacity-50">
Cancel
</button>
<button
@@ -98,7 +98,7 @@ const TeamManagementPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-stone-500">{t('team.notFound')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">{t('team.notFound')}</p>
</div>
</div>
);
@@ -114,7 +114,7 @@ const TeamManagementPanel = () => {
breadcrumbs={breadcrumbs}
/>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-stone-500">{t('team.accessDenied')}</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">{t('team.accessDenied')}</p>
</div>
</div>
);
@@ -134,16 +134,18 @@ const TeamManagementPanel = () => {
<div>
<div className="p-4 space-y-4">
{/* Team Info */}
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-stone-200 flex items-center justify-center">
<span className="text-sm font-semibold text-stone-700">
<div className="w-10 h-10 rounded-lg bg-stone-200 dark:bg-neutral-800 flex items-center justify-center">
<span className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{team.name.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900">{team.name}</h3>
<p className="text-xs text-stone-500">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{team.name}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{team.subscription.plan} Plan Created{' '}
{new Date(team.createdAt).toLocaleDateString()}
</p>
@@ -153,14 +155,14 @@ const TeamManagementPanel = () => {
{/* Management Options */}
<div className="space-y-1">
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1 mb-3">
<h3 className="text-xs font-medium text-stone-500 dark:text-neutral-400 uppercase tracking-wider px-1 mb-3">
Team Management
</h3>
{/* Members */}
<button
onClick={() => navigateToSettings(`team/manage/${teamId}/members`)}
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 bg-stone-50 hover:bg-stone-100 transition-all text-left">
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-all text-left">
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-primary-500"
@@ -175,12 +177,16 @@ const TeamManagementPanel = () => {
/>
</svg>
<div>
<div className="font-medium text-sm text-stone-900">Members</div>
<p className="text-xs text-stone-500">Manage team members and roles</p>
<div className="font-medium text-sm text-stone-900 dark:text-neutral-100">
Members
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
Manage team members and roles
</p>
</div>
</div>
<svg
className="w-4 h-4 text-stone-500"
className="w-4 h-4 text-stone-500 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -196,7 +202,7 @@ const TeamManagementPanel = () => {
{/* Invites */}
<button
onClick={() => navigateToSettings(`team/manage/${teamId}/invites`)}
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 bg-stone-50 hover:bg-stone-100 transition-all text-left">
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-all text-left">
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-primary-500"
@@ -211,12 +217,16 @@ const TeamManagementPanel = () => {
/>
</svg>
<div>
<div className="font-medium text-sm text-stone-900">Invites</div>
<p className="text-xs text-stone-500">Generate and manage invite codes</p>
<div className="font-medium text-sm text-stone-900 dark:text-neutral-100">
Invites
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
Generate and manage invite codes
</p>
</div>
</div>
<svg
className="w-4 h-4 text-stone-500"
className="w-4 h-4 text-stone-500 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -232,7 +242,7 @@ const TeamManagementPanel = () => {
{/* Edit Team Settings */}
<button
onClick={handleEditTeam}
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 bg-stone-50 hover:bg-stone-100 transition-all text-left">
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-all text-left">
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-primary-500"
@@ -247,12 +257,16 @@ const TeamManagementPanel = () => {
/>
</svg>
<div>
<div className="font-medium text-sm text-stone-900">Team Settings</div>
<p className="text-xs text-stone-500">Edit team name and settings</p>
<div className="font-medium text-sm text-stone-900 dark:text-neutral-100">
Team Settings
</div>
<p className="text-xs text-stone-500 dark:text-neutral-400">
Edit team name and settings
</p>
</div>
</div>
<svg
className="w-4 h-4 text-stone-500"
className="w-4 h-4 text-stone-500 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -285,7 +299,9 @@ const TeamManagementPanel = () => {
</svg>
<div>
<div className="font-medium text-sm text-coral-400">Delete Team</div>
<p className="text-xs text-stone-500">Permanently delete this team</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
Permanently delete this team
</p>
</div>
</div>
<svg
@@ -307,18 +323,20 @@ const TeamManagementPanel = () => {
{/* Edit Team Modal */}
{isEditModalOpen && (
<div className="fixed inset-0 bg-stone-900/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md border border-stone-200">
<h3 className="text-sm font-semibold text-stone-900 mb-4">Edit Team Settings</h3>
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
Edit Team Settings
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-600">{error}</p>
<p className="text-xs text-coral-600 dark:text-coral-300">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">
<label className="block text-sm font-medium text-stone-700 dark:text-neutral-200 mb-2">
Team Name
</label>
<input
@@ -326,7 +344,7 @@ const TeamManagementPanel = () => {
value={editTeamName}
onChange={e => setEditTeamName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleUpdateTeam()}
className="w-full px-3 py-2 text-sm bg-stone-50 border border-stone-200 rounded-xl text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50"
className="w-full px-3 py-2 text-sm bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 rounded-xl text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:outline-none focus:border-primary-500/50"
placeholder="Enter team name"
/>
</div>
@@ -335,7 +353,7 @@ const TeamManagementPanel = () => {
<button
onClick={() => setIsEditModalOpen(false)}
disabled={isUpdating}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors disabled:opacity-50">
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 transition-colors disabled:opacity-50">
Cancel
</button>
<button
@@ -353,20 +371,25 @@ const TeamManagementPanel = () => {
{/* Delete Team Modal */}
{isDeleteModalOpen && (
<div className="fixed inset-0 bg-stone-900/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md border border-stone-200">
<h3 className="text-sm font-semibold text-stone-900 mb-4">Delete Team</h3>
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
Delete Team
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
<p className="text-xs text-coral-600">{error}</p>
<p className="text-xs text-coral-600 dark:text-coral-300">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="text-sm text-stone-400">
<div className="text-sm text-stone-400 dark:text-neutral-500">
<p>
Are you sure you want to delete{' '}
<strong className="text-stone-900">{teamEntry?.team.name}</strong>?
<strong className="text-stone-900 dark:text-neutral-100">
{teamEntry?.team.name}
</strong>
?
</p>
<p className="mt-2 text-coral-400">
This action cannot be undone. All team data will be permanently removed.
@@ -377,7 +400,7 @@ const TeamManagementPanel = () => {
<button
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors disabled:opacity-50">
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 transition-colors disabled:opacity-50">
Cancel
</button>
<button
@@ -111,7 +111,8 @@ const TeamMembersPanel = () => {
const roleBadgeColor: Record<string, string> = {
ADMIN: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
BILLING_MANAGER: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
MEMBER: 'bg-stone-500/20 text-stone-400 border-stone-500/30',
MEMBER:
'bg-stone-50 dark:bg-neutral-800/60 text-stone-400 dark:text-neutral-500 border-stone-500/30',
};
return (
@@ -154,14 +155,17 @@ const TeamMembersPanel = () => {
)}
{/* Member count */}
<p className="text-xs text-stone-500 px-1">
<p className="text-xs text-stone-500 dark:text-neutral-400 px-1">
{members.length} member{members.length !== 1 ? 's' : ''}
</p>
{/* Full loading state - only when loading and no existing data */}
{isLoadingMembers && members.length === 0 ? (
<div className="flex items-center justify-center py-8">
<svg className="w-5 h-5 text-stone-500 animate-spin" fill="none" viewBox="0 0 24 24">
<svg
className="w-5 h-5 text-stone-500 dark:text-neutral-400 animate-spin"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -176,14 +180,16 @@ const TeamMembersPanel = () => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="ml-3 text-sm text-stone-500">Loading members...</span>
<span className="ml-3 text-sm text-stone-500 dark:text-neutral-400">
Loading members...
</span>
</div>
) : (
<div className="space-y-2">
{members.map(member => (
<div
key={member._id}
className="flex items-center justify-between p-3 rounded-xl border border-stone-200 bg-white">
className="flex items-center justify-between p-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<div className="flex items-center gap-3 min-w-0">
{/* Avatar */}
<div className="w-8 h-8 rounded-full bg-stone-700/60 flex items-center justify-center flex-shrink-0">
@@ -193,15 +199,19 @@ const TeamMembersPanel = () => {
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-stone-900 truncate">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{displayName(member)}
</span>
{isCurrentUser(member) && (
<span className="text-[10px] text-stone-500">(You)</span>
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
(You)
</span>
)}
</div>
{member.user.username && (
<p className="text-xs text-stone-500 truncate">@{member.user.username}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 truncate">
@{member.user.username}
</p>
)}
</div>
</div>
@@ -213,7 +223,7 @@ const TeamMembersPanel = () => {
value={member.role.toUpperCase()}
onChange={e => handleChangeRole(member, e.target.value as TeamRole)}
disabled={changingRoleId === member._id}
className="px-2 py-1 text-[10px] font-medium rounded-full border bg-white text-stone-700 border-stone-300 focus:outline-none focus:border-primary-500/50 disabled:opacity-50">
className="px-2 py-1 text-[10px] font-medium rounded-full border bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 border-stone-300 dark:border-neutral-700 focus:outline-none focus:border-primary-500/50 disabled:opacity-50">
{ROLES.map(r => (
<option key={r} value={r}>
{r}
@@ -232,7 +242,7 @@ const TeamMembersPanel = () => {
<button
onClick={() => handleRemoveMember(member)}
disabled={removingId === member._id}
className="p-1 rounded-lg text-stone-500 hover:text-coral-400 hover:bg-coral-500/10 transition-colors disabled:opacity-50"
className="p-1 rounded-lg text-stone-500 dark:text-neutral-400 hover:text-coral-400 hover:bg-coral-500/10 transition-colors disabled:opacity-50"
aria-label={`Remove ${displayName(member)}`}>
<svg
className="w-4 h-4"
@@ -254,7 +264,7 @@ const TeamMembersPanel = () => {
{members.length === 0 && !isLoadingMembers && (
<div className="text-center py-8">
<p className="text-sm text-stone-500">No members found</p>
<p className="text-sm text-stone-500 dark:text-neutral-400">No members found</p>
</div>
)}
</div>
@@ -263,8 +273,10 @@ const TeamMembersPanel = () => {
{/* Remove Member Confirmation Modal */}
{memberToRemove && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md border border-stone-200">
<h3 className="text-sm font-semibold text-stone-900 mb-4">Remove Team Member</h3>
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
Remove Team Member
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
@@ -273,11 +285,13 @@ const TeamMembersPanel = () => {
)}
<div className="space-y-4">
<div className="text-sm text-stone-400">
<div className="text-sm text-stone-400 dark:text-neutral-500">
<p>
Are you sure you want to remove{' '}
<strong className="text-stone-900">{displayName(memberToRemove)}</strong> from
the team?
<strong className="text-stone-900 dark:text-neutral-100">
{displayName(memberToRemove)}
</strong>{' '}
from the team?
</p>
<p className="mt-2 text-coral-400">
They will lose access to the team and all team resources.
@@ -288,7 +302,7 @@ const TeamMembersPanel = () => {
<button
onClick={() => setMemberToRemove(null)}
disabled={removingId === memberToRemove._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors disabled:opacity-50">
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 transition-colors disabled:opacity-50">
Cancel
</button>
<button
@@ -306,8 +320,10 @@ const TeamMembersPanel = () => {
{/* Change Role Confirmation Modal */}
{roleChangeConfirmation && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md border border-stone-200">
<h3 className="text-sm font-semibold text-stone-900 mb-4">Change Member Role</h3>
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
Change Member Role
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
@@ -316,10 +332,10 @@ const TeamMembersPanel = () => {
)}
<div className="space-y-4">
<div className="text-sm text-stone-400">
<div className="text-sm text-stone-400 dark:text-neutral-500">
<p>
Change{' '}
<strong className="text-stone-900 font-semibold">
<strong className="text-stone-900 dark:text-neutral-100 font-semibold">
{displayName(roleChangeConfirmation.member)}
</strong>
's role from{' '}
@@ -350,7 +366,7 @@ const TeamMembersPanel = () => {
<button
onClick={() => setRoleChangeConfirmation(null)}
disabled={changingRoleId === roleChangeConfirmation.member._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50">
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 dark:text-neutral-600 transition-colors disabled:opacity-50">
Cancel
</button>
<button
@@ -137,7 +137,8 @@ const TeamPanel = () => {
const colors: Record<string, string> = {
ADMIN: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
BILLING_MANAGER: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
MEMBER: 'bg-stone-500/20 text-stone-400 border-stone-500/30',
MEMBER:
'bg-stone-50 dark:bg-neutral-800/60 text-stone-400 dark:text-neutral-500 border-stone-500/30',
};
return (
@@ -152,7 +153,7 @@ const TeamPanel = () => {
const colors: Record<string, string> = {
PRO: 'bg-lavender-500/20 text-lavender-400 border-lavender-500/30',
BASIC: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
FREE: 'bg-stone-500/20 text-stone-400 border-stone-500/30',
FREE: 'bg-stone-50 dark:bg-neutral-800/60 text-stone-400 dark:text-neutral-500 border-stone-500/30',
};
return (
<span
@@ -173,18 +174,20 @@ const TeamPanel = () => {
<div
className={`flex items-center justify-between p-3 rounded-xl border transition-all ${
isActive
? 'border-primary-200 bg-primary-50'
: 'border-stone-200 bg-white hover:bg-stone-50'
? 'border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60'
}`}>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-lg bg-stone-100 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-stone-600">
<div className="w-9 h-9 rounded-lg bg-stone-100 dark:bg-neutral-800 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-stone-600 dark:text-neutral-300">
{team.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-stone-900 truncate">{team.name}</span>
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{team.name}
</span>
{roleBadge(role, team.createdBy)}
{planBadge(team.subscription.plan)}
{isActive && (
@@ -194,7 +197,9 @@ const TeamPanel = () => {
)}
</div>
{team.isPersonal && (
<p className="text-xs text-stone-400 mt-0.5">{t('team.personalTeam')}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500 mt-0.5">
{t('team.personalTeam')}
</p>
)}
</div>
</div>
@@ -203,7 +208,7 @@ const TeamPanel = () => {
{canManage && (
<button
onClick={() => navigateToTeamManagement(team._id)}
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-primary-50 hover:bg-primary-100 text-primary-600 transition-colors">
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-primary-50 dark:bg-primary-500/10 hover:bg-primary-100 dark:bg-primary-500/20 text-primary-600 dark:text-primary-300 transition-colors">
{t('team.manageTeam')}
</button>
)}
@@ -211,7 +216,7 @@ const TeamPanel = () => {
<button
onClick={() => handleSwitchTeam(team._id)}
disabled={isSwitching === team._id}
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-600 transition-colors disabled:opacity-50">
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 transition-colors disabled:opacity-50">
{isSwitching === team._id ? t('team.switching') : t('team.switch')}
</button>
)}
@@ -219,7 +224,7 @@ const TeamPanel = () => {
<button
onClick={() => handleLeaveTeam(entry)}
disabled={isLeaving === team._id}
className="px-2.5 py-1 text-xs font-medium rounded-lg text-amber-700 hover:bg-amber-50 transition-colors disabled:opacity-50">
className="px-2.5 py-1 text-xs font-medium rounded-lg text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:bg-amber-500/10 transition-colors disabled:opacity-50">
{isLeaving === team._id ? t('team.leaving') : t('team.leave')}
</button>
)}
@@ -247,7 +252,10 @@ const TeamPanel = () => {
{isLoading && teams.length === 0 && (
<div className="flex items-center justify-center py-8">
<svg className="w-5 h-5 text-stone-500 animate-spin" fill="none" viewBox="0 0 24 24">
<svg
className="w-5 h-5 text-stone-500 dark:text-neutral-400 animate-spin"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
@@ -267,7 +275,7 @@ const TeamPanel = () => {
{teams.length > 0 && (
<div className="space-y-3">
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
<h3 className="text-xs font-medium text-stone-500 dark:text-neutral-400 uppercase tracking-wider px-1">
{t('team.yourTeams')} ({teams.length})
</h3>
<div className="space-y-2">
@@ -278,9 +286,9 @@ const TeamPanel = () => {
</div>
)}
<div className="space-y-4 border-t border-stone-200 pt-4">
<div className="space-y-4 border-t border-stone-200 dark:border-neutral-800 pt-4">
<div className="space-y-2">
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
<h3 className="text-xs font-medium text-stone-500 dark:text-neutral-400 uppercase tracking-wider px-1">
{t('team.createNewTeam')}
</h3>
<div className="flex gap-2">
@@ -290,7 +298,7 @@ const TeamPanel = () => {
onChange={e => setNewTeamName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateTeam()}
placeholder={t('team.teamName')}
className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-xl text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50"
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:outline-none focus:border-primary-500/50"
/>
<button
onClick={handleCreateTeam}
@@ -302,7 +310,7 @@ const TeamPanel = () => {
</div>
<div className="space-y-2">
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
<h3 className="text-xs font-medium text-stone-500 dark:text-neutral-400 uppercase tracking-wider px-1">
{t('team.joinExistingTeam')}
</h3>
<div className="flex gap-2">
@@ -312,12 +320,12 @@ const TeamPanel = () => {
onChange={e => setJoinCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleJoinTeam()}
placeholder={t('team.inviteCode')}
className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-xl text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 font-mono"
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-xl text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:outline-none focus:border-primary-500/50 font-mono"
/>
<button
onClick={handleJoinTeam}
disabled={isJoining || !joinCode.trim()}
className="px-4 py-2 text-xs font-medium rounded-xl bg-stone-100 hover:bg-stone-200 text-stone-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
className="px-4 py-2 text-xs font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{isJoining ? t('team.joining') : t('team.join')}
</button>
</div>
@@ -326,8 +334,10 @@ const TeamPanel = () => {
{teamToLeave && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md border border-stone-200">
<h3 className="text-sm font-semibold text-stone-900 mb-4">{t('team.leaveTeam')}</h3>
<div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 w-full max-w-md border border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-4">
{t('team.leaveTeam')}
</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
@@ -336,10 +346,13 @@ const TeamPanel = () => {
)}
<div className="space-y-4">
<div className="text-sm text-stone-500">
<div className="text-sm text-stone-500 dark:text-neutral-400">
<p>
{t('team.confirmLeave')}{' '}
<strong className="text-stone-900">{teamToLeave.team.name}</strong>?
<strong className="text-stone-900 dark:text-neutral-100">
{teamToLeave.team.name}
</strong>
?
</p>
<p className="mt-2 text-amber-400">{t('team.leaveWarning')}</p>
</div>
@@ -348,7 +361,7 @@ const TeamPanel = () => {
<button
onClick={() => setTeamToLeave(null)}
disabled={isLeaving === teamToLeave.team._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 hover:bg-stone-200 text-stone-600 transition-colors disabled:opacity-50">
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
@@ -98,7 +98,9 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
)}
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<p className="text-stone-500 text-sm">{t('settings.tools.chooseCapabilities')}</p>
<p className="text-stone-500 dark:text-neutral-400 text-sm">
{t('settings.tools.chooseCapabilities')}
</p>
<div className="max-h-[420px] overflow-y-auto pr-1 space-y-4">
{TOOL_CATEGORIES.map(category => {
@@ -107,10 +109,12 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
return (
<div key={category}>
<div className="mb-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-stone-500">
<h2 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{category}
</h2>
<p className="text-xs text-stone-400">{CATEGORY_DESCRIPTIONS[category]}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{CATEGORY_DESCRIPTIONS[category]}
</p>
</div>
<div className="space-y-1">
{tools.map(tool => (
@@ -118,19 +122,21 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
key={tool.id}
type="button"
onClick={() => toggle(tool.id)}
className="w-full flex items-center justify-between p-2.5 rounded-xl border border-stone-200 bg-white hover:border-stone-300 transition-colors text-left">
className="w-full flex items-center justify-between p-2.5 rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:border-stone-300 dark:border-neutral-700 dark:hover:border-neutral-700 transition-colors text-left">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-stone-900">
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{tool.displayName}
</span>
<p className="text-xs text-stone-500 mt-0.5">{tool.description}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{tool.description}
</p>
</div>
<div
className={`ml-3 flex-shrink-0 w-9 h-5 rounded-full transition-colors relative ${
enabled[tool.id] ? 'bg-sage-500' : 'bg-stone-200'
enabled[tool.id] ? 'bg-sage-500' : 'bg-stone-200 dark:bg-neutral-800'
}`}>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
enabled[tool.id] ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
@@ -153,7 +159,7 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
</button>
)}
{saveStatus === 'saved' && (
<p className="text-xs text-center text-green-600 mt-1">
<p className="text-xs text-center text-green-600 dark:text-green-300 mt-1">
{t('settings.tools.preferencesSaved')}
</p>
)}
@@ -124,39 +124,45 @@ const VoiceDebugPanel = () => {
<div className="p-4 space-y-4">
{/* Runtime status section */}
<section className="space-y-3">
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-stone-900">Runtime Status</h3>
<p className="text-xs text-stone-500 mt-1">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Runtime Status
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
Live diagnostics for the voice server and speech-to-text engine.
</p>
</div>
<button
type="button"
onClick={() => void loadData()}
className="text-xs text-primary-600 hover:text-primary-700">
className="text-xs text-primary-600 dark:text-primary-300 hover:text-primary-700 dark:text-primary-300">
Refresh
</button>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-stone-200 bg-white p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500">Server</div>
<div className="mt-1 font-medium text-stone-900">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Server
</div>
<div className="mt-1 font-medium text-stone-900 dark:text-neutral-100">
{serverStatus ? serverStatus.state : isLoading ? 'Loading…' : 'Unavailable'}
</div>
</div>
<div className="rounded-md border border-stone-200 bg-white p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500">STT</div>
<div className="mt-1 font-medium text-stone-900">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<div className="text-[10px] uppercase tracking-wide text-stone-500 dark:text-neutral-400">
STT
</div>
<div className="mt-1 font-medium text-stone-900 dark:text-neutral-100">
{voiceStatus?.stt_available ? 'Ready' : 'Not ready'}
</div>
</div>
</div>
{serverStatus && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-stone-600">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-stone-600 dark:text-neutral-300">
<div>Hotkey: {serverStatus.hotkey || 'n/a'}</div>
<div>Mode: {serverStatus.activation_mode}</div>
<div>Transcriptions: {serverStatus.transcription_count}</div>
@@ -164,7 +170,7 @@ const VoiceDebugPanel = () => {
)}
{serverStatus?.last_error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
<div className="rounded-md border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-3 text-xs text-red-600 dark:text-red-300">
<div className="font-medium mb-1">Server Error</div>
{serverStatus.last_error}
</div>
@@ -174,10 +180,12 @@ const VoiceDebugPanel = () => {
{/* Advanced settings section */}
<section className="space-y-3">
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">Advanced Settings</h3>
<p className="text-xs text-stone-500 mt-1">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Advanced Settings
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
Low-level tuning parameters for recording and silence detection.
</p>
</div>
@@ -185,7 +193,7 @@ const VoiceDebugPanel = () => {
{settings && (
<>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Minimum Recording Seconds
</span>
<input
@@ -194,15 +202,15 @@ const VoiceDebugPanel = () => {
step="0.1"
value={settings.min_duration_secs}
onChange={e => updateSetting('min_duration_secs', Number(e.target.value) || 0)}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Silence Threshold (RMS)
</span>
<p className="text-[11px] text-stone-400">
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
Recordings with energy below this are treated as silence and skipped. Lower =
more sensitive.
</p>
@@ -215,19 +223,19 @@ const VoiceDebugPanel = () => {
onChange={e =>
updateSetting('silence_threshold', Number(e.target.value) || 0.002)
}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
</>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
<div className="rounded-md border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-3 text-xs text-red-600 dark:text-red-300">
{error}
</div>
)}
{notice && (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-700">
<div className="rounded-md border border-emerald-200 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-300">
{notice}
</div>
)}
@@ -512,11 +512,13 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<section className="space-y-3">
<div
className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4"
className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-4"
data-testid="voice-providers-section">
<div>
<h3 className="text-sm font-semibold text-stone-900">Voice Providers</h3>
<p className="text-xs text-stone-500 mt-1">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Voice Providers
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
Choose where transcription and synthesis run. Use the Install locally buttons to
download the binaries and models into your workspace no manual{' '}
<code>WHISPER_BIN</code> or <code>PIPER_BIN</code> setup required.
@@ -524,14 +526,16 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Speech-to-Text Provider</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Speech-to-Text Provider
</span>
<select
aria-label="STT provider"
data-testid="stt-provider-select"
value={sttProvider || 'cloud'}
disabled={isSavingProviders}
onChange={e => onSttProviderChange(e.target.value as 'cloud' | 'whisper')}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
<option value="cloud">Cloud (Whisper proxy)</option>
<option value="whisper" disabled={!whisperReady}>
Local Whisper{whisperReady ? '' : ' (install required)'}
@@ -559,10 +563,10 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
data-testid="whisper-install-state"
className={`text-[11px] ${
whisperReady
? 'text-emerald-600'
? 'text-emerald-600 dark:text-emerald-300'
: whisperInstall?.state === 'error'
? 'text-red-600'
: 'text-stone-500'
? 'text-red-600 dark:text-red-300'
: 'text-stone-500 dark:text-neutral-400'
}`}>
{whisperInstall?.state === 'installing' && whisperInstall.stage
? whisperInstall.stage
@@ -576,7 +580,9 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
</label>
{sttProvider === 'whisper' && (
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Whisper Model</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Whisper Model
</span>
<select
aria-label="Whisper model"
data-testid="stt-model-select"
@@ -598,7 +604,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
)
);
}}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
<option value="tiny">Tiny (39 MB, fastest)</option>
<option value="base">Base (74 MB)</option>
<option value="small">Small (244 MB)</option>
@@ -610,14 +616,16 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
</label>
)}
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Text-to-Speech Provider</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Text-to-Speech Provider
</span>
<select
aria-label="TTS provider"
data-testid="tts-provider-select"
value={ttsProvider || 'cloud'}
disabled={isSavingProviders}
onChange={e => onTtsProviderChange(e.target.value as 'cloud' | 'piper')}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
<option value="cloud">Cloud (ElevenLabs proxy)</option>
<option value="piper" disabled={!piperReady}>
Local Piper{piperReady ? '' : ' (install required)'}
@@ -645,10 +653,10 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
data-testid="piper-install-state"
className={`text-[11px] ${
piperReady
? 'text-emerald-600'
? 'text-emerald-600 dark:text-emerald-300'
: piperInstall?.state === 'error'
? 'text-red-600'
: 'text-stone-500'
? 'text-red-600 dark:text-red-300'
: 'text-stone-500 dark:text-neutral-400'
}`}>
{piperInstall?.state === 'installing' && piperInstall.stage
? piperInstall.stage
@@ -662,7 +670,9 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
</label>
{ttsProvider === 'piper' && (
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Piper Voice</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Piper Voice
</span>
<select
aria-label="Piper voice"
data-testid="tts-voice-select"
@@ -689,7 +699,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
)
);
}}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
{PIPER_VOICE_PRESETS.map(v => (
<option key={v.id} value={v.id}>
{v.label}
@@ -716,10 +726,10 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
);
}
}}
className="mt-1 w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="mt-1 w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
)}
<p className="text-[11px] text-stone-500 mt-0.5">
<p className="text-[11px] text-stone-500 dark:text-neutral-400 mt-0.5">
Voices come from{' '}
<code className="font-mono">huggingface.co/rhasspy/piper-voices</code>.
Switching voices may require an Install/Reinstall click to download the new{' '}
@@ -741,10 +751,12 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
shipped default. */}
{ttsProvider !== 'piper' && (
<section className="space-y-3" data-testid="mascot-voice-section">
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">Mascot Voice</h3>
<p className="text-xs text-stone-500 mt-1">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Mascot Voice
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
Pick the ElevenLabs voice the mascot uses for spoken replies. Switch among the
curated presets, paste any voice id you have access to under{' '}
<strong>Other</strong>, or hit <strong>Reset</strong> to fall back to the shipped
@@ -753,13 +765,15 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Voice preset</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Voice preset
</span>
<select
aria-label="Mascot voice preset"
data-testid="mascot-voice-select"
value={isCustomMascotVoice ? '__custom__' : effectiveMascotVoiceId}
onChange={e => onMascotVoicePresetChange(e.target.value)}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
{ELEVENLABS_VOICE_PRESETS.map(v => (
<option key={v.id} value={v.id}>
{v.label}
@@ -771,7 +785,9 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
{isCustomMascotVoice && (
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Custom voice id</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
Custom voice id
</span>
<div className="flex gap-2">
<input
aria-label="Custom ElevenLabs voice id"
@@ -779,7 +795,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
value={mascotVoiceDraft}
placeholder="e.g. 21m00Tcm4TlvDq8ikWAM"
onChange={e => setMascotVoiceDraft(e.target.value)}
className="flex-1 rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="flex-1 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<button
type="button"
@@ -790,7 +806,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
Save
</button>
</div>
<p className="text-[11px] text-stone-500">
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
Find voice ids at <code className="font-mono">api.elevenlabs.io/v1/voices</code>{' '}
or your ElevenLabs dashboard. Only the id is stored your API key stays on the
backend.
@@ -817,12 +833,12 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
? 'Already using the shipped default voice'
: 'Restore the shipped default mascot voice'
}
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 hover:border-stone-400 disabled:opacity-60 text-stone-700">
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
Reset to default
</button>
<span
data-testid="mascot-voice-current"
className="ml-1 text-[11px] text-stone-500 truncate max-w-[18rem]"
className="ml-1 text-[11px] text-stone-500 dark:text-neutral-400 truncate max-w-[18rem]"
title={effectiveMascotVoiceId}>
current: <code className="font-mono">{effectiveMascotVoiceId}</code>
</span>
@@ -831,7 +847,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
{mascotVoicePreviewError && (
<div
data-testid="mascot-voice-preview-error"
className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
className="rounded-md border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3 text-xs text-amber-800 dark:text-amber-200">
Voice preview failed: {mascotVoicePreviewError}. Reply speech will fall back to
the default voice on the next reply.
</div>
@@ -841,27 +857,33 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
)}
<section className={`space-y-3 ${disabled ? 'opacity-60' : ''}`}>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">{t('voice.settings')}</h3>
<p className="text-xs text-stone-500 mt-1">{t('voice.settingsDesc')}</p>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('voice.settings')}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t('voice.settingsDesc')}
</p>
</div>
{!disabled && settings && (
<>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">{t('voice.hotkey')}</span>
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('voice.hotkey')}
</span>
<input
value={settings.hotkey}
onChange={e => updateSetting('hotkey', e.target.value)}
placeholder="Fn"
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('voice.activationMode')}
</span>
<select
@@ -869,43 +891,45 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
onChange={e =>
updateSetting('activation_mode', e.target.value as 'tap' | 'push')
}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
<option value="push">{t('voice.pushToTalk')}</option>
<option value="tap">{t('voice.tapToToggle')}</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('voice.writingStyle')}
</span>
<select
value={settings.skip_cleanup ? 'verbatim' : 'natural'}
onChange={e => updateSetting('skip_cleanup', e.target.value === 'verbatim')}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
<option value="verbatim">{t('voice.verbatimTranscription')}</option>
<option value="natural">{t('voice.naturalCleanup')}</option>
</select>
</label>
</div>
<label className="flex items-center gap-2 text-sm text-stone-700">
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-200">
<input
type="checkbox"
data-testid="voice-auto-start-toggle"
checked={settings.auto_start}
onChange={e => updateSetting('auto_start', e.target.checked)}
className="h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
className="h-4 w-4 rounded border-stone-300 dark:border-neutral-700 text-primary-600 dark:text-primary-300 focus:ring-primary-500"
/>
{t('voice.autoStart')}
</label>
<div className="space-y-2">
<div>
<span className="text-xs font-medium text-stone-600">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('voice.customDictionary')}
</span>
<p className="text-[11px] text-stone-400">{t('voice.customDictionaryDesc')}</p>
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
{t('voice.customDictionaryDesc')}
</p>
</div>
<div className="flex gap-2">
<input
@@ -925,7 +949,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
}
}}
placeholder={t('voice.addWord')}
className="flex-1 rounded-md border border-stone-200 bg-white px-3 py-1.5 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="flex-1 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<button
type="button"
@@ -946,7 +970,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
{settings.custom_dictionary.map(word => (
<span
key={word}
className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-0.5 text-xs text-stone-700">
className="inline-flex items-center gap-1 rounded-full bg-stone-100 dark:bg-neutral-800 px-2.5 py-0.5 text-xs text-stone-700 dark:text-neutral-200">
{word}
<button
type="button"
@@ -956,7 +980,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
settings.custom_dictionary.filter(w => w !== word)
)
}
className="ml-0.5 text-stone-400 hover:text-stone-700">
className="ml-0.5 text-stone-400 dark:text-neutral-500 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200">
&times;
</button>
</span>
@@ -968,19 +992,19 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
)}
{disabled && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
<div className="rounded-md border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-4 text-sm text-amber-800 dark:text-amber-200">
Voice dictation is disabled until the local STT model is downloaded. Use the{' '}
<strong>Voice Providers</strong> section above to install Whisper.
</div>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
<div className="rounded-md border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-3 text-xs text-red-600 dark:text-red-300">
{error}
</div>
)}
{notice && (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-700">
<div className="rounded-md border border-emerald-200 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-300">
{notice}
</div>
)}
@@ -1005,7 +1029,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
type="button"
onClick={() => void stopServer()}
disabled={!isRunning || isStopping}
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 hover:border-stone-400 disabled:opacity-60 text-stone-700">
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
{isStopping ? t('common.loading') : t('voice.stopVoiceServer')}
</button>
</div>
@@ -1015,7 +1039,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
<button
type="button"
onClick={() => navigateToSettings('voice-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
className="flex items-center gap-1.5 text-xs text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:text-neutral-300 dark:hover:text-neutral-300 transition-colors">
{t('settings.advanced')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
@@ -155,62 +155,70 @@ const WebhooksDebugPanel = () => {
type="button"
onClick={() => void loadData()}
disabled={loading}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 font-medium text-stone-700 hover:bg-stone-100 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{loading ? 'Loading...' : 'Refresh'}
</button>
<button
type="button"
onClick={() => void handleClearLogs()}
disabled={clearing || logs.length === 0}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-1.5 font-medium text-stone-700 hover:bg-stone-100 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-1.5 font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-50">
{clearing ? 'Clearing...' : 'Clear Logs'}
</button>
<span className="text-stone-500">
<span className="text-stone-500 dark:text-neutral-400">
{registrations.length} registered &middot; {logs.length} captured &middot;{' '}
<span className={isLive ? 'text-sage-600' : 'text-stone-400'}>
<span
className={
isLive ? 'text-sage-600 dark:text-sage-300' : 'text-stone-400 dark:text-neutral-500'
}>
{isLive ? 'live' : 'disconnected'}
</span>
</span>
</div>
{error && (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
{lastEvent && (
<div className="text-xs text-stone-500">
Last event: <span className="font-medium text-stone-700">{lastEvent.event_type}</span>{' '}
<div className="text-xs text-stone-500 dark:text-neutral-400">
Last event:{' '}
<span className="font-medium text-stone-700 dark:text-neutral-200">
{lastEvent.event_type}
</span>{' '}
at {formatDateTime(lastEvent.timestamp)}
</div>
)}
{/* Registrations */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900">Registered Webhooks</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Registered Webhooks
</h3>
{registrations.length === 0 ? (
<p className="text-xs text-stone-400">No active registrations.</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">No active registrations.</p>
) : (
<div className="space-y-2">
{registrations.map(registration => (
<div
key={registration.tunnel_uuid}
className="rounded-xl border border-stone-200 bg-stone-50 p-3">
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-stone-900">
<span className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
{registration.tunnel_name || registration.tunnel_uuid}
</span>
<div className="flex gap-1 text-[10px]">
<span className="rounded-full bg-stone-200 px-2 py-0.5 text-stone-600">
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{registration.target_kind}
</span>
<span className="rounded-full bg-stone-200 px-2 py-0.5 text-stone-600">
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{registration.skill_id}
</span>
</div>
</div>
<div className="mt-1 text-[11px] text-stone-500 font-mono break-all">
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400 font-mono break-all">
{backendUrl
? tunnelsApi.ingressUrl(backendUrl, registration.tunnel_uuid)
: 'Resolving backend URL…'}
@@ -223,9 +231,13 @@ const WebhooksDebugPanel = () => {
{/* Captured Requests */}
<section className="space-y-2">
<h3 className="text-sm font-semibold text-stone-900">Captured Requests</h3>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
Captured Requests
</h3>
{logs.length === 0 ? (
<p className="text-xs text-stone-400">No webhook requests captured yet.</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
No webhook requests captured yet.
</p>
) : (
<div className="space-y-2">
{logs.map(entry => (
@@ -235,16 +247,18 @@ const WebhooksDebugPanel = () => {
onClick={() => setSelectedCorrelationId(entry.correlation_id)}
className={`w-full rounded-xl border p-3 text-left transition-colors ${
selectedLog?.correlation_id === entry.correlation_id
? 'border-primary-300 bg-primary-50'
: 'border-stone-200 bg-stone-50 hover:bg-stone-100'
? 'border-primary-300 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 dark:bg-neutral-800'
}`}>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-stone-900">
<span className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
{entry.method} {entry.path}
</span>
<span className="text-[10px] text-stone-500">{entry.status_code ?? '...'}</span>
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
{entry.status_code ?? '...'}
</span>
</div>
<div className="mt-1 text-[11px] text-stone-500">
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
{entry.tunnel_name} {entry.skill_id ? `· ${entry.skill_id}` : '· unrouted'} ·{' '}
{formatDateTime(entry.updated_at)}
</div>
@@ -252,30 +266,30 @@ const WebhooksDebugPanel = () => {
))}
{selectedLog && (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3 space-y-3">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 space-y-3">
<div>
<div className="text-xs font-semibold text-stone-900">
<div className="text-xs font-semibold text-stone-900 dark:text-neutral-100">
{selectedLog.method} {selectedLog.path}
</div>
<div className="text-[10px] text-stone-400 font-mono">
<div className="text-[10px] text-stone-400 dark:text-neutral-500 font-mono">
{selectedLog.correlation_id}
</div>
</div>
<div className="flex flex-wrap gap-1 text-[10px]">
<span className="rounded-full bg-stone-200 px-2 py-0.5 text-stone-600">
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{selectedLog.stage}
</span>
<span className="rounded-full bg-stone-200 px-2 py-0.5 text-stone-600">
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{selectedLog.status_code ?? 'pending'}
</span>
<span className="rounded-full bg-stone-200 px-2 py-0.5 text-stone-600">
<span className="rounded-full bg-stone-200 dark:bg-neutral-800 px-2 py-0.5 text-stone-600 dark:text-neutral-300">
{selectedLog.skill_id || 'unrouted'}
</span>
</div>
{selectedLog.error_message && (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{selectedLog.error_message}
</div>
)}
@@ -316,10 +330,10 @@ const WebhooksDebugPanel = () => {
function PayloadBlock({ title, value }: { title: string; value: string }) {
return (
<details className="text-xs">
<summary className="cursor-pointer font-semibold text-stone-500 uppercase tracking-wide text-[10px]">
<summary className="cursor-pointer font-semibold text-stone-500 dark:text-neutral-400 uppercase tracking-wide text-[10px]">
{title}
</summary>
<pre className="mt-1 max-h-40 overflow-auto rounded-lg border border-stone-200 bg-stone-950 p-2 text-[11px] text-stone-100 whitespace-pre-wrap break-words">
<pre className="mt-1 max-h-40 overflow-auto rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-950 dark:bg-neutral-50 p-2 text-[11px] text-stone-100 whitespace-pre-wrap break-words">
{value}
</pre>
</details>
@@ -39,11 +39,11 @@ const AppFilterSection = ({
const { t } = useT();
return (
<>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.autocomplete.appFilter.runtime')}
</h3>
<div className="text-sm text-stone-700 space-y-1">
<div className="text-sm text-stone-700 dark:text-neutral-200 space-y-1">
<div>Platform supported: {status?.platform_supported ? 'yes' : 'no'}</div>
<div>Enabled: {status?.enabled ? 'yes' : 'no'}</div>
<div>Running: {status?.running ? 'yes' : 'no'}</div>
@@ -59,7 +59,7 @@ const AppFilterSection = ({
type="button"
onClick={onRefreshStatus}
disabled={isLoading}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700 disabled:opacity-50">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 px-3 py-2 text-sm text-stone-700 dark:text-neutral-200 disabled:opacity-50">
{isLoading
? t('settings.autocomplete.appFilter.refreshing')
: t('settings.autocomplete.appFilter.refreshStatus')}
@@ -68,80 +68,80 @@ const AppFilterSection = ({
type="button"
onClick={onStart}
disabled={!status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
className="rounded-lg border border-green-500/60 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-300 disabled:opacity-50">
{t('autocomplete.start')}
</button>
<button
type="button"
onClick={onStop}
disabled={!status?.running}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
className="rounded-lg border border-red-500/60 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300 disabled:opacity-50">
{t('autocomplete.stop')}
</button>
</div>
</section>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.autocomplete.appFilter.test')}
</h3>
<div className="space-y-1">
<div className="text-xs text-stone-600">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('settings.autocomplete.appFilter.contextOverride')}
</div>
<textarea
value={contextOverride}
onChange={event => onSetContextOverride(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onTestCurrent}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600">
className="rounded-lg border border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-600 dark:text-primary-300">
{t('settings.autocomplete.appFilter.getSuggestion')}
</button>
<button
type="button"
onClick={onAcceptSuggestion}
className="rounded-lg border border-emerald-500/60 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
className="rounded-lg border border-emerald-500/60 bg-emerald-50 dark:bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
{t('settings.autocomplete.appFilter.acceptSuggestion')}
</button>
<button
type="button"
onClick={onDebugFocus}
className="rounded-lg border border-amber-500/60 bg-amber-50 px-3 py-2 text-sm text-amber-700">
className="rounded-lg border border-amber-500/60 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300">
{t('settings.autocomplete.appFilter.debugFocus')}
</button>
</div>
{focusDebug && (
<pre className="max-h-48 overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700">
<pre className="max-h-48 overflow-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200">
{focusDebug}
</pre>
)}
</section>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.autocomplete.appFilter.liveLogs')}
</h3>
<button
type="button"
onClick={onClearLogs}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-1.5 text-xs text-stone-700">
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200">
{t('common.clear')}
</button>
</div>
<pre className="max-h-56 overflow-auto rounded-xl border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700">
<pre className="max-h-56 overflow-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200">
{logs.length > 0 ? logs.join('\n') : t('settings.autocomplete.appFilter.noLogs')}
</pre>
</section>
{message && <div className="text-xs text-green-700">{message}</div>}
{error && <div className="text-xs text-red-600">{error}</div>}
{message && <div className="text-xs text-green-700 dark:text-green-300">{message}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-300">{error}</div>}
</>
);
};
@@ -57,10 +57,12 @@ const CompletionStyleSection = ({
const { t } = useT();
return (
<>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">{t('autocomplete.settings')}</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('autocomplete.settings')}
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.enabled')}
</span>
<input
@@ -69,16 +71,18 @@ const CompletionStyleSection = ({
onChange={event => onSetEnabled(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">{t('autocomplete.acceptWithTab')}</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.acceptWithTab')}
</span>
<input
type="checkbox"
checked={acceptWithTab}
onChange={event => onSetAcceptWithTab(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.debounce')}
</span>
<input
@@ -88,11 +92,11 @@ const CompletionStyleSection = ({
step={10}
value={debounceMs}
onChange={event => onSetDebounceMs(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.maxChars')}
</span>
<input
@@ -102,11 +106,11 @@ const CompletionStyleSection = ({
step={8}
value={maxChars}
onChange={event => onSetMaxChars(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.autocomplete.completionStyle.overlayTtl')}
</span>
<input
@@ -116,15 +120,17 @@ const CompletionStyleSection = ({
step={100}
value={overlayTtlMs}
onChange={event => onSetOverlayTtlMs(event.target.value)}
className="w-28 rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700"
className="w-28 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">{t('autocomplete.stylePreset')}</span>
<label className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('autocomplete.stylePreset')}
</span>
<select
value={stylePreset}
onChange={event => onSetStylePreset(event.target.value)}
className="rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700">
className="rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs text-stone-700 dark:text-neutral-200">
<option value="balanced">Balanced</option>
<option value="concise">Concise</option>
<option value="formal">Formal</option>
@@ -133,61 +139,63 @@ const CompletionStyleSection = ({
</select>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('settings.autocomplete.completionStyle.styleInstructions')}
</div>
<textarea
value={styleInstructions}
onChange={event => onSetStyleInstructions(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600">
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('settings.autocomplete.completionStyle.styleExamples')}
</div>
<textarea
value={styleExamplesText}
onChange={event => onSetStyleExamplesText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-stone-600">{t('autocomplete.disabledApps')}</div>
<div className="text-xs text-stone-600 dark:text-neutral-300">
{t('autocomplete.disabledApps')}
</div>
<textarea
value={disabledAppsText}
onChange={event => onSetDisabledAppsText(event.target.value)}
rows={3}
className="w-full rounded border border-stone-200 bg-stone-50 p-2 text-xs text-stone-700"
className="w-full rounded border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 text-xs text-stone-700 dark:text-neutral-200"
/>
</div>
<button
type="button"
onClick={onSaveConfig}
disabled={isSaving}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
className="rounded-lg border border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-600 dark:text-primary-300 disabled:opacity-50">
{isSaving ? t('autocomplete.saving') : t('autocomplete.saveSettings')}
</button>
</section>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.autocomplete.completionStyle.personalizationHistory')}
</h3>
<button
type="button"
onClick={onClearHistory}
disabled={isClearingHistory || historyEntries.length === 0}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-1.5 text-xs text-red-600 disabled:opacity-40">
className="rounded-lg border border-red-500/60 bg-red-50 dark:bg-red-500/10 px-3 py-1.5 text-xs text-red-600 dark:text-red-300 disabled:opacity-40">
{isClearingHistory
? t('settings.autocomplete.completionStyle.clearing')
: t('settings.autocomplete.completionStyle.clearHistory')}
</button>
</div>
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{isHistoryLoading
? t('common.loading')
: historyEntries.length === 0
@@ -195,23 +203,25 @@ const CompletionStyleSection = ({
: `${String(historyEntries.length)} ${historyEntries.length === 1 ? t('settings.autocomplete.completionStyle.acceptedCompletion') : t('settings.autocomplete.completionStyle.acceptedCompletions')}`}
</p>
{historyEntries.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-xl border border-stone-200 bg-stone-50 p-2 space-y-1">
<div className="max-h-48 overflow-y-auto rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-2 space-y-1">
{historyEntries.map((entry, idx) => (
<div
key={`${String(entry.timestamp_ms)}-${String(idx)}`}
className="flex flex-col gap-0.5 rounded-lg bg-white px-2 py-1.5 text-xs border border-stone-100">
<div className="flex items-center gap-2 text-stone-500">
className="flex flex-col gap-0.5 rounded-lg bg-white dark:bg-neutral-900 px-2 py-1.5 text-xs border border-stone-100 dark:border-neutral-800">
<div className="flex items-center gap-2 text-stone-500 dark:text-neutral-400">
<span className="shrink-0">{new Date(entry.timestamp_ms).toLocaleString()}</span>
{entry.app_name && (
<span className="rounded bg-stone-100 px-1 text-stone-600">
<span className="rounded bg-stone-100 dark:bg-neutral-800 px-1 text-stone-600 dark:text-neutral-300">
{entry.app_name}
</span>
)}
</div>
<div className="flex items-baseline gap-1 text-stone-700 truncate">
<span className="shrink-0 text-stone-400"></span>
<span className="truncate text-stone-500">{entry.context.slice(-40)}</span>
<span className="shrink-0 text-stone-400"></span>
<div className="flex items-baseline gap-1 text-stone-700 dark:text-neutral-200 truncate">
<span className="shrink-0 text-stone-400 dark:text-neutral-500"></span>
<span className="truncate text-stone-500 dark:text-neutral-400">
{entry.context.slice(-40)}
</span>
<span className="shrink-0 text-stone-400 dark:text-neutral-500"></span>
<span className="font-medium text-primary-500 truncate">{entry.suggestion}</span>
</div>
</div>
@@ -72,14 +72,14 @@ const AutoRechargeSection = ({
}: AutoRechargeSectionProps) => {
const { t } = useT();
return (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden">
{/* Header row */}
<div className="flex items-center justify-between p-3">
<div>
<p className="text-md font-semibold text-stone-900">
<p className="text-md font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.billing.autoRecharge.title')}
</p>
<p className="text-[11px] text-stone-400 mt-0.5">
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mt-0.5">
{t('settings.billing.autoRecharge.subtitle')}
</p>
</div>
@@ -96,7 +96,7 @@ const AutoRechargeSection = ({
arSaving ? 'opacity-50 cursor-not-allowed' : ''
} ${arSettings?.enabled ? 'bg-primary-500' : 'bg-stone-600'}`}>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
arSettings?.enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
@@ -125,11 +125,11 @@ const AutoRechargeSection = ({
{/* Settings — only shown when enabled */}
{!arLoading && arSettings?.enabled && (
<div className="border-t border-stone-200 px-3 pt-3 pb-2 space-y-3">
<div className="border-t border-stone-200 dark:border-neutral-800 px-3 pt-3 pb-2 space-y-3">
{/* Status row */}
<div className="flex items-center gap-3 flex-wrap">
{arSettings.inFlight && (
<span className="flex items-center gap-1 text-[10px] text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
<span className="flex items-center gap-1 text-[10px] text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-full px-2 py-0.5">
<svg className="w-2.5 h-2.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
@@ -149,13 +149,13 @@ const AutoRechargeSection = ({
</span>
)}
{arSettings.spentThisWeekUsd > 0 && (
<span className="text-[10px] text-stone-400">
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
${arSettings.spentThisWeekUsd.toFixed(2)} of ${arSettings.weeklyLimitUsd} used this
week
</span>
)}
{arSettings.lastRechargeAt && (
<span className="text-[10px] text-stone-500">
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
{t('settings.billing.autoRecharge.lastRecharged')}{' '}
{new Date(arSettings.lastRechargeAt).toLocaleDateString('en-US', {
month: 'short',
@@ -188,7 +188,7 @@ const AutoRechargeSection = ({
{/* Trigger threshold */}
<div>
<p className="text-[11px] text-stone-400 mb-1.5">
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mb-1.5">
{t('settings.billing.autoRecharge.rechargeWhen')}
</p>
<div className="flex gap-1.5 flex-wrap">
@@ -199,7 +199,7 @@ const AutoRechargeSection = ({
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
arThreshold === v
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-500 dark:text-neutral-400 border-stone-200 dark:border-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200'
}`}>
${v}
</button>
@@ -209,7 +209,7 @@ const AutoRechargeSection = ({
{/* Recharge amount */}
<div>
<p className="text-[11px] text-stone-400 mb-1.5">
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mb-1.5">
{t('settings.billing.autoRecharge.addAmount')}
</p>
<div className="flex gap-1.5 flex-wrap">
@@ -220,7 +220,7 @@ const AutoRechargeSection = ({
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
arAmount === v
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-500 dark:text-neutral-400 border-stone-200 dark:border-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200'
}`}>
${v}
</button>
@@ -230,7 +230,7 @@ const AutoRechargeSection = ({
{/* Weekly limit */}
<div>
<p className="text-[11px] text-stone-400 mb-1.5">
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mb-1.5">
{t('settings.billing.autoRecharge.weeklyLimit')}
</p>
<div className="flex gap-1.5 flex-wrap">
@@ -241,7 +241,7 @@ const AutoRechargeSection = ({
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
arWeeklyLimit === v
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-500 dark:text-neutral-400 border-stone-200 dark:border-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200'
}`}>
${v}
</button>
@@ -263,7 +263,7 @@ const AutoRechargeSection = ({
disabled={arSaving || arAmount <= arThreshold}
className={`w-full py-1.5 text-xs font-medium rounded-lg transition-colors ${
arSaving || arAmount <= arThreshold
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
? 'bg-stone-700/40 text-stone-500 dark:text-neutral-400 cursor-not-allowed'
: 'bg-primary-500 hover:bg-primary-600 text-white'
}`}>
{arSaving
@@ -275,9 +275,9 @@ const AutoRechargeSection = ({
)}
{/* Payment methods */}
<div className="border-t border-stone-200 px-3 py-2.5">
<div className="border-t border-stone-200 dark:border-neutral-800 px-3 py-2.5">
<div className="flex items-center justify-between mb-2">
<p className="text-[11px] font-medium text-stone-600">
<p className="text-[11px] font-medium text-stone-600 dark:text-neutral-300">
{t('settings.billing.autoRecharge.paymentMethods')}
</p>
<button
@@ -294,9 +294,9 @@ const AutoRechargeSection = ({
))}
</div>
) : cards.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg bg-stone-50 border border-stone-200 p-2.5">
<div className="flex items-center gap-2 rounded-lg bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 p-2.5">
<svg
className="w-4 h-4 text-stone-500 flex-shrink-0"
className="w-4 h-4 text-stone-500 dark:text-neutral-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -307,7 +307,7 @@ const AutoRechargeSection = ({
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
<p className="text-[11px] text-stone-500">
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.billing.autoRecharge.noCards')}
</p>
</div>
@@ -321,10 +321,10 @@ const AutoRechargeSection = ({
return (
<div
key={card.id}
className="flex items-center gap-2 rounded-lg bg-stone-50 border border-stone-200 px-2.5 py-2">
className="flex items-center gap-2 rounded-lg bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 px-2.5 py-2">
{/* Card icon */}
<svg
className="w-4 h-4 text-stone-400 flex-shrink-0"
className="w-4 h-4 text-stone-400 dark:text-neutral-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -339,7 +339,7 @@ const AutoRechargeSection = ({
{/* Card info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-stone-900 font-medium">
<span className="text-xs text-stone-900 dark:text-neutral-100 font-medium">
{cardBrandLabel(card.brand)} {card.last4}
</span>
{card.isDefault && (
@@ -348,7 +348,7 @@ const AutoRechargeSection = ({
</span>
)}
</div>
<p className="text-[10px] text-stone-500 mt-0.5">
<p className="text-[10px] text-stone-500 dark:text-neutral-400 mt-0.5">
Expires {String(card.expMonth).padStart(2, '0')}/
{String(card.expYear).slice(-2)}
</p>
@@ -360,7 +360,7 @@ const AutoRechargeSection = ({
<button
onClick={() => onSetDefault(card.id)}
disabled={!!settingDefaultId || !!deletingCardId}
className="text-[10px] text-stone-500 hover:text-stone-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
className="text-[10px] text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
{isSettingDefault ? '…' : t('settings.billing.autoRecharge.setDefault')}
</button>
)}
@@ -375,7 +375,7 @@ const AutoRechargeSection = ({
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="text-[10px] text-stone-500 hover:text-stone-400 transition-colors px-1 py-1">
className="text-[10px] text-stone-500 dark:text-neutral-400 hover:text-stone-400 dark:text-neutral-500 transition-colors px-1 py-1">
{t('common.cancel')}
</button>
</div>
@@ -383,7 +383,7 @@ const AutoRechargeSection = ({
<button
onClick={() => setConfirmDeleteId(card.id)}
disabled={isDeleting || !!settingDefaultId}
className="text-[10px] text-stone-500 hover:text-coral-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
className="text-[10px] text-stone-500 dark:text-neutral-400 hover:text-coral-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
{t('common.remove')}
</button>
)}
@@ -15,24 +15,26 @@ export default function BillingHistoryTab({
const { t } = useT();
return (
<section className="space-y-4">
<div className="flex flex-col gap-2 rounded-2xl bg-white p-4 border border-stone-200">
<h3 className="font-headline text-2xl font-bold tracking-tight text-stone-950">
<div className="flex flex-col gap-2 rounded-2xl bg-white dark:bg-neutral-900 p-4 border border-stone-200 dark:border-neutral-800">
<h3 className="font-headline text-2xl font-bold tracking-tight text-stone-950 dark:text-neutral-50">
{t('settings.billing.history.title')}
</h3>
<p className="mt-1 text-sm text-stone-500">{t('settings.billing.history.desc')}</p>
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.billing.history.desc')}
</p>
<div className="flex items-center justify-between gap-3">
{hasActive && (
<button
onClick={onManageSubscription}
className="text-sm font-semibold text-primary-600 transition-colors hover:text-primary-700">
className="text-sm font-semibold text-primary-600 dark:text-primary-300 transition-colors hover:text-primary-700 dark:text-primary-300">
{t('settings.billing.history.openPortal')}
</button>
)}
</div>
</div>
<div className="overflow-hidden rounded-[28px] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
<div className="overflow-hidden rounded-[28px] bg-white dark:bg-neutral-900 shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
{transactionRows.length > 0 ? (
<div className="divide-y divide-stone-100">
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{transactionRows.map(transaction => {
const isEarn = transaction.type === 'EARN';
return (
@@ -40,8 +42,10 @@ export default function BillingHistoryTab({
key={transaction.id}
className="grid gap-3 px-5 py-4 text-sm sm:grid-cols-[1.3fr_0.8fr_0.7fr_0.8fr] sm:items-center">
<div>
<p className="font-semibold text-stone-950">{transaction.action}</p>
<p className="mt-1 text-xs text-stone-500">
<p className="font-semibold text-stone-950 dark:text-neutral-50">
{transaction.action}
</p>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{new Date(transaction.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
@@ -49,12 +53,13 @@ export default function BillingHistoryTab({
})}
</p>
</div>
<div className="text-stone-500">{transaction.type}</div>
<div className={`font-semibold ${isEarn ? 'text-sage-600' : 'text-stone-950'}`}>
<div className="text-stone-500 dark:text-neutral-400">{transaction.type}</div>
<div
className={`font-semibold ${isEarn ? 'text-sage-600 dark:text-sage-300' : 'text-stone-950 dark:text-neutral-50'}`}>
{isEarn ? '+' : '-'}${Math.abs(transaction.amountUsd).toFixed(2)}
</div>
<div className="sm:text-right">
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-stone-500">
<span className="rounded-full bg-stone-100 dark:bg-neutral-800 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-stone-500 dark:text-neutral-400">
{t('settings.billing.history.posted')}
</span>
</div>
@@ -63,7 +68,7 @@ export default function BillingHistoryTab({
})}
</div>
) : (
<div className="px-5 py-8 text-sm text-stone-500">
<div className="px-5 py-8 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.billing.history.empty')}
</div>
)}
@@ -9,16 +9,18 @@ interface InferenceBudgetProps {
const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps) => {
const { t } = useT();
return (
<div className="rounded-2xl border border-stone-200 bg-white p-3">
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.billing.inferenceBudget.title')}
</h3>
{isLoadingCredits && (
<span className="text-[10px] text-stone-500">{t('common.loading')}</span>
<span className="text-[10px] text-stone-500 dark:text-neutral-400">
{t('common.loading')}
</span>
)}
{teamUsage && !isLoadingCredits && (
<span className="text-xs text-stone-400">
<span className="text-xs text-stone-400 dark:text-neutral-500">
{teamUsage.cycleBudgetUsd > 0
? t('settings.billing.inferenceBudget.remaining')
.replace('{remaining}', (teamUsage.remainingUsd ?? 0).toFixed(2))
@@ -30,7 +32,7 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
{teamUsage ? (
teamUsage.cycleBudgetUsd > 0 ? (
<>
<div className="h-1.5 bg-stone-200 rounded-full overflow-hidden mb-2">
<div className="h-1.5 bg-stone-200 dark:bg-neutral-800 rounded-full overflow-hidden mb-2">
<div
className={`h-full rounded-full transition-all duration-300 ${
teamUsage.remainingUsd <= 0
@@ -49,13 +51,13 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
</div>
<div className="mt-1 flex items-center justify-between">
{((teamUsage.cycleLimit5hr ?? 0) > 0 || (teamUsage.fiveHourCapUsd ?? 0) > 0) && (
<span className="text-[11px] text-stone-500">
<span className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.billing.inferenceBudget.tenHourCap')
.replace('{used}', (teamUsage.cycleLimit5hr ?? 0).toFixed(2))
.replace('{cap}', (teamUsage.fiveHourCapUsd ?? 0).toFixed(2))}
</span>
)}
<span className="text-[11px] text-stone-500 ml-auto">
<span className="text-[11px] text-stone-500 dark:text-neutral-400 ml-auto">
{t('settings.billing.inferenceBudget.cycleEnds').replace(
'{date}',
new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')
@@ -69,16 +71,18 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
)}
</>
) : (
<div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
<p className="text-[11px] text-stone-600">
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<p className="text-[11px] text-stone-600 dark:text-neutral-300">
{t('settings.billing.inferenceBudget.noBudgetDesc')}
</p>
</div>
)
) : isLoadingCredits ? (
<div className="h-1.5 w-full rounded-full bg-stone-200 animate-pulse" />
<div className="h-1.5 w-full rounded-full bg-stone-200 dark:bg-neutral-800 animate-pulse" />
) : (
<p className="text-xs text-stone-500">{t('settings.billing.inferenceBudget.loadError')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.inferenceBudget.loadError')}
</p>
)}
</div>
);
@@ -31,36 +31,36 @@ const PayAsYouGoCard = ({
return (
<>
<div className="rounded-lg bg-white p-6 shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
<h3 className="font-headline text-xl font-bold tracking-tight text-stone-950">
<div className="rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
<h3 className="font-headline text-xl font-bold tracking-tight text-stone-950 dark:text-neutral-50">
{t('settings.billing.payAsYouGo.creditBalanceTitle')}
</h3>
<p className="mt-1 text-sm text-stone-500">
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.billing.payAsYouGo.creditBalanceDesc')}
</p>
{creditBalance ? (
<div className="grid mt-4 gap-3 sm:grid-cols-3">
<div>
<p className="text-sm font-semibold text-stone-400">
<p className="text-sm font-semibold text-stone-400 dark:text-neutral-500">
{t('settings.billing.payAsYouGo.available')}
</p>
<p className="mt-2 text-2xl font-bold tracking-tight text-stone-600">
<p className="mt-2 text-2xl font-bold tracking-tight text-stone-600 dark:text-neutral-300">
${availableCredits.toFixed(2)}
</p>
</div>
<div>
<p className="text-sm font-semibold text-stone-400">
<p className="text-sm font-semibold text-stone-400 dark:text-neutral-500">
{t('settings.billing.payAsYouGo.promotionalCredits')}
</p>
<p className="mt-2 text-xl font-bold tracking-tight text-stone-600">
<p className="mt-2 text-xl font-bold tracking-tight text-stone-600 dark:text-neutral-300">
${promoCredits.toFixed(2)}
</p>
</div>
<div>
<p className="text-sm font-semibold text-stone-400">
<p className="text-sm font-semibold text-stone-400 dark:text-neutral-500">
{t('settings.billing.payAsYouGo.topUpBalance')}
</p>
<p className="mt-2 text-xl font-bold tracking-tight text-stone-600">
<p className="mt-2 text-xl font-bold tracking-tight text-stone-600 dark:text-neutral-300">
${teamTopupCredits.toFixed(2)}
</p>
</div>
@@ -68,20 +68,23 @@ const PayAsYouGoCard = ({
) : isLoadingCredits ? (
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{[0, 1, 2].map(index => (
<div key={index} className="h-24 rounded-2xl bg-stone-100 animate-pulse" />
<div
key={index}
className="h-24 rounded-2xl bg-stone-100 dark:bg-neutral-800 animate-pulse"
/>
))}
</div>
) : (
<p className="mt-5 text-sm text-stone-500">
<p className="mt-5 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.billing.payAsYouGo.unableToLoad')}
</p>
)}
</div>
<div className="rounded-lg bg-white p-6 shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
<h3 className="font-headline text-xl font-bold tracking-tight text-stone-950">
<div className="rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
<h3 className="font-headline text-xl font-bold tracking-tight text-stone-950 dark:text-neutral-50">
{t('settings.billing.payAsYouGo.chooseTopUpTitle')}
</h3>
<p className="mt-1 text-sm text-stone-500">
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.billing.payAsYouGo.chooseTopUpDesc')}
</p>
@@ -91,27 +94,29 @@ const PayAsYouGoCard = ({
key={amount}
onClick={() => onTopUp(amount)}
disabled={isToppingUp}
className="group rounded-2xl border border-primary-200/50 bg-primary-50/50 px-4 py-5 text-center transition-all hover:border-primary-200 disabled:cursor-not-allowed disabled:opacity-50">
<div className="text-2xl font-bold tracking-tight text-primary-600">
className="group rounded-2xl border border-primary-200 dark:border-primary-500/30 bg-primary-50/50 px-4 py-5 text-center transition-all hover:border-primary-200 dark:border-primary-500/30 disabled:cursor-not-allowed disabled:opacity-50">
<div className="text-2xl font-bold tracking-tight text-primary-600 dark:text-primary-300">
{isToppingUp ? t('settings.billing.payAsYouGo.opening') : `$${amount.toFixed(2)}`}
</div>
<div className="mt-1 text-[11px] font-semibold text-stone-400">
<div className="mt-1 text-[11px] font-semibold text-stone-400 dark:text-neutral-500">
{t('settings.billing.payAsYouGo.topUpCredits')}
</div>
</button>
))}
</div>
<div className="mt-4 rounded-2xl border border-stone-200 bg-stone-50 p-4">
<div className="mt-4 rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
<div>
<label
htmlFor="billing-custom-top-up"
className="text-[11px] font-semibold uppercase tracking-[0.24em] text-stone-400">
className="text-[11px] font-semibold uppercase tracking-[0.24em] text-stone-400 dark:text-neutral-500">
{t('settings.billing.payAsYouGo.customAmount')}
</label>
<div className="mt-2 flex items-center rounded-2xl bg-white px-4 ring-1 ring-stone-200 focus-within:ring-2 focus-within:ring-primary-500/20">
<span className="text-sm font-semibold text-stone-500">$</span>
<div className="mt-2 flex items-center rounded-2xl bg-white dark:bg-neutral-900 px-4 ring-1 ring-stone-200 focus-within:ring-2 focus-within:ring-primary-500/20">
<span className="text-sm font-semibold text-stone-500 dark:text-neutral-400">
$
</span>
<input
id="billing-custom-top-up"
type="number"
@@ -124,17 +129,17 @@ const PayAsYouGoCard = ({
if (e.key === 'Enter') handleCustomTopUp();
}}
placeholder={t('settings.billing.payAsYouGo.enterAmount')}
className="w-full border-0 bg-transparent px-3 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-0"
className="w-full border-0 bg-transparent px-3 py-3 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-0"
/>
</div>
<p className="mt-2 text-xs text-stone-500">
<p className="mt-2 text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.payAsYouGo.chooseTopUpDesc')}
</p>
</div>
<button
onClick={handleCustomTopUp}
disabled={!customTopUpAmountValid || isToppingUp}
className="rounded-2xl bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50 lg:self-end">
className="rounded-2xl bg-stone-950 dark:bg-neutral-50 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50 lg:self-end">
{isToppingUp
? t('settings.billing.payAsYouGo.opening')
: t('settings.billing.payAsYouGo.chargeCustomAmount')}
@@ -28,32 +28,32 @@ const SubscriptionPlans = ({
const { t } = useT();
return (
<>
<div className="flex flex-col gap-2 rounded-2xl bg-white p-4 border border-stone-200">
<h3 className="font-headline text-2xl font-bold tracking-tight text-stone-950">
<div className="flex flex-col gap-2 rounded-2xl bg-white dark:bg-neutral-900 p-4 border border-stone-200 dark:border-neutral-800">
<h3 className="font-headline text-2xl font-bold tracking-tight text-stone-950 dark:text-neutral-50">
{t('settings.billing.subscription.chooseTitle')}
</h3>
<p className="mt-1 text-sm text-stone-500">
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">
{t('settings.billing.subscription.chooseSubtitle')}
</p>
<div className="flex items-center justify-between mt-4">
<div>
<p className="text-sm font-semibold text-stone-950">
<p className="text-sm font-semibold text-stone-950 dark:text-neutral-50">
{t('settings.billing.subscription.cryptoQuestion')}
</p>
<p className="mt-0.5 text-xs text-stone-500">
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.subscription.cryptoDesc')}
</p>
</div>
<button
onClick={() => setPaymentMethod(paymentMethod === 'card' ? 'crypto' : 'card')}
className={`relative h-6 w-11 rounded-full transition-colors ${
paymentMethod === 'crypto' ? 'bg-primary-600' : 'bg-stone-300'
paymentMethod === 'crypto' ? 'bg-primary-600' : 'bg-stone-300 dark:bg-neutral-700'
}`}
role="switch"
aria-checked={paymentMethod === 'crypto'}>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
paymentMethod === 'crypto' ? 'translate-x-5' : 'translate-x-0'
}`}
/>
@@ -63,7 +63,7 @@ const SubscriptionPlans = ({
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="mx-auto inline-flex w-fit rounded-full bg-white p-1 shadow-sm ring-1 ring-stone-950/5 lg:mx-0">
<div className="mx-auto inline-flex w-fit rounded-full bg-white dark:bg-neutral-900 p-1 shadow-sm ring-1 ring-stone-950/5 lg:mx-0">
<button
onClick={() => {
if (paymentMethod !== 'crypto') setBillingInterval('monthly');
@@ -72,7 +72,7 @@ const SubscriptionPlans = ({
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
billingInterval === 'monthly'
? 'bg-primary-600 text-white'
: 'text-stone-500 hover:text-stone-900'
: 'text-stone-500 dark:text-neutral-400 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100'
} ${paymentMethod === 'crypto' ? 'cursor-not-allowed opacity-40' : ''}`}>
{t('settings.billing.subscription.monthly')}
</button>
@@ -81,7 +81,7 @@ const SubscriptionPlans = ({
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
billingInterval === 'annual'
? 'bg-primary-600 text-white'
: 'text-stone-500 hover:text-stone-900'
: 'text-stone-500 dark:text-neutral-400 hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100'
}`}>
{t('settings.billing.subscription.annual')}
</button>
@@ -103,7 +103,7 @@ const SubscriptionPlans = ({
d="M5 13l4 4L19 7"
/>
</svg>
<p className="text-sm font-medium text-sage-700">
<p className="text-sm font-medium text-sage-700 dark:text-sage-300">
{t('settings.billing.subscription.paymentConfirmed')}
</p>
</div>
@@ -128,7 +128,7 @@ const SubscriptionPlans = ({
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<p className="text-sm text-amber-700">
<p className="text-sm text-amber-700 dark:text-amber-300">
{t('settings.billing.subscription.waitingPayment')}
</p>
</div>
@@ -148,10 +148,10 @@ const SubscriptionPlans = ({
key={plan.tier}
className={`relative flex flex-col gap-5 rounded-[24px] px-5 py-5 transition-all sm:flex-row sm:items-center sm:justify-between ${
isPopular
? 'bg-primary-50 ring-2 ring-primary-500 shadow-sm'
? 'bg-primary-50 dark:bg-primary-500/10 ring-2 ring-primary-500 shadow-sm'
: isCurrent
? 'bg-white ring-1 ring-primary-200 shadow-sm'
: 'bg-white ring-1 ring-stone-950/5 shadow-sm'
? 'bg-white dark:bg-neutral-900 ring-1 ring-primary-200 shadow-sm'
: 'bg-white dark:bg-neutral-900 ring-1 ring-stone-950/5 shadow-sm'
}`}>
<div className="flex items-start gap-4">
<div
@@ -159,8 +159,8 @@ const SubscriptionPlans = ({
plan.recommended
? 'bg-primary-600 text-white'
: isCurrent
? 'bg-primary-100 text-primary-700'
: 'bg-stone-100 text-stone-700'
? 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200'
}`}>
{plan.tier === 'PRO' ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
@@ -197,7 +197,7 @@ const SubscriptionPlans = ({
<div>
<div className="flex flex-wrap items-center gap-2">
<h4 className="font-headline text-xl font-bold tracking-tight text-stone-950">
<h4 className="font-headline text-xl font-bold tracking-tight text-stone-950 dark:text-neutral-50">
{plan.name}
</h4>
{isPopular && (
@@ -206,7 +206,7 @@ const SubscriptionPlans = ({
</span>
)}
{isCurrent && !plan.recommended && (
<span className="rounded-full bg-stone-950 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.24em] text-white">
<span className="rounded-full bg-stone-950 dark:bg-neutral-50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.24em] text-white">
{t('settings.billing.subscription.current')}
</span>
)}
@@ -215,7 +215,7 @@ const SubscriptionPlans = ({
{plan.features.slice(0, 4).map(feature => (
<span
key={feature.text}
className="rounded-full bg-stone-100/50 border border-primary-200/50 px-3 py-1 text-xs font-medium text-stone-600">
className="rounded-full bg-stone-100/50 border border-primary-200 dark:border-primary-500/30 px-3 py-1 text-xs font-medium text-stone-600 dark:text-neutral-300">
{feature.text}
</span>
))}
@@ -225,16 +225,16 @@ const SubscriptionPlans = ({
<div className="flex items-end justify-between gap-2 sm:min-w-[148px] sm:flex-col sm:items-end">
<div className="text-right">
<p className="text-2xl font-bold tracking-tight text-stone-950">
<p className="text-2xl font-bold tracking-tight text-stone-950 dark:text-neutral-50">
{displayPrice(plan, billingInterval)}
{plan.tier !== 'FREE' && (
<span className="text-sm font-medium text-stone-400">
<span className="text-sm font-medium text-stone-400 dark:text-neutral-500">
{t('settings.billing.subscription.perMonth')}
</span>
)}
</p>
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
<p className="mt-1 text-xs text-stone-500">
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('settings.billing.subscription.billedAnnually').replace(
'{price}',
String(plan.annualPrice)
@@ -242,7 +242,7 @@ const SubscriptionPlans = ({
</p>
)}
{savings && (
<p className="mt-1 text-xs font-semibold uppercase text-primary-600">
<p className="mt-1 text-xs font-semibold uppercase text-primary-600 dark:text-primary-300">
{t('settings.billing.subscription.save').replace('{pct}', String(savings))}
</p>
)}
@@ -258,8 +258,8 @@ const SubscriptionPlans = ({
disabled={isPurchasing}
className={`rounded-full px-4 py-2 text-xs font-semibold transition-colors ${
isPurchasing
? 'cursor-not-allowed bg-stone-200 text-stone-400'
: 'bg-stone-950 text-white hover:bg-primary-600'
? 'cursor-not-allowed bg-stone-200 dark:bg-neutral-800 text-stone-400 dark:text-neutral-500'
: 'bg-stone-950 dark:bg-neutral-50 text-white hover:bg-primary-600'
}`}>
{isThisPurchasing
? t('settings.billing.subscription.waiting')
@@ -24,18 +24,26 @@ const CoreJobList = ({
}: CoreJobListProps) => {
const { t } = useT();
return (
<section className="rounded-xl border border-stone-200 bg-white">
<div className="p-4 border-b border-stone-200">
<h3 className="text-sm font-semibold text-stone-900">{t('settings.cron.jobs.title')}</h3>
<p className="text-xs text-stone-500 mt-1">{t('settings.cron.jobs.desc')}</p>
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-stone-200 dark:border-neutral-800">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.cron.jobs.title')}
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t('settings.cron.jobs.desc')}
</p>
</div>
{loading && (
<div className="p-4 text-sm text-stone-400">{t('settings.cron.jobs.loading')}</div>
<div className="p-4 text-sm text-stone-400 dark:text-neutral-500">
{t('settings.cron.jobs.loading')}
</div>
)}
{!loading && coreJobs.length === 0 && (
<div className="p-4 text-sm text-stone-400">{t('settings.cron.jobs.empty')}</div>
<div className="p-4 text-sm text-stone-400 dark:text-neutral-500">
{t('settings.cron.jobs.empty')}
</div>
)}
{!loading &&
@@ -44,26 +52,28 @@ const CoreJobList = ({
return (
<div
key={job.id}
className={`p-4 ${index === 0 ? '' : 'border-t border-stone-200'} space-y-3`}>
className={`p-4 ${index === 0 ? '' : 'border-t border-stone-200 dark:border-neutral-800'} space-y-3`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-stone-900">{job.name || job.id}</div>
<div className="text-[11px] text-stone-400">{job.id}</div>
<div className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{job.name || job.id}
</div>
<div className="text-[11px] text-stone-400 dark:text-neutral-500">{job.id}</div>
</div>
<span
className={`px-2 py-1 text-[11px] font-semibold uppercase border rounded-full ${
job.enabled
? 'bg-sage-50 text-sage-700 border-sage-200'
: 'bg-stone-100 text-stone-600 border-stone-200'
? 'bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border-sage-200 dark:border-sage-500/30'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 border-stone-200 dark:border-neutral-800'
}`}>
{job.enabled ? t('common.enabled') : t('settings.cron.jobs.paused')}
</span>
</div>
<div className="text-xs text-stone-600 space-y-1">
<div className="text-xs text-stone-600 dark:text-neutral-300 space-y-1">
<div>
{t('settings.cron.jobs.schedule')}{' '}
<span className="font-medium text-stone-700">
<span className="font-medium text-stone-700 dark:text-neutral-200">
{job.schedule.kind === 'cron'
? job.schedule.expr
: job.schedule.kind === 'every'
@@ -73,14 +83,16 @@ const CoreJobList = ({
</div>
<div>
{t('settings.cron.jobs.nextRun')}{' '}
<span className="font-medium text-stone-700">
<span className="font-medium text-stone-700 dark:text-neutral-200">
{new Date(job.next_run).toLocaleString()}
</span>
</div>
{job.last_status && (
<div>
{t('settings.cron.jobs.lastStatus')}{' '}
<span className="font-medium text-stone-700">{job.last_status}</span>
<span className="font-medium text-stone-700 dark:text-neutral-200">
{job.last_status}
</span>
</div>
)}
</div>
@@ -127,14 +139,16 @@ const CoreJobList = ({
</div>
{runs.length > 0 && (
<div className="rounded-lg border border-stone-200 bg-stone-50 p-3 space-y-1">
<div className="text-[11px] uppercase tracking-wide text-stone-400">
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 space-y-1">
<div className="text-[11px] uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{t('settings.cron.jobs.recentRuns')}
</div>
{runs.map(run => (
<div key={run.id} className="text-xs text-stone-600">
<span className="font-medium text-stone-700">{run.status}</span> at{' '}
{new Date(run.finished_at).toLocaleString()}
<div key={run.id} className="text-xs text-stone-600 dark:text-neutral-300">
<span className="font-medium text-stone-700 dark:text-neutral-200">
{run.status}
</span>{' '}
at {new Date(run.finished_at).toLocaleString()}
</div>
))}
</div>
@@ -77,38 +77,38 @@ const DeviceCapabilitySection = ({
return (
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.deviceCapability.modelTier')}
</h3>
{presetsLoading && !presetsData && (
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 text-sm text-stone-500 animate-pulse">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 text-sm text-stone-500 dark:text-neutral-400 animate-pulse">
{t('settings.localModel.deviceCapability.loadingDeviceInfo')}
</div>
)}
{!presetsLoading && !presetsData && presetError && (
<div className="bg-red-50 rounded-lg border border-red-300 p-4 text-sm text-red-600">
<div className="bg-red-50 dark:bg-red-500/10 rounded-lg border border-red-300 dark:border-red-500/40 p-4 text-sm text-red-600 dark:text-red-300">
{t('settings.localModel.deviceCapability.couldNotLoadPresets')} {presetError}
</div>
)}
{presetsData?.device && (
<div className="bg-stone-50 rounded-lg border border-stone-200 p-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<div className="text-stone-500 uppercase tracking-wide">
<div className="text-stone-500 dark:text-neutral-400 uppercase tracking-wide">
{t('settings.localModel.deviceCapability.ram')}
</div>
<div className="text-stone-800 mt-0.5 font-medium">
<div className="text-stone-800 dark:text-neutral-100 mt-0.5 font-medium">
{formatRamGb(presetsData.device.total_ram_bytes)}
</div>
</div>
<div>
<div className="text-stone-500 uppercase tracking-wide">
<div className="text-stone-500 dark:text-neutral-400 uppercase tracking-wide">
{t('settings.localModel.deviceCapability.cpu')}
</div>
<div
className="text-stone-800 mt-0.5 font-medium truncate"
className="text-stone-800 dark:text-neutral-100 mt-0.5 font-medium truncate"
title={presetsData.device.cpu_brand}>
{t('settings.localModel.deviceCapability.cores').replace(
'{count}',
@@ -117,11 +117,11 @@ const DeviceCapabilitySection = ({
</div>
</div>
<div>
<div className="text-stone-500 uppercase tracking-wide">
<div className="text-stone-500 dark:text-neutral-400 uppercase tracking-wide">
{t('settings.localModel.deviceCapability.gpu')}
</div>
<div
className="text-stone-800 mt-0.5 font-medium truncate"
className="text-stone-800 dark:text-neutral-100 mt-0.5 font-medium truncate"
title={presetsData.device.gpu_description ?? undefined}>
{presetsData.device.has_gpu
? (presetsData.device.gpu_description ??
@@ -137,10 +137,10 @@ const DeviceCapabilitySection = ({
<div
className={`rounded-lg border p-3 space-y-2 ${
installFailed
? 'border-red-300 bg-red-50'
? 'border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10'
: installInProgress
? 'border-blue-300 bg-blue-50'
: 'border-amber-300 bg-amber-50'
? 'border-blue-300 dark:border-blue-500/40 bg-blue-50 dark:bg-blue-500/10'
: 'border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10'
}`}>
{installInProgress ? (
<>
@@ -153,10 +153,10 @@ const DeviceCapabilitySection = ({
: '…'}
</div>
</div>
<div className="text-xs text-blue-800">
<div className="text-xs text-blue-800 dark:text-blue-200">
{installWarning ?? t('settings.localModel.deviceCapability.downloadingSetupDesc')}
</div>
<div className="h-1.5 rounded-full bg-blue-200 overflow-hidden">
<div className="h-1.5 rounded-full bg-blue-200 dark:bg-blue-500/30 overflow-hidden">
<div className="h-full w-1/3 bg-blue-500 animate-pulse" />
</div>
</>
@@ -165,11 +165,11 @@ const DeviceCapabilitySection = ({
<div className="text-sm font-semibold text-red-900">
{t('settings.localModel.deviceCapability.installFailed')}
</div>
<div className="text-xs text-red-800">
<div className="text-xs text-red-800 dark:text-red-200">
{installWarning ?? t('settings.localModel.deviceCapability.installFailedDesc')}
</div>
{installError && (
<pre className="max-h-40 overflow-auto rounded bg-red-100 border border-red-200 p-2 text-[10px] text-red-700 leading-tight whitespace-pre-wrap break-words">
<pre className="max-h-40 overflow-auto rounded bg-red-100 dark:bg-red-500/20 border border-red-200 dark:border-red-500/30 p-2 text-[10px] text-red-700 dark:text-red-300 leading-tight whitespace-pre-wrap break-words">
{installError}
</pre>
)}
@@ -189,14 +189,14 @@ const DeviceCapabilitySection = ({
href="https://ollama.com"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-xs rounded-md border border-red-300 hover:border-red-400 text-red-800">
className="px-3 py-1.5 text-xs rounded-md border border-red-300 dark:border-red-500/40 hover:border-red-400 text-red-800 dark:text-red-200">
{t('settings.localModel.status.installManually')}
</a>
</div>
</>
) : (
<>
<div className="text-xs text-amber-800">
<div className="text-xs text-amber-800 dark:text-amber-200">
<span className="font-semibold text-amber-900">
{t('settings.localModel.deviceCapability.installFirst')}
</span>{' '}
@@ -207,7 +207,7 @@ const DeviceCapabilitySection = ({
href="https://ollama.com"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-xs rounded-md border border-amber-300 hover:border-amber-400 text-amber-800">
className="px-3 py-1.5 text-xs rounded-md border border-amber-300 dark:border-amber-500/40 hover:border-amber-400 text-amber-800 dark:text-amber-200">
{t('settings.localModel.status.ollamaDocs')}
</a>
</div>
@@ -225,28 +225,28 @@ const DeviceCapabilitySection = ({
disabled={applying !== null}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
isDisabledActive
? 'border-primary-400 bg-primary-50'
: 'border-stone-200 bg-stone-50 hover:bg-stone-100'
? 'border-primary-400 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800'
} ${applying !== null ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900">
<span className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.deviceCapability.disabled')}
</span>
{isDisabledActive && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-50 text-primary-600 uppercase tracking-wide">
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300 uppercase tracking-wide">
{t('settings.localModel.deviceCapability.active')}
</span>
)}
{(presetsData.recommend_disabled || !ollamaAvailable) && !isDisabledActive && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-50 text-amber-700 uppercase tracking-wide">
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 uppercase tracking-wide">
{t('settings.localModel.deviceCapability.recommended')}
</span>
)}
</div>
<span className="text-xs text-stone-500">0 GB</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">0 GB</span>
</div>
<div className="text-xs text-stone-500 mt-1">
<div className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t('settings.localModel.deviceCapability.disabledDesc')}
</div>
</button>
@@ -266,40 +266,44 @@ const DeviceCapabilitySection = ({
}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
isCurrent
? 'border-primary-400 bg-primary-50'
: 'border-stone-200 bg-stone-50 hover:bg-stone-100'
? 'border-primary-400 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800'
} ${
locked
? 'opacity-50 cursor-not-allowed hover:bg-stone-50'
? 'opacity-50 cursor-not-allowed hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60'
: applying !== null && !isApplying
? 'opacity-60 cursor-not-allowed'
: 'cursor-pointer'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900">{preset.label}</span>
<span className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{preset.label}
</span>
{isCurrent && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-50 text-primary-600 uppercase tracking-wide">
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300 uppercase tracking-wide">
{t('settings.localModel.deviceCapability.active')}
</span>
)}
{isApplying && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-stone-100 text-stone-500 uppercase tracking-wide">
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-stone-100 dark:bg-neutral-800 text-stone-500 dark:text-neutral-400 uppercase tracking-wide">
{t('settings.localModel.deviceCapability.applying')}
</span>
)}
{locked && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-50 text-amber-700 uppercase tracking-wide">
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 uppercase tracking-wide">
{t('settings.localModel.deviceCapability.needsOllama')}
</span>
)}
</div>
<span className="text-xs text-stone-500">
<span className="text-xs text-stone-500 dark:text-neutral-400">
~{Number(preset.approx_download_gb).toFixed(1)} GB
</span>
</div>
<div className="text-xs text-stone-400 mt-1">{preset.description}</div>
<div className="text-[10px] text-stone-500 mt-1">
<div className="text-xs text-stone-400 dark:text-neutral-500 mt-1">
{preset.description}
</div>
<div className="text-[10px] text-stone-500 dark:text-neutral-400 mt-1">
Chat: {preset.chat_model_id} &middot; Vision:{' '}
{preset.vision_mode === 'disabled'
? 'disabled'
@@ -311,19 +315,19 @@ const DeviceCapabilitySection = ({
})}
{presetsData.current_tier === 'custom' && !isDisabledActive && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-300">
{t('settings.localModel.deviceCapability.customModelIds')}
</div>
)}
</div>
)}
{applyError && <div className="text-xs text-red-600">{applyError}</div>}
{applyError && <div className="text-xs text-red-600 dark:text-red-300">{applyError}</div>}
{presetError && !(!presetsLoading && !presetsData) && (
<div className="text-xs text-red-600">{presetError}</div>
<div className="text-xs text-red-600 dark:text-red-300">{presetError}</div>
)}
{(applySuccess ?? presetSuccess) && (
<div className="text-xs text-green-700">
<div className="text-xs text-green-700 dark:text-green-300">
{(applySuccess ?? presetSuccess)?.applied_tier === DISABLED_TIER_ID
? t('settings.localModel.deviceCapability.localAiDisabled')
: t('settings.localModel.deviceCapability.appliedTier')
@@ -106,11 +106,11 @@ const ModelDownloadSection = ({
return (
<>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.download.capabilityAssets')}
</h3>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="text-xs text-stone-500">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.localModel.download.quantizationPref')} {assets?.quantization ?? 'q4'}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
@@ -121,24 +121,32 @@ const ModelDownloadSection = ({
{ label: 'STT', key: 'stt' as const, item: assets?.stt },
{ label: 'TTS', key: 'tts' as const, item: assets?.tts },
].map(({ label, key, item }) => (
<div key={String(label)} className="rounded-md border border-stone-200 p-2">
<div className="text-stone-500 text-xs uppercase tracking-wide">{label}</div>
<div className="text-stone-800 mt-1 break-all">{item?.id ?? 'n/a'}</div>
<div
key={String(label)}
className="rounded-md border border-stone-200 dark:border-neutral-800 p-2">
<div className="text-stone-500 dark:text-neutral-400 text-xs uppercase tracking-wide">
{label}
</div>
<div className="text-stone-800 dark:text-neutral-100 mt-1 break-all">
{item?.id ?? 'n/a'}
</div>
<div className={`text-xs mt-1 ${statusTone(item?.state ?? 'idle')}`}>
{statusLabel(item?.state ?? 'idle')}
</div>
{item?.path && (
<div className="text-[10px] text-stone-500 mt-1 break-all">{item.path}</div>
<div className="text-[10px] text-stone-500 dark:text-neutral-400 mt-1 break-all">
{item.path}
</div>
)}
{item?.provider === 'ollama' || item?.provider === 'lm_studio' ? (
<div className="mt-2 text-[10px] text-stone-500">
<div className="mt-2 text-[10px] text-stone-500 dark:text-neutral-400">
{t('settings.localModel.download.manageExternal')}
</div>
) : (
<button
onClick={() => onTriggerAssetDownload(key)}
disabled={!runtimeEnabled || assetDownloadBusy[key]}
className="mt-2 px-2 py-1 text-[10px] rounded border border-stone-200 hover:border-stone-300 disabled:opacity-60 text-stone-600">
className="mt-2 px-2 py-1 text-[10px] rounded border border-stone-200 dark:border-neutral-800 hover:border-stone-300 dark:border-neutral-700 disabled:opacity-60 text-stone-600 dark:text-neutral-300">
{assetDownloadBusy[key]
? t('settings.localModel.download.downloading')
: t('common.download')}
@@ -151,18 +159,18 @@ const ModelDownloadSection = ({
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.download.testSummarization')}
</h3>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<textarea
value={summaryInput}
onChange={e => onSetSummaryInput(e.target.value)}
placeholder={t('settings.localModel.download.summarizePlaceholder')}
className="w-full min-h-28 rounded-md bg-white border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-500"
className="w-full min-h-28 rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
<div className="flex items-center justify-between">
<div className="text-xs text-stone-500">
<div className="text-xs text-stone-500 dark:text-neutral-400">
Calls `openhuman.inference_summarize` via Rust core
</div>
<button
@@ -175,7 +183,7 @@ const ModelDownloadSection = ({
</button>
</div>
{summaryOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-50 border border-stone-200 p-3 text-xs text-stone-700">
<pre className="whitespace-pre-wrap rounded-md bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 p-3 text-xs text-stone-700 dark:text-neutral-200">
{summaryOutput}
</pre>
)}
@@ -183,23 +191,23 @@ const ModelDownloadSection = ({
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.download.testCustomPrompt')}
</h3>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<textarea
value={promptInput}
onChange={e => onSetPromptInput(e.target.value)}
placeholder={t('settings.localModel.download.promptPlaceholder')}
className="w-full min-h-28 rounded-md bg-white border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-500"
className="w-full min-h-28 rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-xs text-stone-700">
<label className="flex items-center gap-2 text-xs text-stone-700 dark:text-neutral-200">
<input
type="checkbox"
checked={promptNoThink}
onChange={e => onSetPromptNoThink(e.target.checked)}
className="h-3.5 w-3.5 rounded border-stone-300 bg-white text-primary-500 focus:ring-primary-500"
className="h-3.5 w-3.5 rounded border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-primary-500 focus:ring-primary-500"
/>
{t('settings.localModel.download.noThinkMode')}
</label>
@@ -213,18 +221,18 @@ const ModelDownloadSection = ({
</button>
</div>
{isPromptLoading && (
<div className="flex items-center gap-2 text-xs text-primary-600">
<div className="flex items-center gap-2 text-xs text-primary-600 dark:text-primary-300">
<div className="h-3 w-3 rounded-full border-2 border-blue-400 border-t-transparent animate-spin" />
{t('settings.localModel.download.runningPrompt')}
</div>
)}
{promptError && (
<div className="rounded-md bg-red-50 border border-red-300 p-3 text-xs text-red-600">
<div className="rounded-md bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/40 p-3 text-xs text-red-600 dark:text-red-300">
{promptError}
</div>
)}
{promptOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-50 border border-stone-200 p-3 text-xs text-stone-700 max-h-64 overflow-auto">
<pre className="whitespace-pre-wrap rounded-md bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 p-3 text-xs text-stone-700 dark:text-neutral-200 max-h-64 overflow-auto">
{promptOutput}
</pre>
)}
@@ -232,21 +240,21 @@ const ModelDownloadSection = ({
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.download.testVisionPrompt')}
</h3>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<textarea
value={visionPromptInput}
onChange={e => onSetVisionPromptInput(e.target.value)}
placeholder={t('settings.localModel.download.visionPromptPlaceholder')}
className="w-full min-h-20 rounded-md bg-white border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full min-h-20 rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<textarea
value={visionImageInput}
onChange={e => onSetVisionImageInput(e.target.value)}
placeholder={t('settings.localModel.download.visionImagePlaceholder')}
className="w-full min-h-20 rounded-md bg-white border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full min-h-20 rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<button
onClick={onRunVisionTest}
@@ -262,7 +270,7 @@ const ModelDownloadSection = ({
: t('settings.localModel.download.runVisionTest')}
</button>
{visionOutput && (
<pre className="whitespace-pre-wrap rounded-md bg-stone-50 border border-stone-200 p-3 text-xs text-stone-700">
<pre className="whitespace-pre-wrap rounded-md bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 p-3 text-xs text-stone-700 dark:text-neutral-200">
{visionOutput}
</pre>
)}
@@ -270,15 +278,15 @@ const ModelDownloadSection = ({
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.download.testEmbeddings')}
</h3>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<textarea
value={embeddingInput}
onChange={e => onSetEmbeddingInput(e.target.value)}
placeholder={t('settings.localModel.download.embeddingPlaceholder')}
className="w-full min-h-20 rounded-md bg-white border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full min-h-20 rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<button
onClick={onRunEmbeddingTest}
@@ -289,7 +297,7 @@ const ModelDownloadSection = ({
: t('settings.localModel.download.runEmbeddingTest')}
</button>
{embeddingOutput && (
<div className="rounded-md bg-stone-50 border border-stone-200 p-3 text-xs text-stone-700 space-y-1">
<div className="rounded-md bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 p-3 text-xs text-stone-700 dark:text-neutral-200 space-y-1">
<div>Model: {embeddingOutput.model_id}</div>
<div>Dimensions: {embeddingOutput.dimensions}</div>
<div>Vectors: {embeddingOutput.vectors.length}</div>
@@ -299,15 +307,15 @@ const ModelDownloadSection = ({
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.download.testVoiceInput')}
</h3>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<input
value={audioPathInput}
onChange={e => onSetAudioPathInput(e.target.value)}
placeholder={t('settings.localModel.download.audioPathPlaceholder')}
className="w-full rounded-md bg-white border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<button
onClick={onRunTranscribeTest}
@@ -318,10 +326,10 @@ const ModelDownloadSection = ({
: t('settings.localModel.download.runTranscriptionTest')}
</button>
{transcribeOutput && (
<div className="rounded-md bg-stone-50 border border-stone-200 p-3 text-xs text-stone-700 space-y-2">
<div className="rounded-md bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 p-3 text-xs text-stone-700 dark:text-neutral-200 space-y-2">
<div>Model: {transcribeOutput.model_id}</div>
<div>
<span className="text-stone-400">Transcript:</span>
<span className="text-stone-400 dark:text-neutral-500">Transcript:</span>
<pre className="whitespace-pre-wrap mt-1">{transcribeOutput.text}</pre>
</div>
</div>
@@ -330,21 +338,21 @@ const ModelDownloadSection = ({
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.download.testVoiceOutput')}
</h3>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<textarea
value={ttsInput}
onChange={e => onSetTtsInput(e.target.value)}
placeholder={t('settings.localModel.download.ttsPlaceholder')}
className="w-full min-h-20 rounded-md bg-white border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full min-h-20 rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<input
value={ttsOutputPath}
onChange={e => onSetTtsOutputPath(e.target.value)}
placeholder={t('settings.localModel.download.ttsOutputPlaceholder')}
className="w-full rounded-md bg-white border border-stone-200 px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
className="w-full rounded-md bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<button
onClick={onRunTtsTest}
@@ -355,7 +363,7 @@ const ModelDownloadSection = ({
: t('settings.localModel.download.runTtsTest')}
</button>
{ttsOutput && (
<div className="rounded-md bg-stone-50 border border-stone-200 p-3 text-xs text-stone-700 space-y-1">
<div className="rounded-md bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 p-3 text-xs text-stone-700 dark:text-neutral-200 space-y-1">
<div>Voice: {ttsOutput.voice_id}</div>
<div className="break-all">Output: {ttsOutput.output_path}</div>
</div>
@@ -90,10 +90,10 @@ const ModelStatusSection = ({
if (showInstallOllamaCta) {
return (
<section className="rounded-lg border border-amber-300 bg-amber-50 p-4 space-y-3">
<section className="rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-3">
<svg
className="h-5 w-5 flex-shrink-0 text-amber-600 mt-0.5"
className="h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-300 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
@@ -108,7 +108,7 @@ const ModelStatusSection = ({
<div className="text-sm font-semibold text-amber-900">
{t('settings.localModel.status.ollamaNotInstalled')}
</div>
<div className="text-xs text-amber-800">
<div className="text-xs text-amber-800 dark:text-amber-200">
{t('settings.localModel.status.ollamaNotInstalledDesc')}
</div>
</div>
@@ -118,23 +118,23 @@ const ModelStatusSection = ({
href="https://ollama.com"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-xs rounded-md border border-amber-300 hover:border-amber-400 text-amber-800">
className="px-3 py-1.5 text-xs rounded-md border border-amber-300 dark:border-amber-500/40 hover:border-amber-400 text-amber-800 dark:text-amber-200">
{t('settings.localModel.status.ollamaDocs')}
</a>
</div>
{isInstallError && status?.error_detail && (
<div className="space-y-1 pt-2 border-t border-amber-200">
<div className="space-y-1 pt-2 border-t border-amber-200 dark:border-amber-500/30">
<button
type="button"
onClick={onToggleErrorDetail}
className="text-xs text-red-700 hover:text-red-600 underline">
className="text-xs text-red-700 dark:text-red-300 hover:text-red-600 dark:text-red-300 underline">
{showErrorDetail
? t('settings.localModel.status.hideErrorDetails')
: t('settings.localModel.status.showInstallErrorDetails')}
</button>
{showErrorDetail && (
<pre className="max-h-40 overflow-auto rounded bg-red-50 border border-red-200 p-2 text-[10px] text-red-700 leading-tight whitespace-pre-wrap break-words">
<pre className="max-h-40 overflow-auto rounded bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30 p-2 text-[10px] text-red-700 dark:text-red-300 leading-tight whitespace-pre-wrap break-words">
{status.error_detail}
</pre>
)}
@@ -148,19 +148,19 @@ const ModelStatusSection = ({
<>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.status.runtimeStatus')}
</h3>
<button
onClick={onRefreshStatus}
className="text-sm text-primary-500 hover:text-primary-600 transition-colors">
className="text-sm text-primary-500 hover:text-primary-600 dark:text-primary-300 transition-colors">
{t('common.refresh')}
</button>
</div>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-stone-500">{t('settings.ai.state')}</span>
<span className="text-stone-500 dark:text-neutral-400">{t('settings.ai.state')}</span>
<span className={`font-medium ${statusTone(status?.state ?? 'idle')}`}>
{status
? statusLabel(downloads?.state ?? status.state)
@@ -168,7 +168,7 @@ const ModelStatusSection = ({
</span>
</div>
<div className="h-2 rounded-full bg-stone-200 overflow-hidden">
<div className="h-2 rounded-full bg-stone-200 dark:bg-neutral-800 overflow-hidden">
<div
className={`h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500 ${
isIndeterminateDownload ? 'animate-pulse' : ''
@@ -177,7 +177,7 @@ const ModelStatusSection = ({
/>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500 dark:text-neutral-400">
<span>
{t('settings.localModel.status.progress')}{' '}
{isInstalling
@@ -186,8 +186,12 @@ const ModelStatusSection = ({
? t('settings.localModel.status.downloadingUnknown')
: `${Math.round(progress * 100)}%`}
</span>
{downloadedText && <span className="text-stone-600">{downloadedText}</span>}
{speedText && <span className="text-primary-600">{speedText}</span>}
{downloadedText && (
<span className="text-stone-600 dark:text-neutral-300">{downloadedText}</span>
)}
{speedText && (
<span className="text-primary-600 dark:text-primary-300">{speedText}</span>
)}
{etaText && (
<span className="text-primary-500">
{t('settings.localModel.status.eta')} {etaText}
@@ -196,42 +200,48 @@ const ModelStatusSection = ({
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-stone-200 p-2">
<div className="text-stone-500 text-xs uppercase tracking-wide">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 p-2">
<div className="text-stone-500 dark:text-neutral-400 text-xs uppercase tracking-wide">
{t('settings.localModel.status.provider')}
</div>
<div className="text-stone-800 mt-1">{status?.provider ?? 'n/a'}</div>
<div className="text-stone-800 dark:text-neutral-100 mt-1">
{status?.provider ?? 'n/a'}
</div>
</div>
<div className="rounded-md border border-stone-200 p-2">
<div className="text-stone-500 text-xs uppercase tracking-wide">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 p-2">
<div className="text-stone-500 dark:text-neutral-400 text-xs uppercase tracking-wide">
{t('settings.localModel.status.model')}
</div>
<div className="text-stone-800 mt-1">{status?.model_id ?? 'n/a'}</div>
<div className="text-stone-800 dark:text-neutral-100 mt-1">
{status?.model_id ?? 'n/a'}
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="rounded-md border border-stone-200 p-2">
<div className="text-stone-500 text-xs uppercase tracking-wide">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 p-2">
<div className="text-stone-500 dark:text-neutral-400 text-xs uppercase tracking-wide">
{t('settings.localModel.status.backend')}
</div>
<div className="text-stone-800 mt-1">{status?.active_backend ?? 'cpu'}</div>
<div className="text-stone-800 dark:text-neutral-100 mt-1">
{status?.active_backend ?? 'cpu'}
</div>
</div>
<div className="rounded-md border border-stone-200 p-2">
<div className="text-stone-500 text-xs uppercase tracking-wide">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 p-2">
<div className="text-stone-500 dark:text-neutral-400 text-xs uppercase tracking-wide">
{t('settings.localModel.status.lastLatency')}
</div>
<div className="text-stone-800 mt-1">
<div className="text-stone-800 dark:text-neutral-100 mt-1">
{typeof status?.last_latency_ms === 'number'
? `${status.last_latency_ms} ms`
: 'n/a'}
</div>
</div>
<div className="rounded-md border border-stone-200 p-2">
<div className="text-stone-500 text-xs uppercase tracking-wide">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 p-2">
<div className="text-stone-500 dark:text-neutral-400 text-xs uppercase tracking-wide">
{t('settings.localModel.status.generationTps')}
</div>
<div className="text-stone-800 mt-1">
<div className="text-stone-800 dark:text-neutral-100 mt-1">
{typeof status?.gen_toks_per_sec === 'number'
? `${status.gen_toks_per_sec.toFixed(1)} tok/s`
: 'n/a'}
@@ -240,38 +250,44 @@ const ModelStatusSection = ({
</div>
{status?.model_path && (
<div className="text-xs text-stone-500 break-all">
<div className="text-xs text-stone-500 dark:text-neutral-400 break-all">
{t('settings.localModel.status.artifact')} {status.model_path}
</div>
)}
{status?.backend_reason && (
<div className="text-xs text-primary-600">{status.backend_reason}</div>
<div className="text-xs text-primary-600 dark:text-primary-300">
{status.backend_reason}
</div>
)}
{status?.warning && (
<div className="text-xs text-amber-700 dark:text-amber-300">{status.warning}</div>
)}
{statusError && (
<div className="text-xs text-red-600 dark:text-red-300">{statusError}</div>
)}
{status?.warning && <div className="text-xs text-amber-700">{status.warning}</div>}
{statusError && <div className="text-xs text-red-600">{statusError}</div>}
{status?.error_detail && (
<div className="space-y-1">
<button
onClick={onToggleErrorDetail}
className="text-xs text-red-600 hover:text-red-500 underline">
className="text-xs text-red-600 dark:text-red-300 hover:text-red-500 underline">
{showErrorDetail
? t('settings.localModel.status.hideErrorDetails')
: t('settings.localModel.status.showErrorDetails')}
</button>
{showErrorDetail && (
<pre className="max-h-40 overflow-auto rounded bg-red-50 border border-red-200 p-2 text-[10px] text-red-600 leading-tight whitespace-pre-wrap break-words">
<pre className="max-h-40 overflow-auto rounded bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30 p-2 text-[10px] text-red-600 dark:text-red-300 leading-tight whitespace-pre-wrap break-words">
{status.error_detail}
</pre>
)}
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.localModel.status.installManuallyFrom')}{' '}
<a
href="https://ollama.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-primary-600 underline">
className="text-primary-500 hover:text-primary-600 dark:text-primary-300 underline">
ollama.com
</a>{' '}
{t('settings.localModel.status.thenRetry')}
@@ -283,7 +299,7 @@ const ModelStatusSection = ({
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.localModel.status.ollamaDiagnostics')}
</h3>
<button
@@ -295,20 +311,20 @@ const ModelStatusSection = ({
: t('settings.localModel.status.runDiagnostics')}
</button>
</div>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
{!diagnostics && !diagnosticsError && (
<p className="text-xs text-stone-500">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.localModel.status.diagnosticsHint')}
</p>
)}
{isDiagnosticsLoading && (
<div className="flex items-center gap-2 text-xs text-primary-600">
<div className="flex items-center gap-2 text-xs text-primary-600 dark:text-primary-300">
<div className="h-3 w-3 rounded-full border-2 border-blue-400 border-t-transparent animate-spin" />
{t('settings.localModel.status.checkingOllama')}
</div>
)}
{diagnosticsError && (
<div className="rounded-md bg-red-50 border border-red-300 p-3 text-xs text-red-600">
<div className="rounded-md bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/40 p-3 text-xs text-red-600 dark:text-red-300">
{diagnosticsError}
</div>
)}
@@ -318,7 +334,12 @@ const ModelStatusSection = ({
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${diagnostics.ok ? 'bg-green-400' : 'bg-red-400'}`}
/>
<span className={diagnostics.ok ? 'text-green-600' : 'text-red-600'}>
<span
className={
diagnostics.ok
? 'text-green-600 dark:text-green-300'
: 'text-red-600 dark:text-red-300'
}>
{diagnostics.ok
? t('settings.localModel.status.allChecksPassed')
: t('settings.localModel.status.issuesFound').replace(
@@ -329,30 +350,30 @@ const ModelStatusSection = ({
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-stone-200 p-2">
<div className="text-stone-400 uppercase tracking-wide text-[10px]">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 p-2">
<div className="text-stone-400 dark:text-neutral-500 uppercase tracking-wide text-[10px]">
{t('settings.localModel.status.server')}
</div>
<div
className={`mt-1 font-medium ${diagnostics.ollama_running ? 'text-green-600' : 'text-red-600'}`}>
className={`mt-1 font-medium ${diagnostics.ollama_running ? 'text-green-600 dark:text-green-300' : 'text-red-600 dark:text-red-300'}`}>
{diagnostics.ollama_running
? t('settings.localModel.status.running')
: t('settings.localModel.status.notRunning')}
</div>
{diagnostics.ollama_base_url && (
<div
className="mt-0.5 text-stone-400 truncate text-[10px]"
className="mt-0.5 text-stone-400 dark:text-neutral-500 truncate text-[10px]"
title={diagnostics.ollama_base_url}>
{diagnostics.ollama_base_url}
</div>
)}
</div>
<div className="rounded-md border border-stone-200 p-2">
<div className="text-stone-400 uppercase tracking-wide text-[10px]">
<div className="rounded-md border border-stone-200 dark:border-neutral-800 p-2">
<div className="text-stone-400 dark:text-neutral-500 uppercase tracking-wide text-[10px]">
{t('settings.localModel.status.binary')}
</div>
<div
className="mt-1 text-stone-600 truncate"
className="mt-1 text-stone-600 dark:text-neutral-300 truncate"
title={
diagnostics.ollama_binary_path ??
(diagnostics.ollama_running ? 'External process' : 'Not found')
@@ -368,7 +389,7 @@ const ModelStatusSection = ({
{diagnostics.installed_models.length > 0 && (
<div>
<div className="text-stone-400 uppercase tracking-wide text-[10px] mb-1">
<div className="text-stone-400 dark:text-neutral-500 uppercase tracking-wide text-[10px] mb-1">
{t('settings.localModel.status.installedModels')} (
{diagnostics.installed_models.length})
</div>
@@ -376,9 +397,11 @@ const ModelStatusSection = ({
{diagnostics.installed_models.map(m => (
<div
key={m.name}
className="flex items-center justify-between rounded border border-stone-200 px-2 py-1.5 text-xs">
<span className="text-stone-800 font-medium">{m.name}</span>
<span className="text-stone-400">
className="flex items-center justify-between rounded border border-stone-200 dark:border-neutral-800 px-2 py-1.5 text-xs">
<span className="text-stone-800 dark:text-neutral-100 font-medium">
{m.name}
</span>
<span className="text-stone-400 dark:text-neutral-500">
{typeof m.size === 'number' ? formatBytes(m.size) : ''}
</span>
</div>
@@ -388,38 +411,46 @@ const ModelStatusSection = ({
)}
<div>
<div className="text-stone-400 uppercase tracking-wide text-[10px] mb-1">
<div className="text-stone-400 dark:text-neutral-500 uppercase tracking-wide text-[10px] mb-1">
{t('settings.localModel.status.expectedModels')}
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<span
className={
diagnostics.expected.chat_found ? 'text-green-600' : 'text-red-600'
diagnostics.expected.chat_found
? 'text-green-600 dark:text-green-300'
: 'text-red-600 dark:text-red-300'
}>
{diagnostics.expected.chat_found ? '✓' : '✗'}
</span>
<span className="text-stone-700">Chat: {diagnostics.expected.chat_model}</span>
<span className="text-stone-700 dark:text-neutral-200">
Chat: {diagnostics.expected.chat_model}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={
diagnostics.expected.embedding_found ? 'text-green-600' : 'text-red-600'
diagnostics.expected.embedding_found
? 'text-green-600 dark:text-green-300'
: 'text-red-600 dark:text-red-300'
}>
{diagnostics.expected.embedding_found ? '✓' : '✗'}
</span>
<span className="text-stone-700">
<span className="text-stone-700 dark:text-neutral-200">
Embedding: {diagnostics.expected.embedding_model}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={
diagnostics.expected.vision_found ? 'text-green-600' : 'text-amber-700'
diagnostics.expected.vision_found
? 'text-green-600 dark:text-green-300'
: 'text-amber-700 dark:text-amber-300'
}>
{diagnostics.expected.vision_found ? '✓' : ''}
</span>
<span className="text-stone-700">
<span className="text-stone-700 dark:text-neutral-200">
Vision: {diagnostics.expected.vision_model}
</span>
</div>
@@ -428,10 +459,10 @@ const ModelStatusSection = ({
{diagnostics.issues.length > 0 && (
<div>
<div className="text-red-600 uppercase tracking-wide text-[10px] mb-1">
<div className="text-red-600 dark:text-red-300 uppercase tracking-wide text-[10px] mb-1">
{t('settings.localModel.status.issues')}
</div>
<ul className="space-y-1 text-xs text-red-600">
<ul className="space-y-1 text-xs text-red-600 dark:text-red-300">
{diagnostics.issues.map((issue, i) => (
<li key={i} className="flex gap-1.5">
<span className="shrink-0">&bull;</span>
@@ -442,7 +473,7 @@ const ModelStatusSection = ({
</div>
)}
<div className="text-xs text-stone-500">
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.localModel.status.manageOllamaExternal')}
</div>
</>
@@ -9,14 +9,14 @@ interface PermissionsBadgeProps {
const PermissionBadge = ({ label, value }: PermissionsBadgeProps) => {
const colorClass =
value === 'granted'
? 'bg-green-50 text-green-700 border-green-200'
? 'bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-300 border-green-200 dark:border-green-500/30'
: value === 'denied'
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-stone-100 text-stone-600 border-stone-200';
? 'bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300 border-red-200 dark:border-red-500/30'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 border-stone-200 dark:border-neutral-800';
return (
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-white p-3">
<span className="text-sm text-stone-700">{label}</span>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<span className="text-sm text-stone-700 dark:text-neutral-200">{label}</span>
<span className={`rounded-md border px-2 py-1 text-xs uppercase tracking-wide ${colorClass}`}>
{value}
</span>
@@ -56,7 +56,7 @@ const PermissionsSection = ({
const { t } = useT();
return (
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.screenIntel.permissions.title')}
</h3>
<PermissionBadge
@@ -73,12 +73,12 @@ const PermissionsSection = ({
/>
{anyPermissionDenied && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700 space-y-1">
<div className="rounded-xl border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300 space-y-1">
<p>{t('settings.screenIntel.permissions.grantHint')}</p>
{permissionCheckProcessPath ? (
<p className="opacity-75 text-xs">
{t('settings.screenIntel.permissions.macosAppliesPrivacy')}{' '}
<span className="font-mono break-all text-stone-600">
<span className="font-mono break-all text-stone-600 dark:text-neutral-300">
{permissionCheckProcessPath}
</span>
</p>
@@ -87,7 +87,7 @@ const PermissionsSection = ({
)}
{lastRestartSummary ? (
<div className="rounded-xl border border-green-300 bg-green-50 p-3 text-sm text-green-700">
<div className="rounded-xl border border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-300">
{lastRestartSummary}
</div>
) : null}
@@ -96,7 +96,7 @@ const PermissionsSection = ({
type="button"
onClick={() => void requestPermission('screen_recording')}
disabled={isRequestingPermissions || isRestartingCore}
className="mt-1 rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
className="mt-1 rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.requestScreenRecording')}
@@ -105,7 +105,7 @@ const PermissionsSection = ({
type="button"
onClick={() => void requestPermission('accessibility')}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.requestAccessibility')}
@@ -114,7 +114,7 @@ const PermissionsSection = ({
type="button"
onClick={() => void requestPermission('input_monitoring')}
disabled={isRequestingPermissions || isRestartingCore}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
className="rounded-lg border border-primary-400 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm text-primary-700 dark:text-primary-300 disabled:opacity-50">
{isRequestingPermissions
? t('settings.screenIntel.permissions.requesting')
: t('settings.screenIntel.permissions.openInputMonitoring')}
@@ -124,7 +124,7 @@ const PermissionsSection = ({
type="button"
onClick={() => void refreshPermissionsWithRestart()}
disabled={isRestartingCore || isLoading}
className="rounded-lg border border-amber-400 bg-amber-50 px-3 py-2 text-sm text-amber-700 disabled:opacity-50">
className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300 disabled:opacity-50">
{isRestartingCore
? t('settings.screenIntel.permissions.restartingCore')
: t('settings.screenIntel.permissions.restartRefresh')}
@@ -134,7 +134,7 @@ const PermissionsSection = ({
type="button"
onClick={() => void refreshStatus()}
disabled={isLoading || isRestartingCore}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-700 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 text-sm text-stone-700 dark:text-neutral-200 disabled:opacity-50">
{isLoading
? t('settings.screenIntel.permissions.refreshing')
: t('settings.screenIntel.permissions.refreshStatus')}
@@ -73,9 +73,9 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
role="dialog"
aria-modal="true"
aria-labelledby="ac-setup-title"
className="w-full max-w-md mx-4 rounded-2xl bg-white shadow-xl overflow-hidden animate-fade-up">
className="w-full max-w-md mx-4 rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden animate-fade-up">
{/* Header */}
<div className="flex items-center justify-between border-b border-stone-100 px-5 py-4">
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -88,8 +88,8 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
</svg>
</div>
<div>
<h2 id="ac-setup-title" className="text-sm font-semibold text-stone-900">{t('skills.setup.autocomplete.title')}</h2>
<p className="text-xs text-stone-500">
<h2 id="ac-setup-title" className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.autocomplete.title')}</h2>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{step === 'enable' && t('skills.setup.autocomplete.stepEnable')}
{step === 'success' && t('skills.setup.autocomplete.stepSuccess')}
</p>
@@ -98,7 +98,7 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors">
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -110,7 +110,7 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
{/* ─── Enable step ─── */}
{step === 'enable' && (
<div className="space-y-4">
<p className="text-xs text-stone-500 leading-relaxed">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.autocomplete.description')}
</p>
@@ -121,17 +121,17 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
)}
<div className="space-y-2">
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
<span className="text-sm text-stone-700">{t('skills.setup.autocomplete.stylePreset')}</span>
<span className="text-xs text-stone-500">{t('skills.setup.autocomplete.stylePresetValue')}</span>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.autocomplete.stylePreset')}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">{t('skills.setup.autocomplete.stylePresetValue')}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
<span className="text-sm text-stone-700">{t('skills.setup.autocomplete.acceptKey')}</span>
<span className="text-xs font-mono text-stone-500">Tab</span>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.autocomplete.acceptKey')}</span>
<span className="text-xs font-mono text-stone-500 dark:text-neutral-400">Tab</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
<span className="text-sm text-stone-700">{t('skills.setup.autocomplete.debounce')}</span>
<span className="text-xs text-stone-500">{status?.debounce_ms ?? 120}ms</span>
<div className="flex items-center justify-between rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2.5">
<span className="text-sm text-stone-700 dark:text-neutral-200">{t('skills.setup.autocomplete.debounce')}</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">{status?.debounce_ms ?? 120}ms</span>
</div>
</div>
@@ -161,8 +161,8 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900">{t('skills.setup.autocomplete.activeTitle')}</h3>
<p className="mt-1 text-xs text-stone-500 leading-relaxed">
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">{t('skills.setup.autocomplete.activeTitle')}</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('skills.setup.autocomplete.activeDesc')}
</p>
</div>
@@ -177,7 +177,7 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
<button
type="button"
onClick={onClose}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 transition-colors">
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-4 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 transition-colors">
{t('common.finish')}
</button>
</div>
+27 -27
View File
@@ -187,17 +187,17 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
role="dialog"
aria-modal="true"
aria-labelledby="create-skill-title"
className="relative w-full max-w-[520px] rounded-2xl bg-white shadow-2xl animate-fade-in">
className="relative w-full max-w-[520px] rounded-2xl bg-white dark:bg-neutral-900 shadow-2xl animate-fade-in">
<form onSubmit={handleSubmit}>
{/* Header */}
<div className="flex items-start justify-between gap-3 border-b border-stone-100 px-5 py-4">
<div className="flex items-start justify-between gap-3 border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
<div className="min-w-0 flex-1">
<h2
id="create-skill-title"
className="text-base font-semibold text-stone-900 font-sans">
className="text-base font-semibold text-stone-900 dark:text-neutral-100 font-sans">
{t('skills.create.title')}
</h2>
<p className="mt-0.5 text-xs text-stone-500">
<p className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">
{t('skills.create.subtitle')}
</p>
</div>
@@ -211,7 +211,7 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
}}
disabled={submitting}
aria-label="Close"
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-stone-400 transition-colors hover:bg-stone-100 hover:text-stone-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-600 dark:hover:text-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@@ -229,7 +229,7 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
<div>
<label
htmlFor="create-skill-name"
className="block text-xs font-medium text-stone-600">
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.name')}<span className="text-coral-500"> *</span>
</label>
<input
@@ -240,12 +240,12 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
onChange={e => setName(e.target.value)}
required
maxLength={128}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.namePlaceholder')}
/>
<p className="mt-1 text-[11px] text-stone-500">
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
{t('skills.create.slugLabel')}{' '}
<code className="rounded bg-stone-100 px-1 py-[1px] font-mono text-stone-700">
<code className="rounded bg-stone-100 dark:bg-neutral-800 px-1 py-[1px] font-mono text-stone-700 dark:text-neutral-200">
{slug || '—'}
</code>
</p>
@@ -255,7 +255,7 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
<div>
<label
htmlFor="create-skill-description"
className="block text-xs font-medium text-stone-600">
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.description')}<span className="text-coral-500"> *</span>
</label>
<textarea
@@ -265,14 +265,14 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
required
rows={3}
maxLength={500}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.descriptionPlaceholder')}
/>
</div>
{/* Scope */}
<fieldset>
<legend className="block text-xs font-medium text-stone-600">{t('skills.create.scope')}</legend>
<legend className="block text-xs font-medium text-stone-600 dark:text-neutral-300">{t('skills.create.scope')}</legend>
<div className="mt-1 flex gap-2">
{(['user', 'project'] as const).map(s => {
const selected = scope === s;
@@ -282,7 +282,7 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
selected
? 'border-primary-500 bg-primary-50 text-primary-900'
: 'border-stone-200 bg-white text-stone-700 hover:border-stone-300'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 hover:border-stone-300 dark:border-neutral-700'
}`}>
<input
type="radio"
@@ -297,7 +297,7 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
);
})}
</div>
<p className="mt-1 text-[11px] text-stone-500">
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
{scope === 'user'
? t('skills.create.scopeUserHint')
: t('skills.create.scopeProjectHint')}
@@ -309,7 +309,7 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
<div>
<label
htmlFor="create-skill-license"
className="block text-xs font-medium text-stone-600">
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.license')}
</label>
<input
@@ -318,14 +318,14 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
value={license}
onChange={e => setLicense(e.target.value)}
maxLength={64}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder="MIT"
/>
</div>
<div>
<label
htmlFor="create-skill-author"
className="block text-xs font-medium text-stone-600">
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.author')}
</label>
<input
@@ -334,7 +334,7 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
value={author}
onChange={e => setAuthor(e.target.value)}
maxLength={128}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder={t('skills.create.authorPlaceholder')}
/>
</div>
@@ -344,9 +344,9 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
<div>
<label
htmlFor="create-skill-tags"
className="block text-xs font-medium text-stone-600">
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.tags')}
<span className="ml-1 font-normal text-stone-400">{t('skills.create.commaSeparated')}</span>
<span className="ml-1 font-normal text-stone-400 dark:text-neutral-500">{t('skills.create.commaSeparated')}</span>
</label>
<input
id="create-skill-tags"
@@ -354,7 +354,7 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
value={tagsCsv}
onChange={e => setTagsCsv(e.target.value)}
maxLength={256}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder="trading, research"
/>
</div>
@@ -363,9 +363,9 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
<div>
<label
htmlFor="create-skill-tools"
className="block text-xs font-medium text-stone-600">
className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('skills.create.allowedTools')}
<span className="ml-1 font-normal text-stone-400">{t('skills.create.commaSeparated')}</span>
<span className="ml-1 font-normal text-stone-400 dark:text-neutral-500">{t('skills.create.commaSeparated')}</span>
</label>
<input
id="create-skill-tools"
@@ -373,10 +373,10 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
value={allowedToolsCsv}
onChange={e => setAllowedToolsCsv(e.target.value)}
maxLength={512}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm font-mono text-stone-900 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
placeholder="node_exec, fetch"
/>
<p className="mt-1 text-[11px] text-stone-500">
<p className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
Rendered into the SKILL.md frontmatter as{' '}
<code className="font-mono">allowed-tools:</code>.
</p>
@@ -394,12 +394,12 @@ export default function CreateSkillModal({ onClose, onCreated }: Props) {
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-stone-100 px-5 py-3">
<div className="flex items-center justify-end gap-2 border-t border-stone-100 dark:border-neutral-800 px-5 py-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 transition-colors hover:bg-stone-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
{t('common.cancel')}
</button>
<button

Some files were not shown because too many files have changed in this diff Show More