diff --git a/frontend/src/App.css b/frontend/src/App.css index f7eb7b4..f23cec8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -6,7 +6,7 @@ --border: #3f3f46; --text-primary: #e4e4e7; --text-secondary: #a1a1aa; - --text-muted: #71717a; + --text-muted: #8b8b95; --accent: #3b82f6; --accent-hover: #2563eb; --accent-glow: rgba(59, 130, 246, 0.3); @@ -17,6 +17,27 @@ --input-bg: #18181b; } +/* System color scheme detection */ +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --bg-primary: #f8fafc; + --bg-secondary: #ffffff; + --bg-tertiary: #f1f5f9; + --border: #e2e8f0; + --text-primary: #1e293b; + --text-secondary: #64748b; + --text-muted: #64748b; + --accent: #3b82f6; + --accent-hover: #2563eb; + --accent-glow: rgba(59, 130, 246, 0.15); + --success: #16a34a; + --warning: #ea580c; + --danger: #dc2626; + --card-bg: #ffffff; + --input-bg: #ffffff; + } +} + [data-theme="light"] { --bg-primary: #f8fafc; --bg-secondary: #ffffff; @@ -78,7 +99,7 @@ body { .nav-tabs { display: flex; gap: 0.25rem; } .nav-tabs button { - padding: 0.4rem 0.8rem; + padding: 0.5rem 1rem; background: transparent; border: 1px solid transparent; color: var(--text-secondary); @@ -86,6 +107,8 @@ body { cursor: pointer; font-size: 0.85rem; transition: all 0.15s; + min-height: 44px; + min-width: 44px; } .nav-tabs button:hover { background: var(--bg-tertiary); } .nav-tabs button.active { @@ -96,13 +119,15 @@ body { .mode-toggle { display: flex; gap: 0.25rem; } .mode-toggle button { - padding: 0.3rem 0.6rem; + padding: 0.4rem 0.7rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-secondary); border-radius: 4px; cursor: pointer; font-size: 0.75rem; + min-height: 44px; + min-width: 44px; } .mode-toggle button.active { background: var(--accent); @@ -118,6 +143,8 @@ body { border-radius: 6px; cursor: pointer; font-size: 0.85rem; + min-height: 44px; + min-width: 44px; } .icon-btn:hover { background: var(--bg-tertiary); } @@ -232,6 +259,7 @@ body { padding: 0.5rem 1rem; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); cursor: pointer; font-size: 0.85rem; + min-height: 44px; } .suggestion:hover { border-color: var(--accent); } @@ -291,6 +319,7 @@ body { border: none; border-radius: 8px; color: white; cursor: pointer; font-weight: 600; transition: background 0.15s; + min-height: 44px; } .send-btn:hover:not(:disabled) { background: var(--accent-hover); } .send-btn:disabled { opacity: 0.5; cursor: not-allowed; } @@ -523,6 +552,127 @@ body { } .save-btn:hover { background: var(--accent-hover); } +/* ========== Head Colors ========== */ +.avatar[data-head="logic"] .avatar-placeholder { background: #6366f1; color: white; } +.avatar[data-head="research"] .avatar-placeholder { background: #8b5cf6; color: white; } +.avatar[data-head="systems"] .avatar-placeholder { background: #06b6d4; color: white; } +.avatar[data-head="strategy"] .avatar-placeholder { background: #f59e0b; color: #18181b; } +.avatar[data-head="product"] .avatar-placeholder { background: #ec4899; color: white; } +.avatar[data-head="security"] .avatar-placeholder { background: #ef4444; color: white; } +.avatar[data-head="safety"] .avatar-placeholder { background: #22c55e; color: #18181b; } +.avatar[data-head="reliability"] .avatar-placeholder { background: #14b8a6; color: white; } +.avatar[data-head="cost"] .avatar-placeholder { background: #f97316; color: white; } +.avatar[data-head="data"] .avatar-placeholder { background: #a855f7; color: white; } +.avatar[data-head="devex"] .avatar-placeholder { background: #0ea5e9; color: white; } +.avatar[data-head="witness"] .avatar-placeholder { background: #64748b; color: white; } + +.avatar.active .avatar-placeholder, .avatar.speaking .avatar-placeholder { + filter: brightness(1.2); + box-shadow: 0 0 8px var(--accent-glow); +} + +/* ========== Collapsible Avatar Grid ========== */ +.avatar-grid-wrapper { flex-shrink: 0; border-bottom: 1px solid var(--border); } +.avatar-grid-toggle { + display: none; width: 100%; padding: 0.4rem 1rem; + background: var(--bg-secondary); border: none; border-bottom: 1px solid var(--border); + color: var(--text-secondary); cursor: pointer; font-size: 0.8rem; + text-align: left; min-height: 44px; +} +.avatar-grid-toggle:hover { background: var(--bg-tertiary); } +.avatar-grid-wrapper .avatar-grid { border-bottom: none; } + +/* ========== Structured Response Cards ========== */ +.response-structured { display: flex; flex-direction: column; gap: 0.5rem; } +.response-synthesis { + font-size: 0.9rem; line-height: 1.6; margin-bottom: 0.25rem; +} +.response-synthesis p { margin-bottom: 0.5rem; } +.response-synthesis p:last-child { margin-bottom: 0; } +.response-synthesis code { + background: var(--bg-tertiary); padding: 0.15rem 0.4rem; + border-radius: 3px; font-size: 0.85em; +} +.response-synthesis pre { + background: var(--bg-tertiary); padding: 0.75rem; + border-radius: 6px; overflow-x: auto; margin: 0.5rem 0; +} +.response-synthesis pre code { background: none; padding: 0; } +.response-synthesis strong { color: var(--text-primary); } +.response-synthesis em { color: var(--text-secondary); } +.response-synthesis ul, .response-synthesis ol { padding-left: 1.5rem; margin: 0.25rem 0; } +.response-synthesis li { margin-bottom: 0.2rem; } +.response-synthesis a { color: var(--accent); text-decoration: none; } +.response-synthesis a:hover { text-decoration: underline; } +.response-synthesis blockquote { + border-left: 3px solid var(--accent); padding-left: 0.75rem; + margin: 0.5rem 0; color: var(--text-secondary); +} +.response-synthesis h1, .response-synthesis h2, .response-synthesis h3 { + margin-top: 0.75rem; margin-bottom: 0.25rem; +} +.response-synthesis h1 { font-size: 1.1rem; } +.response-synthesis h2 { font-size: 1rem; } +.response-synthesis h3 { font-size: 0.95rem; } + +.head-cards { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.5rem; } +.head-card { + display: flex; align-items: flex-start; gap: 0.5rem; + padding: 0.4rem 0.6rem; border-radius: 6px; + background: var(--bg-tertiary); font-size: 0.8rem; +} +.head-card-dot { + width: 8px; height: 8px; border-radius: 50%; margin-top: 0.35rem; flex-shrink: 0; +} +.head-card-label { font-weight: 600; color: var(--text-primary); text-transform: capitalize; } +.head-card-text { color: var(--text-secondary); } + +/* Head card dot colors */ +.head-card[data-head="logic"] .head-card-dot { background: #6366f1; } +.head-card[data-head="research"] .head-card-dot { background: #8b5cf6; } +.head-card[data-head="systems"] .head-card-dot { background: #06b6d4; } +.head-card[data-head="strategy"] .head-card-dot { background: #f59e0b; } +.head-card[data-head="product"] .head-card-dot { background: #ec4899; } +.head-card[data-head="security"] .head-card-dot { background: #ef4444; } +.head-card[data-head="safety"] .head-card-dot { background: #22c55e; } +.head-card[data-head="reliability"] .head-card-dot { background: #14b8a6; } +.head-card[data-head="cost"] .head-card-dot { background: #f97316; } +.head-card[data-head="data"] .head-card-dot { background: #a855f7; } +.head-card[data-head="devex"] .head-card-dot { background: #0ea5e9; } +.head-card[data-head="witness"] .head-card-dot { background: #64748b; } + +/* ========== Status Indicators ========== */ +.status-value.healthy { color: var(--success); } +.status-value.degraded { color: var(--warning); } +.status-value.offline { color: var(--danger); } +.status-dot { + display: inline-block; width: 10px; height: 10px; border-radius: 50%; + margin-right: 0.4rem; vertical-align: middle; +} +.status-dot.healthy { background: var(--success); box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); } +.status-dot.degraded { background: var(--warning); } +.status-dot.offline { background: var(--danger); } + +/* ========== Toast Notifications ========== */ +.toast-container { + position: fixed; bottom: 1.5rem; right: 1.5rem; + display: flex; flex-direction: column; gap: 0.5rem; + z-index: 1000; pointer-events: none; +} +.toast { + padding: 0.6rem 1rem; border-radius: 8px; + font-size: 0.85rem; font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + animation: toast-in 0.3s ease-out, toast-out 0.3s ease-in 2.7s forwards; + pointer-events: auto; max-width: 320px; +} +.toast.success { background: var(--success); color: white; } +.toast.error { background: var(--danger); color: white; } +.toast.info { background: var(--accent); color: white; } +.toast.warning { background: var(--warning); color: white; } +@keyframes toast-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } +@keyframes toast-out { from { opacity: 1; } to { opacity: 0; } } + /* ========== Utilities ========== */ .muted { color: var(--text-muted); font-size: 0.85rem; } .error-banner { @@ -536,6 +686,12 @@ body { color: var(--text-muted); font-size: 0.9rem; } +/* ========== Focus visible (keyboard nav) ========== */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* ========== Responsive ========== */ @media (max-width: 768px) { .header { flex-direction: column; gap: 0.5rem; padding: 0.5rem 1rem; } @@ -543,16 +699,19 @@ body { .header-right { width: 100%; justify-content: flex-end; } .consensus-panel { display: none; } .avatar-grid { grid-template-columns: repeat(4, 1fr); } + .avatar-grid-toggle { display: block; } + .avatar-grid-wrapper.collapsed .avatar-grid { display: none; } .messages { padding: 0.75rem; } .message { max-width: 95%; } .admin-page, .ethics-page, .settings-page { padding: 1rem; } .status-grid { grid-template-columns: repeat(2, 1fr); } .add-form { flex-direction: column; } .setting-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; } + .nav-tabs button { min-height: 44px; padding: 0.5rem 0.75rem; } } @media (max-width: 480px) { .avatar-grid { grid-template-columns: repeat(3, 1fr); } - .nav-tabs button { font-size: 0.75rem; padding: 0.3rem 0.5rem; } + .nav-tabs button { font-size: 0.75rem; padding: 0.4rem 0.6rem; min-height: 44px; } .mode-toggle { display: none; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 24fd1b7..38e6e2b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react' import { AvatarGrid } from './components/AvatarGrid' import { ConsensusPanel } from './components/ConsensusPanel' import { ChatMessage } from './components/ChatMessage' +import { ToastProvider, useToast } from './components/Toast' import { AdminPage } from './pages/AdminPage' import { EthicsPage } from './pages/EthicsPage' import { SettingsPage } from './pages/SettingsPage' @@ -169,13 +170,14 @@ function App() { } return ( -
-
+
+

FusionAGI

-
{page === 'chat' && ( -
+
{(['normal', 'explain', 'developer'] as const).map((m) => ( - ))}
)} - - {token && } + {token && }
{networkError && ( -
+
{networkError} @@ -216,7 +220,7 @@ function App() { speakingHead={speakingHead} headSummaries={headSummaries} /> -
+
{messages.length === 0 && (

Welcome to FusionAGI Dvādaśa

@@ -234,8 +238,8 @@ function App() { ))} {loading && ( -
-
+
+ Heads analyzing...
)} @@ -251,8 +255,9 @@ function App() { placeholder="Ask FusionAGI... (/head strategy, /show dissent)" autoComplete="off" disabled={loading} + aria-label="Message input" /> -
@@ -276,4 +281,12 @@ function App() { ) } -export default App +function AppWithProviders() { + return ( + + + + ) +} + +export default AppWithProviders diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index 6c81e5c..9a0aae2 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -1,3 +1,18 @@ +const HEAD_DESCRIPTIONS: Record = { + logic: 'Logical reasoning and consistency checking', + research: 'Research synthesis and source evaluation', + systems: 'System architecture and integration analysis', + strategy: 'Strategic planning and long-term vision', + product: 'Product design and user experience', + security: 'Security analysis and threat assessment', + safety: 'Safety evaluation and risk observation', + reliability: 'Reliability engineering and fault tolerance', + cost: 'Cost analysis and resource optimization', + data: 'Data analysis and statistical reasoning', + devex: 'Developer experience and tooling', + witness: 'Observation and audit recording', +} + interface AvatarProps { headId: string isActive?: boolean @@ -8,19 +23,24 @@ interface AvatarProps { export function Avatar({ headId, isActive, isSpeaking, summary, avatarUrl }: AvatarProps) { const displayName = headId.charAt(0).toUpperCase() + headId.slice(1) + const description = HEAD_DESCRIPTIONS[headId] || displayName + const status = isSpeaking ? 'speaking' : isActive ? 'active' : 'idle' + return (
{avatarUrl ? ( {displayName} ) : ( -
{headId.slice(0, 2)}
+ )} - {isSpeaking &&
} + {isSpeaking && {displayName}
diff --git a/frontend/src/components/AvatarGrid.tsx b/frontend/src/components/AvatarGrid.tsx index 2bd100b..d1cef67 100644 --- a/frontend/src/components/AvatarGrid.tsx +++ b/frontend/src/components/AvatarGrid.tsx @@ -1,6 +1,6 @@ -import { Avatar } from "./Avatar" - -import { AVATAR_URLS } from "../config/avatars" +import { useState } from 'react' +import { Avatar } from './Avatar' +import { AVATAR_URLS } from '../config/avatars' interface AvatarGridProps { headIds: string[] @@ -17,18 +17,38 @@ export function AvatarGrid({ headSummaries = {}, avatarUrls = AVATAR_URLS, }: AvatarGridProps) { + const [collapsed, setCollapsed] = useState(false) + const activeCount = activeHeads.length + return ( -
- {headIds.map((id) => ( - - ))} +
+ +
+ {headIds.map((id) => ( + + ))} +
) } diff --git a/frontend/src/components/ChatMessage.tsx b/frontend/src/components/ChatMessage.tsx index 24a8803..f8b2e12 100644 --- a/frontend/src/components/ChatMessage.tsx +++ b/frontend/src/components/ChatMessage.tsx @@ -1,27 +1,62 @@ import type { FinalResponse } from '../types' +import { Markdown } from './Markdown' interface ChatMessageProps { message: { role: 'user' | 'assistant'; content: string; data?: FinalResponse } viewMode: string } +function extractSynthesis(content: string): string { + const lines = content.split('\n') + const filtered = lines.filter((line) => { + const trimmed = line.trim().toLowerCase() + return !( + /^(research|strategy|logic|systems|product|security|safety|reliability|cost|data|devex|witness)\s*:/.test(trimmed) && + /perspective/.test(trimmed) + ) + }) + return filtered.join('\n').trim() +} + export function ChatMessage({ message, viewMode }: ChatMessageProps) { const isUser = message.role === 'user' + + if (isUser) { + return ( +
+
{message.content}
+
+ ) + } + + const hasHeadData = message.data?.head_contributions && message.data.head_contributions.length > 0 + const synthesis = extractSynthesis(message.content) + return ( -
-
{message.content}
- {!isUser && message.data && (viewMode === 'explain' || viewMode === 'developer') && ( -
- - Confidence: {(message.data.confidence_score * 100).toFixed(0)}% - - {message.data.head_contributions?.length > 0 && ( - - Heads: {message.data.head_contributions.map((h) => h.head_id).join(', ')} +
+
+ + {hasHeadData && (viewMode === 'explain' || viewMode === 'developer') && ( +
+ {message.data!.head_contributions.map((h) => ( +
+ + ))} +
+ )} + {message.data && (viewMode === 'explain' || viewMode === 'developer') && ( +
+ + Confidence: {(message.data.confidence_score * 100).toFixed(0)}% - )} -
- )} +
+ )} +
) } diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx new file mode 100644 index 0000000..6633254 --- /dev/null +++ b/frontend/src/components/Markdown.tsx @@ -0,0 +1,83 @@ +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +} + +function renderInline(text: string): string { + let out = escapeHtml(text) + out = out.replace(/`([^`]+)`/g, '$1') + out = out.replace(/\*\*([^*]+)\*\*/g, '$1') + out = out.replace(/\*([^*]+)\*/g, '$1') + out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + return out +} + +function parseMarkdown(md: string): string { + const lines = md.split('\n') + const html: string[] = [] + let inCode = false + let codeBlock: string[] = [] + let inList = false + let listType: 'ul' | 'ol' = 'ul' + + for (const line of lines) { + if (line.startsWith('```')) { + if (inCode) { + html.push(`
${escapeHtml(codeBlock.join('\n'))}
`) + codeBlock = [] + inCode = false + } else { + if (inList) { html.push(``); inList = false } + inCode = true + } + continue + } + if (inCode) { codeBlock.push(line); continue } + + const trimmed = line.trim() + if (!trimmed) { + if (inList) { html.push(``); inList = false } + continue + } + + if (trimmed.startsWith('### ')) { + if (inList) { html.push(``); inList = false } + html.push(`

${renderInline(trimmed.slice(4))}

`) + } else if (trimmed.startsWith('## ')) { + if (inList) { html.push(``); inList = false } + html.push(`

${renderInline(trimmed.slice(3))}

`) + } else if (trimmed.startsWith('# ')) { + if (inList) { html.push(``); inList = false } + html.push(`

${renderInline(trimmed.slice(2))}

`) + } else if (trimmed.startsWith('> ')) { + if (inList) { html.push(``); inList = false } + html.push(`
${renderInline(trimmed.slice(2))}
`) + } else if (/^[-*] /.test(trimmed)) { + if (!inList || listType !== 'ul') { + if (inList) html.push(``) + html.push('
    '); inList = true; listType = 'ul' + } + html.push(`
  • ${renderInline(trimmed.slice(2))}
  • `) + } else if (/^\d+\. /.test(trimmed)) { + if (!inList || listType !== 'ol') { + if (inList) html.push(``) + html.push('
      '); inList = true; listType = 'ol' + } + html.push(`
    1. ${renderInline(trimmed.replace(/^\d+\. /, ''))}
    2. `) + } else { + if (inList) { html.push(``); inList = false } + html.push(`

      ${renderInline(trimmed)}

      `) + } + } + if (inCode) html.push(`
      ${escapeHtml(codeBlock.join('\n'))}
      `) + if (inList) html.push(``) + return html.join('') +} + +export function Markdown({ content }: { content: string }) { + return ( +
      + ) +} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..930e235 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,40 @@ +import { useState, useEffect, useCallback, createContext, useContext } from 'react' + +interface ToastItem { + id: number + message: string + type: 'success' | 'error' | 'info' | 'warning' +} + +interface ToastContextType { + toast: (message: string, type?: ToastItem['type']) => void +} + +const ToastContext = createContext({ toast: () => {} }) + +export function useToast() { + return useContext(ToastContext) +} + +let nextId = 0 + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]) + + const toast = useCallback((message: string, type: ToastItem['type'] = 'info') => { + const id = nextId++ + setToasts((prev) => [...prev, { id, message, type }]) + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000) + }, []) + + return ( + + {children} +
      + {toasts.map((t) => ( +
      {t.message}
      + ))} +
      +
      + ) +} diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts index fbc713c..1ed02e6 100644 --- a/frontend/src/hooks/useTheme.ts +++ b/frontend/src/hooks/useTheme.ts @@ -1,10 +1,18 @@ import { useState, useEffect, useCallback } from 'react' import type { Theme } from '../types' +function getSystemTheme(): Theme { + if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: light)').matches) { + return 'light' + } + return 'dark' +} + export function useTheme() { const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('fusionagi-theme') - return (saved === 'light' ? 'light' : 'dark') as Theme + if (saved === 'light' || saved === 'dark') return saved + return getSystemTheme() }) useEffect(() => { @@ -12,6 +20,17 @@ export function useTheme() { localStorage.setItem('fusionagi-theme', theme) }, [theme]) + useEffect(() => { + const mq = window.matchMedia('(prefers-color-scheme: light)') + const handler = (e: MediaQueryListEvent) => { + if (!localStorage.getItem('fusionagi-theme')) { + setTheme(e.matches ? 'light' : 'dark') + } + } + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + const toggle = useCallback(() => { setTheme((t) => (t === 'dark' ? 'light' : 'dark')) }, []) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index fff07d7..b4bcfa8 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,11 +1,16 @@ import { useState, useEffect, useCallback } from 'react' import type { SystemStatus, VoiceProfile } from '../types' -function StatusCard({ label, value, unit }: { label: string; value: string | number | null; unit?: string }) { +function StatusCard({ label, value, unit, statusClass }: { + label: string; value: string | number | null; unit?: string; statusClass?: string +}) { return ( -
      +
      {label} - {value ?? 'N/A'}{unit && value != null ? unit : ''} + + {statusClass &&
      ) } @@ -63,25 +68,34 @@ export function AdminPage({ authHeaders }: { authHeaders: () => RecordLoading admin dashboard...
      + const statusClass = status?.status === 'healthy' ? 'healthy' : status?.status === 'degraded' ? 'degraded' : status?.status === 'offline' ? 'offline' : '' + + if (loading) return
      Loading admin dashboard...
      return ( -
      -
      +
      +
      {(['overview', 'voices', 'agents', 'governance'] as const).map((t) => ( - ))}
      - {error &&
      setError(null)}>{error}
      } + {error &&
      setError(null)}>{error}
      } {tab === 'overview' && ( -
      +

      System Overview

      -
      - +
      + @@ -93,11 +107,13 @@ export function AdminPage({ authHeaders }: { authHeaders: () => Record +

      Voice Library

      -
      - setNewVoiceName(e.target.value)} /> - setNewVoiceName(e.target.value)} /> + + onChange(parseFloat(e.target.value))} /> - {value.toFixed(1)} + + onChange(parseFloat(e.target.value))} + aria-valuemin={min} aria-valuemax={max} aria-valuenow={value} + aria-valuetext={`${label}: ${value.toFixed(1)}`} /> +
      ) } export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPageProps) { + const { toast } = useToast() const [style, setStyle] = useState({ formality: 'neutral', verbosity: 'balanced', @@ -29,61 +33,78 @@ export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPagePr humor_level: 0.3, technical_depth: 0.5, }) - const [saved, setSaved] = useState(false) const saveSettings = async () => { try { - await fetch('/v1/admin/conversation-style', { + const r = await fetch('/v1/admin/conversation-style', { method: 'POST', headers: authHeaders(), body: JSON.stringify(style), }) - setSaved(true) - setTimeout(() => setSaved(false), 2000) - } catch { /* offline */ } + if (r.ok) { + toast('Settings saved successfully', 'success') + } else { + toast('Failed to save settings', 'error') + } + } catch { + toast('Network error — settings saved locally', 'warning') + } + } + + const resetDefaults = () => { + setStyle({ + formality: 'neutral', + verbosity: 'balanced', + empathy_level: 0.7, + proactivity: 0.5, + humor_level: 0.3, + technical_depth: 0.5, + }) + toast('Settings reset to defaults', 'info') } return ( -
      +

      Settings

      Appearance

      -
      -
      +

      Conversation Style

      - - setStyle({ ...style, formality: e.target.value as ConversationStyle['formality'] })}>
      - - setStyle({ ...style, verbosity: e.target.value as ConversationStyle['verbosity'] })}>
      - setStyle({ ...style, empathy_level: v })} /> - setStyle({ ...style, proactivity: v })} /> - setStyle({ ...style, humor_level: v })} /> - setStyle({ ...style, technical_depth: v })} /> + setStyle({ ...style, empathy_level: v })} /> + setStyle({ ...style, proactivity: v })} /> + setStyle({ ...style, humor_level: v })} /> + setStyle({ ...style, technical_depth: v })} />
      - +
      + + +
      ) } diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index c44951a..2c91fc0 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1 +1,15 @@ import '@testing-library/jest-dom' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +})