chore(ui): improve visibility of forms, color palette

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto 2026-04-14 21:53:03 +00:00
parent 833b7e8557
commit 410d100cc3
20 changed files with 1227 additions and 538 deletions

File diff suppressed because it is too large Load diff

View file

@ -65,7 +65,7 @@ export default function App() {
<div className="app-footer-inner">
{version && (
<span className="app-footer-version">
LocalAI <span style={{ color: 'var(--color-primary)', fontWeight: 500 }}>{version}</span>
LocalAI <span style={{ fontWeight: 500 }}>{version}</span>
</span>
)}
<div className="app-footer-links">

View file

@ -1,15 +1,11 @@
export default function SettingRow({ label, description, children }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) 0',
borderBottom: '1px solid var(--color-border-subtle)',
}}>
<div style={{ flex: 1, marginRight: 'var(--spacing-md)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>}
<div className="form-row">
<div className="form-row__label">
<div className="form-row__label-text">{label}</div>
{description && <div className="form-row__hint">{description}</div>}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
<div className="form-row__control">{children}</div>
</div>
)
}

View file

@ -1,27 +1,14 @@
export default function Toggle({ checked, onChange, disabled }) {
return (
<label style={{
position: 'relative', display: 'inline-block', width: 40, height: 22, cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}>
<label className={`toggle${checked ? ' toggle--on' : ''}${disabled ? ' toggle--disabled' : ''}`}>
<input
type="checkbox"
checked={checked || false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
style={{ display: 'none' }}
/>
<span style={{
position: 'absolute', inset: 0, borderRadius: 22,
background: checked ? 'var(--color-primary)' : 'var(--color-toggle-off)',
transition: 'background 200ms',
}}>
<span style={{
position: 'absolute', top: 2, left: checked ? 20 : 2,
width: 18, height: 18, borderRadius: '50%',
background: 'var(--color-text-inverse)', transition: 'left 200ms',
boxShadow: 'var(--shadow-sm)',
}} />
<span className="toggle__track">
<span className="toggle__thumb" />
</span>
</label>
)

View file

@ -13,6 +13,9 @@ html {
body {
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: var(--text-base);
font-weight: var(--font-weight-regular);
line-height: var(--leading-normal);
min-height: 100%;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
@ -35,8 +38,14 @@ body {
h1, h2, h3, h4, h5, h6 {
font-family: 'Space Grotesk', sans-serif;
color: var(--color-text-primary);
line-height: 1.3;
line-height: var(--leading-tight);
letter-spacing: -0.01em;
}
h1 { font-size: var(--text-2xl); font-weight: var(--font-weight-semibold); }
h2 { font-size: var(--text-xl); font-weight: var(--font-weight-semibold); }
h3 { font-size: var(--text-lg); font-weight: var(--font-weight-semibold); }
h4 { font-size: var(--text-base); font-weight: var(--font-weight-semibold); }
h5, h6 { font-size: var(--text-sm); font-weight: var(--font-weight-semibold); }
code, pre {
font-family: 'JetBrains Mono', monospace;

View file

@ -356,7 +356,15 @@ export default function AgentChat() {
}, [input, processing, name, activeId, addToast, userId, addMessage, nextId])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.nativeEvent?.isComposing &&
e.keyCode !== 229
) {
e.preventDefault()
handleSend()
}

View file

@ -11,8 +11,8 @@ function wsUrl(path) {
}
const STREAM_BADGE = {
stdout: { bg: 'rgba(59,130,246,0.15)', color: '#60a5fa', label: 'stdout' },
stderr: { bg: 'rgba(239,68,68,0.15)', color: '#f87171', label: 'stderr' },
stdout: { bg: 'var(--color-info-light)', color: 'var(--color-log-info)', label: 'stdout' },
stderr: { bg: 'var(--color-error-light)', color: 'var(--color-log-stderr)', label: 'stderr' },
}
// Detail view: log lines for a specific model

View file

@ -262,13 +262,13 @@ export default function Backends() {
marginBottom: 'var(--spacing-md)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) var(--spacing-md)',
background: 'var(--color-warning-bg, #fef3cd)',
border: '1px solid var(--color-warning, #ffc107)',
background: 'var(--color-warning-light)',
border: '1px solid var(--color-warning-border)',
borderRadius: 'var(--radius-md)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-arrow-up" style={{ color: 'var(--color-warning, #856404)' }} />
<span style={{ color: 'var(--color-warning, #856404)', fontWeight: 500, fontSize: '0.875rem' }}>
<i className="fas fa-arrow-up" style={{ color: 'var(--color-warning)' }} />
<span style={{ color: 'var(--color-warning)', fontWeight: 500, fontSize: 'var(--text-sm)' }}>
{Object.keys(upgrades).length} backend{Object.keys(upgrades).length > 1 ? 's have' : ' has'} updates available
</span>
</div>
@ -447,20 +447,18 @@ export default function Backends() {
{/* Status */}
<td>
{isProcessing ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<div style={{
width: 80, height: 6, background: 'var(--color-bg-tertiary)',
borderRadius: 3, overflow: 'hidden',
}}>
<div style={{
width: `${op.progress || 0}%`, height: '100%',
background: 'var(--color-primary)',
borderRadius: 3, transition: 'width 300ms',
}} />
<div className="inline-install">
<div className="inline-install__row">
<div className="operation-spinner" />
<span className="inline-install__label">
{op.isDeletion ? 'Deleting...' : op.isQueued ? 'Queued' : `Installing${op.progress > 0 ? ` · ${Math.round(op.progress)}%` : '...'}`}
</span>
</div>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{op.isDeletion ? 'Deleting...' : op.isQueued ? 'Queued' : 'Installing...'}
</span>
{op.progress > 0 && (
<div className="operation-bar-container" style={{ flex: 'none', width: '120px', marginTop: 4 }}>
<div className="operation-bar" style={{ width: `${op.progress}%` }} />
</div>
)}
</div>
) : b.installed ? (
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
@ -468,14 +466,14 @@ export default function Backends() {
<i className="fas fa-check" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Installed
</span>
{upgrades[b.name] && (
<span className="badge" style={{ fontSize: '0.625rem', background: '#fef3cd', color: '#856404' }}>
<span className="badge" style={{ fontSize: '0.625rem', background: 'var(--color-warning-light)', color: 'var(--color-warning)' }}>
<i className="fas fa-arrow-up" style={{ fontSize: '0.5rem', marginRight: 2 }} />
{upgrades[b.name].available_version ? `v${upgrades[b.name].available_version}` : 'Update'}
</span>
)}
</div>
) : (
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<span className="badge" style={{ background: 'var(--color-surface-sunken)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border-default)' }}>
<i className="fas fa-circle" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Not Installed
</span>
)}

View file

@ -697,7 +697,17 @@ export default function Chat() {
}, [activeChat, isStreaming, sendMessage, updateChatSettings])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
// Only Enter (no modifiers, no IME composition) sends.
// Shift+Enter, Ctrl+Enter, Meta+Enter, Alt+Enter all fall through to default textarea behavior (newline).
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.nativeEvent?.isComposing &&
e.keyCode !== 229
) {
e.preventDefault()
handleSend()
}

View file

@ -420,7 +420,7 @@ export default function Home() {
<span className="home-loaded-dot" />
<span className="home-loaded-text">{loadedCount} model{loadedCount !== 1 ? 's' : ''} loaded</span>
<div className="home-loaded-list">
{loadedModels.map(m => (
{[...loadedModels].sort((a, b) => a.id.localeCompare(b.id)).map(m => (
<span key={m.id} className="home-loaded-item">
{m.id}
<button onClick={() => handleStopModel(m.id)} title="Stop model">

View file

@ -83,7 +83,7 @@ export default function ImageGen() {
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-image" style={{ marginRight: 8, color: 'var(--color-accent)' }} />Image Generation</h1>
<h1 className="page-title"><i className="fas fa-image" /> Image Generation</h1>
</div>
<form onSubmit={handleGenerate}>
@ -100,10 +100,10 @@ export default function ImageGen() {
<textarea className="textarea" value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} placeholder="What to avoid..." rows={2} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-sm)' }}>
<div className="form-grid-2col">
<div className="form-group">
<label className="form-label">Size</label>
<select className="model-selector" value={size} onChange={(e) => setSize(e.target.value)} style={{ width: '100%' }}>
<select className="input btn-full" value={size} onChange={(e) => setSize(e.target.value)}>
{SIZES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
@ -117,7 +117,7 @@ export default function ImageGen() {
<i className="fas fa-chevron-right" /> Advanced Settings
</div>
{showAdvanced && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<div className="form-grid-2col">
<div className="form-group"><label className="form-label">Steps</label><input className="input" type="number" value={steps} onChange={(e) => setSteps(e.target.value)} placeholder="20" /></div>
<div className="form-group"><label className="form-label">Seed</label><input className="input" type="number" value={seed} onChange={(e) => setSeed(e.target.value)} placeholder="Random" /></div>
</div>
@ -127,17 +127,17 @@ export default function ImageGen() {
<i className="fas fa-chevron-right" /> Image Inputs
</div>
{showImageInputs && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<>
<div className="form-group"><label className="form-label">Source Image (img2img)</label><input ref={sourceRef} type="file" accept="image/*" onChange={handleSourceImage} className="input" /></div>
<div className="form-group">
<label className="form-label">Reference Images</label>
<input ref={refRef} type="file" accept="image/*" multiple onChange={handleRefImages} className="input" />
{refImages.length > 0 && <span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{refImages.length} image(s) added</span>}
{refImages.length > 0 && <span className="form-field__hint">{refImages.length} image(s) added</span>}
</div>
</div>
</>
)}
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: '100%' }}>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-wand-magic-sparkles" /> Generate</>}
</button>
</form>

View file

@ -38,20 +38,20 @@ const URI_FORMATS = [
],
},
{
icon: 'fas fa-box', color: '#22d3ee', title: 'OCI Registry',
icon: 'fas fa-box', color: 'var(--color-data-8)', title: 'OCI Registry',
examples: [
{ prefix: 'oci://', suffix: 'registry.example.com/model:tag', desc: 'OCI container registry' },
{ prefix: 'ocifile://', suffix: '/path/to/image.tar', desc: 'Local OCI tarball file' },
],
},
{
icon: 'fas fa-cube', color: '#818cf8', title: 'Ollama',
icon: 'fas fa-cube', color: 'var(--color-data-1)', title: 'Ollama',
examples: [
{ prefix: 'ollama://', suffix: 'llama2:7b', desc: 'Ollama model format' },
],
},
{
icon: 'fas fa-code', color: '#f472b6', title: 'YAML Configuration Files',
icon: 'fas fa-code', color: 'var(--color-data-7)', title: 'YAML Configuration Files',
examples: [
{ prefix: '', suffix: 'https://example.com/model.yaml', desc: 'Remote YAML config file' },
{ prefix: 'file://', suffix: '/path/to/config.yaml', desc: 'Local YAML config file' },
@ -437,7 +437,7 @@ export default function ImportModel() {
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ padding: 'var(--spacing-md)', borderBottom: '1px solid var(--color-border-default)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
<i className="fas fa-code" style={{ color: '#d946ef' }} />
<i className="fas fa-code" style={{ color: 'var(--color-data-3)' }} />
YAML Configuration Editor
</h2>
<button className="btn btn-secondary" style={{ fontSize: '0.75rem' }} onClick={() => { navigator.clipboard.writeText(yamlContent); addToast('Copied to clipboard', 'success') }}>

View file

@ -257,8 +257,8 @@ export default function Models() {
<div className="page">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">Model Gallery</h1>
<p className="page-subtitle">Discover and install AI models for your workflows</p>
<h1 className="page-title">Install Models</h1>
<p className="page-subtitle">Browse and install AI models from the gallery</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
@ -461,15 +461,14 @@ export default function Models() {
{/* Status */}
<td>
{installing ? (
<div>
<span style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}>
<i className="fas fa-spinner fa-spin" /> Installing...
</span>
<div className="inline-install">
<div className="inline-install__row">
<div className="operation-spinner" />
<span className="inline-install__label">Installing{progress > 0 ? ` · ${Math.round(progress)}%` : '...'}</span>
</div>
{progress > 0 && (
<div style={{ marginTop: '4px', width: '100%', maxWidth: '120px' }}>
<div style={{ height: 3, background: 'var(--color-bg-tertiary)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${progress}%`, background: 'var(--color-primary)', borderRadius: 2, transition: 'width 300ms' }} />
</div>
<div className="operation-bar-container" style={{ flex: 'none', width: '120px', marginTop: 4 }}>
<div className="operation-bar" style={{ width: `${progress}%` }} />
</div>
)}
</div>
@ -478,7 +477,7 @@ export default function Models() {
<i className="fas fa-check-circle" /> Installed
</span>
) : (
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<span className="badge" style={{ background: 'var(--color-surface-sunken)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border-default)' }}>
<i className="fas fa-circle" /> Not Installed
</span>
)}

View file

@ -11,8 +11,8 @@ function wsUrl(path) {
}
const STREAM_BADGE = {
stdout: { bg: 'rgba(59,130,246,0.15)', color: '#60a5fa', label: 'stdout' },
stderr: { bg: 'rgba(239,68,68,0.15)', color: '#f87171', label: 'stderr' },
stdout: { bg: 'var(--color-info-light)', color: 'var(--color-log-info)', label: 'stdout' },
stderr: { bg: 'var(--color-error-light)', color: 'var(--color-log-stderr)', label: 'stderr' },
}
export default function NodeBackendLogs() {

View file

@ -20,11 +20,14 @@ const statusBadgeClass = {
function FormSection({ icon, title, children }) {
return (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<h4 style={{ marginBottom: 'var(--spacing-sm)', display: 'flex', alignItems: 'center', gap: '0.5em' }}>
{icon && <i className={icon} />} {title}
</h4>
{children}
<div className="form-group">
<div className="form-group__title">
{icon && <i className={icon} />}
<span>{title}</span>
</div>
<div className="form-group__body">
{children}
</div>
</div>
)
}
@ -62,43 +65,37 @@ function ProgressMonitor({ job, onClose }) {
const message = latestEvent?.message ?? job.message ?? ''
return (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-sm)' }}>
<h4 style={{ margin: 0 }}>
<i className="fas fa-chart-line" style={{ marginRight: '0.5em' }} />
Progress: {job.model}
<div className="card quantize-progress-card">
<div className="quantize-progress-card__header">
<h4 className="quantize-progress-card__title">
<i className="fas fa-chart-line" />
<span>Progress: {job.model}</span>
</h4>
<button className="btn btn-sm" onClick={onClose}><i className="fas fa-times" /></button>
<button type="button" className="btn btn-ghost btn-sm" onClick={onClose} title="Close">
<i className="fas fa-times" />
</button>
</div>
<div style={{ marginBottom: 'var(--spacing-sm)' }}>
<div className="quantize-progress-card__status">
<span className={`badge ${statusBadgeClass[status] || ''}`}>{status}</span>
<span style={{ marginLeft: '1em', fontSize: '0.9em', color: 'var(--color-text-secondary)' }}>{message}</span>
{message && <span className="quantize-progress-card__message">{message}</span>}
</div>
<div style={{
width: '100%', height: '24px', borderRadius: '12px',
background: 'var(--color-bg-tertiary)', overflow: 'hidden', marginBottom: 'var(--spacing-sm)',
}}>
<div style={{
width: `${Math.min(progress, 100)}%`, height: '100%',
background: status === 'failed' ? 'var(--color-error)' : 'var(--color-primary)',
transition: 'width 0.3s ease', borderRadius: '12px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.75em', fontWeight: 'bold', color: '#fff',
}}>
<div className="progress-bar">
<div
className={`progress-bar__fill${status === 'failed' ? ' progress-bar__fill--error' : ''}`}
style={{ width: `${Math.min(progress, 100)}%` }}
>
{progress > 8 ? `${progress.toFixed(1)}%` : ''}
</div>
</div>
{/* Log tail */}
<div style={{
maxHeight: '150px', overflow: 'auto', fontSize: '0.8em',
background: 'var(--color-bg-secondary)', borderRadius: '6px', padding: '0.5em',
fontFamily: 'monospace',
}}>
<div className="log-tail">
{events.slice(-20).map((ev, i) => (
<div key={i} style={{ color: ev.status === 'failed' ? 'var(--color-error)' : 'var(--color-text-secondary)' }}>
<div
key={i}
className={`log-tail__line${ev.status === 'failed' ? ' log-tail__line--error' : ''}`}
>
[{ev.status}] {ev.message}
</div>
))}
@ -140,59 +137,52 @@ function ImportPanel({ job, onRefresh }) {
}
return (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h4 style={{ marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-file-export" style={{ marginRight: '0.5em' }} />
Output
<div className="card quantize-import-card">
<h4 className="quantize-import-card__title">
<i className="fas fa-file-export" />
<span>Output</span>
</h4>
{error && (
<div className="alert alert-error" style={{ marginBottom: 'var(--spacing-sm)' }}>{error}</div>
)}
{error && <div className="alert alert-error">{error}</div>}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* Download */}
<div className="quantize-import-card__row">
<a
href={quantizationApi.downloadUrl(job.id)}
className="btn btn-secondary"
download
>
<i className="fas fa-download" style={{ marginRight: '0.4em' }} />
Download GGUF
<i className="fas fa-download" />
<span>Download GGUF</span>
</a>
{/* Import */}
{job.import_status === 'completed' ? (
<a href={`/app/chat/${encodeURIComponent(job.import_model_name)}`} className="btn btn-success">
<i className="fas fa-comments" style={{ marginRight: '0.4em' }} />
Chat with {job.import_model_name}
<a href={`/app/chat/${encodeURIComponent(job.import_model_name)}`} className="btn btn-primary">
<i className="fas fa-comments" />
<span>Chat with {job.import_model_name}</span>
</a>
) : job.import_status === 'importing' ? (
<button className="btn" disabled>
<i className="fas fa-spinner fa-spin" style={{ marginRight: '0.4em' }} />
Importing... {job.import_message}
<button type="button" className="btn btn-secondary" disabled>
<i className="fas fa-spinner fa-spin" />
<span>Importing... {job.import_message}</span>
</button>
) : (
<>
<input
className="input"
className="input quantize-import-card__name"
placeholder="Model name (auto-generated if empty)"
value={modelName}
onChange={e => setModelName(e.target.value)}
style={{ maxWidth: '280px' }}
/>
<button className="btn btn-primary" onClick={handleImport} disabled={importing}>
<i className="fas fa-file-import" style={{ marginRight: '0.4em' }} />
Import to LocalAI
<button type="button" className="btn btn-primary" onClick={handleImport} disabled={importing}>
<i className="fas fa-file-import" />
<span>Import to LocalAI</span>
</button>
</>
)}
</div>
{job.import_status === 'failed' && (
<div className="alert alert-error" style={{ marginTop: 'var(--spacing-sm)' }}>
Import failed: {job.import_message}
</div>
<div className="alert alert-error">Import failed: {job.import_message}</div>
)}
</div>
)
@ -293,37 +283,38 @@ export default function Quantize() {
const effectiveQuantType = useCustomQuant ? customQuantType : quantType
return (
<div style={{ maxWidth: '900px', margin: '0 auto', padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<h2 style={{ margin: 0 }}>
<i className="fas fa-compress" style={{ marginRight: '0.4em' }} />
Model Quantization
</h2>
<span className="badge badge-warning" style={{ fontSize: '0.7em' }}>Experimental</span>
<div className="page quantize-page">
<div className="page-header quantize-page__header">
<div>
<h1 className="page-title">
<i className="fas fa-compress" /> Model Quantization
</h1>
<p className="page-subtitle">Quantize and import GGUF models directly into LocalAI</p>
</div>
<span className="badge badge-warning">Experimental</span>
</div>
{error && (
<div className="card" style={{ borderColor: 'var(--color-error)', marginBottom: 'var(--spacing-md)' }}>
<div style={{ color: 'var(--color-error)' }}><i className="fas fa-exclamation-triangle" /> {error}</div>
<div className="alert alert-error">
<i className="fas fa-exclamation-triangle" /> {error}
</div>
)}
{/* ── New Job Form ── */}
<form onSubmit={handleSubmit}>
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<FormSection icon="fas fa-cube" title="Model">
<input
className="input"
placeholder="HuggingFace model name (e.g. meta-llama/Llama-3.2-1B) or local path"
value={model}
onChange={e => setModel(e.target.value)}
required
style={{ width: '100%' }}
/>
</FormSection>
<form onSubmit={handleSubmit} className="card quantize-form">
<FormSection icon="fas fa-cube" title="Model">
<input
className="input btn-full"
placeholder="HuggingFace model name (e.g. meta-llama/Llama-3.2-1B) or local path"
value={model}
onChange={e => setModel(e.target.value)}
required
/>
</FormSection>
<div className="form-grid-2col">
<FormSection icon="fas fa-sliders-h" title="Quantization Type">
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'center', flexWrap: 'wrap' }}>
<div className="quantize-form__quant-row">
<select
className="input"
value={useCustomQuant ? '__custom__' : quantType}
@ -335,7 +326,6 @@ export default function Quantize() {
setQuantType(e.target.value)
}
}}
style={{ maxWidth: '200px' }}
>
{QUANT_PRESETS.map(q => (
<option key={q} value={q}>{q}</option>
@ -345,11 +335,10 @@ export default function Quantize() {
{useCustomQuant && (
<input
className="input"
placeholder="Enter custom quantization type"
placeholder="Custom quantization type"
value={customQuantType}
onChange={e => setCustomQuantType(e.target.value)}
required
style={{ maxWidth: '220px' }}
/>
)}
</div>
@ -357,37 +346,37 @@ export default function Quantize() {
<FormSection icon="fas fa-server" title="Backend">
<select
className="input"
className="input btn-full"
value={backend}
onChange={e => setBackend(e.target.value)}
style={{ maxWidth: '280px' }}
>
{backends.map(b => (
<option key={b.name || b} value={b.name || b}>{b.name || b}</option>
))}
</select>
</FormSection>
</div>
<FormSection icon="fas fa-key" title="HuggingFace Token (optional)">
<input
className="input"
type="password"
placeholder="hf_... (required for gated models)"
value={hfToken}
onChange={e => setHfToken(e.target.value)}
style={{ maxWidth: '400px' }}
/>
</FormSection>
<FormSection icon="fas fa-key" title="HuggingFace Token (optional)">
<input
className="input btn-full"
type="password"
placeholder="hf_... (required for gated models)"
value={hfToken}
onChange={e => setHfToken(e.target.value)}
/>
</FormSection>
<div className="form-group__actions">
<button
className="btn btn-primary"
type="submit"
disabled={submitting || !model || (useCustomQuant && !customQuantType)}
>
{submitting ? (
<><i className="fas fa-spinner fa-spin" style={{ marginRight: '0.4em' }} /> Starting...</>
<><i className="fas fa-spinner fa-spin" /> <span>Starting...</span></>
) : (
<><i className="fas fa-play" style={{ marginRight: '0.4em' }} /> Quantize ({effectiveQuantType})</>
<><i className="fas fa-play" /> <span>Quantize ({effectiveQuantType})</span></>
)}
</button>
</div>
@ -413,20 +402,20 @@ export default function Quantize() {
{/* ── Jobs List ── */}
{jobs.length > 0 && (
<div className="card">
<h4 style={{ marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-list" style={{ marginRight: '0.5em' }} />
Jobs
<div className="card quantize-jobs">
<h4 className="quantize-jobs__title">
<i className="fas fa-list" />
<span>Jobs</span>
</h4>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9em' }}>
<div className="quantize-jobs__scroll">
<table className="data-table">
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={{ textAlign: 'left', padding: '0.5em' }}>Model</th>
<th style={{ textAlign: 'left', padding: '0.5em' }}>Quant</th>
<th style={{ textAlign: 'left', padding: '0.5em' }}>Status</th>
<th style={{ textAlign: 'left', padding: '0.5em' }}>Created</th>
<th style={{ textAlign: 'right', padding: '0.5em' }}>Actions</th>
<tr>
<th>Model</th>
<th>Quant</th>
<th>Status</th>
<th>Created</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
@ -436,37 +425,29 @@ export default function Quantize() {
return (
<tr
key={job.id}
style={{
borderBottom: '1px solid var(--color-border)',
background: isSelected ? 'var(--color-bg-secondary)' : undefined,
cursor: 'pointer',
}}
className={isSelected ? 'is-selected' : ''}
onClick={() => setSelectedJob(job)}
style={{ cursor: 'pointer' }}
>
<td style={{ padding: '0.5em', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{job.model}
</td>
<td style={{ padding: '0.5em' }}>
<code>{job.quantization_type}</code>
</td>
<td style={{ padding: '0.5em' }}>
<td className="data-table__truncate">{job.model}</td>
<td><code>{job.quantization_type}</code></td>
<td>
<span className={`badge ${statusBadgeClass[job.status] || ''}`}>{job.status}</span>
{job.import_status === 'completed' && (
<span className="badge badge-success" style={{ marginLeft: '0.3em' }}>imported</span>
<span className="badge badge-success" style={{ marginLeft: 'var(--spacing-xs)' }}>imported</span>
)}
</td>
<td style={{ padding: '0.5em', fontSize: '0.85em', color: 'var(--color-text-secondary)' }}>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)' }}>
{new Date(job.created_at).toLocaleString()}
</td>
<td style={{ padding: '0.5em', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '0.3em', justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
{isActive && (
<button className="btn btn-sm btn-error" onClick={() => handleStop(job.id)} title="Stop">
<td>
<div className="data-table__actions" onClick={e => e.stopPropagation()}>
{isActive ? (
<button type="button" className="btn btn-sm btn-danger" onClick={() => handleStop(job.id)} title="Stop">
<i className="fas fa-stop" />
</button>
)}
{!isActive && (
<button className="btn btn-sm" onClick={() => handleDelete(job.id)} title="Delete">
) : (
<button type="button" className="btn btn-sm btn-ghost" onClick={() => handleDelete(job.id)} title="Delete">
<i className="fas fa-trash" />
</button>
)}

View file

@ -79,7 +79,7 @@ export default function Sound() {
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-music" style={{ marginRight: 8, color: 'var(--color-accent)' }} />Sound Generation</h1>
<h1 className="page-title"><i className="fas fa-music" /> Sound Generation</h1>
</div>
<form onSubmit={handleGenerate}>
@ -89,11 +89,9 @@ export default function Sound() {
</div>
{/* Mode toggle */}
<div className="form-group">
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button type="button" className={`filter-btn ${mode === 'simple' ? 'active' : ''}`} onClick={() => setMode('simple')}>Simple</button>
<button type="button" className={`filter-btn ${mode === 'advanced' ? 'active' : ''}`} onClick={() => setMode('advanced')}>Advanced</button>
</div>
<div className="segmented">
<button type="button" className={`segmented__item${mode === 'simple' ? ' is-active' : ''}`} onClick={() => setMode('simple')}>Simple</button>
<button type="button" className={`segmented__item${mode === 'advanced' ? ' is-active' : ''}`} onClick={() => setMode('advanced')}>Advanced</button>
</div>
{mode === 'simple' ? (
@ -102,12 +100,14 @@ export default function Sound() {
<label className="form-label">Description</label>
<textarea className="textarea" value={text} onChange={(e) => setText(e.target.value)} placeholder="Describe the sound..." rows={3} />
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
<input type="checkbox" checked={instrumental} onChange={(e) => setInstrumental(e.target.checked)} /> Instrumental
<div className="form-grid-2col">
<label className="checkbox-row">
<input type="checkbox" checked={instrumental} onChange={(e) => setInstrumental(e.target.checked)} />
<span>Instrumental</span>
</label>
<div className="form-group" style={{ flex: 1, margin: 0 }}>
<input className="input" value={vocalLanguage} onChange={(e) => setVocalLanguage(e.target.value)} placeholder="Vocal language" />
<div className="form-group">
<label className="form-label">Vocal language</label>
<input className="input" value={vocalLanguage} onChange={(e) => setVocalLanguage(e.target.value)} placeholder="e.g. English" />
</div>
</div>
</>
@ -121,20 +121,21 @@ export default function Sound() {
<label className="form-label">Lyrics</label>
<textarea className="textarea" value={lyrics} onChange={(e) => setLyrics(e.target.value)} rows={3} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-sm)' }}>
<div className="form-grid-2col">
<div className="form-group"><label className="form-label">BPM</label><input className="input" type="number" value={bpm} onChange={(e) => setBpm(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Duration (s)</label><input className="input" type="number" step="0.1" value={duration} onChange={(e) => setDuration(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Key/Scale</label><input className="input" value={keyscale} onChange={(e) => setKeyscale(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Language</label><input className="input" value={language} onChange={(e) => setLanguage(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Time Signature</label><input className="input" value={timesignature} onChange={(e) => setTimesignature(e.target.value)} /></div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.875rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>
<input type="checkbox" checked={think} onChange={(e) => setThink(e.target.checked)} /> Think mode
<label className="checkbox-row">
<input type="checkbox" checked={think} onChange={(e) => setThink(e.target.checked)} />
<span>Think mode</span>
</label>
</>
)}
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: '100%' }}>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-music" /> Generate Sound</>}
</button>
</form>
@ -148,22 +149,20 @@ export default function Sound() {
) : error ? (
<ErrorWithTraceLink message={error} />
) : selectedEntry ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
<audio controls src={selectedEntry.results[0]?.url} style={{ width: '100%', maxWidth: '400px' }} data-testid="history-audio" />
<div style={{ padding: 'var(--spacing-sm)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)', color: 'var(--color-text-secondary)', fontStyle: 'italic', textAlign: 'center' }}>
"{selectedEntry.prompt}"
</div>
<div className="audio-result">
<audio controls src={selectedEntry.results[0]?.url} className="audio-result__player" data-testid="history-audio" />
<div className="result-quote">"{selectedEntry.prompt}"</div>
</div>
) : audioUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%', maxWidth: '400px' }} />
<div className="audio-result">
<audio ref={audioRef} controls src={audioUrl} className="audio-result__player" />
<a href={audioUrl} download={`sound-${new Date().toISOString().slice(0, 10)}.wav`} className="btn btn-primary btn-sm">
<i className="fas fa-download" /> Download
<i className="fas fa-download" /> <span>Download</span>
</a>
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-music" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.4 }} />
<div className="media-empty">
<i className="fas fa-music media-empty__icon" />
<p>Generated sound will appear here</p>
</div>
)}

View file

@ -49,7 +49,7 @@ export default function TTS() {
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-headphones" style={{ marginRight: 8, color: 'var(--color-accent)' }} />Text to Speech</h1>
<h1 className="page-title"><i className="fas fa-headphones" /> Text to Speech</h1>
</div>
<form onSubmit={handleGenerate}>
@ -67,7 +67,7 @@ export default function TTS() {
rows={5}
/>
</div>
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: '100%' }}>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-headphones" /> Generate Audio</>}
</button>
</form>
@ -81,30 +81,26 @@ export default function TTS() {
) : error ? (
<ErrorWithTraceLink message={error} />
) : selectedEntry ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
<audio controls src={selectedEntry.results[0]?.url} style={{ width: '100%' }} data-testid="history-audio" />
<div style={{ padding: 'var(--spacing-sm)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)', color: 'var(--color-text-secondary)', fontStyle: 'italic', textAlign: 'center' }}>
"{selectedEntry.prompt}"
</div>
<div className="audio-result">
<audio controls src={selectedEntry.results[0]?.url} className="audio-result__player" data-testid="history-audio" />
<div className="result-quote">"{selectedEntry.prompt}"</div>
</div>
) : audioUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%' }} />
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<div className="audio-result">
<audio ref={audioRef} controls src={audioUrl} className="audio-result__player" />
<div className="audio-result__actions">
<a href={audioUrl} download={`tts-${model}-${new Date().toISOString().slice(0, 10)}.mp3`} className="btn btn-primary btn-sm">
<i className="fas fa-download" /> Download
<i className="fas fa-download" /> <span>Download</span>
</a>
<button className="btn btn-secondary btn-sm" onClick={() => audioRef.current?.play()}>
<i className="fas fa-rotate-right" /> Replay
<button type="button" className="btn btn-secondary btn-sm" onClick={() => audioRef.current?.play()}>
<i className="fas fa-rotate-right" /> <span>Replay</span>
</button>
</div>
<div style={{ padding: 'var(--spacing-sm)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)', color: 'var(--color-text-secondary)', fontStyle: 'italic', textAlign: 'center' }}>
"{text}"
</div>
<div className="result-quote">"{text}"</div>
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-headphones" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.4 }} />
<div className="media-empty">
<i className="fas fa-headphones media-empty__icon" />
<p>Generated audio will appear here</p>
</div>
)}

View file

@ -61,17 +61,17 @@ function truncateValue(value, maxLen) {
}
const TYPE_COLORS = {
llm: { bg: 'rgba(59,130,246,0.15)', color: '#60a5fa' },
embedding: { bg: 'rgba(168,85,247,0.15)', color: '#c084fc' },
transcription: { bg: 'rgba(234,179,8,0.15)', color: '#facc15' },
image_generation: { bg: 'rgba(34,197,94,0.15)', color: '#4ade80' },
video_generation: { bg: 'rgba(236,72,153,0.15)', color: '#f472b6' },
tts: { bg: 'rgba(249,115,22,0.15)', color: '#fb923c' },
sound_generation: { bg: 'rgba(20,184,166,0.15)', color: '#2dd4bf' },
rerank: { bg: 'rgba(99,102,241,0.15)', color: '#818cf8' },
tokenize: { bg: 'rgba(107,114,128,0.15)', color: '#9ca3af' },
detection: { bg: 'rgba(14,165,233,0.15)', color: '#38bdf8' },
model_load: { bg: 'rgba(239,68,68,0.15)', color: '#f87171' },
llm: { bg: 'var(--color-primary-light)', color: 'var(--color-data-1)' },
embedding: { bg: 'var(--color-accent-light)', color: 'var(--color-data-3)' },
transcription: { bg: 'var(--color-warning-light)', color: 'var(--color-data-4)' },
image_generation: { bg: 'var(--color-success-light)', color: 'var(--color-data-5)' },
video_generation: { bg: 'var(--color-accent-light)', color: 'var(--color-data-7)' },
tts: { bg: 'var(--color-warning-light)', color: 'var(--color-data-6)' },
sound_generation: { bg: 'var(--color-info-light)', color: 'var(--color-data-8)' },
rerank: { bg: 'var(--color-primary-light)', color: 'var(--color-data-1)' },
tokenize: { bg: 'var(--color-secondary-light)', color: 'var(--color-text-muted)' },
detection: { bg: 'var(--color-info-light)', color: 'var(--color-data-8)' },
model_load: { bg: 'var(--color-error-light)', color: 'var(--color-data-2)' },
}
function typeBadgeStyle(type) {
@ -335,7 +335,11 @@ export default function Traces() {
setBackendCount(backend.length)
setTraces(activeTab === 'api' ? api : backend)
} catch (err) {
addToast(`Failed to load traces: ${err.message}`, 'error')
// Tracing disabled is the default state, not an error the in-page banner covers it.
const disabled = /disabled|not enabled|404|not found/i.test(err?.message || '')
if (!disabled) {
addToast(`Failed to load traces: ${err.message}`, 'error')
}
} finally {
setLoading(false)
}
@ -395,10 +399,11 @@ export default function Traces() {
</button>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)', alignItems: 'center' }}>
<button className="btn btn-secondary btn-sm" onClick={fetchTraces}><i className="fas fa-rotate" /> Refresh</button>
<button className="btn btn-danger btn-sm" onClick={handleClear}><i className="fas fa-trash" /> Clear</button>
<button className="btn btn-secondary btn-sm" onClick={handleExport} disabled={traces.length === 0}><i className="fas fa-download" /> Export</button>
<div style={{ flex: 1 }} />
<button className="btn btn-danger btn-sm" onClick={handleClear} disabled={traces.length === 0}><i className="fas fa-trash" /> Clear</button>
</div>
{settings && (() => {
@ -457,7 +462,7 @@ export default function Traces() {
onChange={(v) => setSettings(prev => ({ ...prev, enable_backend_logging: v }))}
/>
</SettingRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 'var(--spacing-sm)' }}>
<div className="form-group__actions" style={{ justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={handleSaveSettings} disabled={saving}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
</button>
@ -473,8 +478,20 @@ export default function Traces() {
) : traces.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-wave-square" /></div>
<h2 className="empty-state-title">No traces</h2>
<p className="empty-state-text">Traces will appear here as requests are made.</p>
<h2 className="empty-state-title">
{activeTab === 'api'
? (tracingEnabled ? 'No API traces yet' : 'API tracing is off')
: (backendLoggingEnabled ? 'No backend traces yet' : 'Backend logging is off')}
</h2>
<p className="empty-state-text">
{activeTab === 'api'
? (tracingEnabled
? 'Traces will appear here as API requests are made.'
: 'Enable Tracing above to start recording API requests, responses, and backend operations.')
: (backendLoggingEnabled
? 'Backend operations will appear here as models run.'
: 'Enable Backend Logging above to capture per-model process output.')}
</p>
</div>
) : activeTab === 'api' ? (
<div className="table-container">

View file

@ -80,7 +80,7 @@ export default function VideoGen() {
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-video" style={{ marginRight: 8, color: 'var(--color-accent)' }} />Video Generation</h1>
<h1 className="page-title"><i className="fas fa-video" /> Video Generation</h1>
</div>
<form onSubmit={handleGenerate}>
@ -97,10 +97,10 @@ export default function VideoGen() {
<textarea className="textarea" value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} rows={2} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--spacing-sm)' }}>
<div className="form-grid-3col">
<div className="form-group">
<label className="form-label">Size</label>
<select className="model-selector" value={size} onChange={(e) => setSize(e.target.value)} style={{ width: '100%' }}>
<select className="input btn-full" value={size} onChange={(e) => setSize(e.target.value)}>
{SIZES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
@ -118,7 +118,7 @@ export default function VideoGen() {
<i className="fas fa-chevron-right" /> Advanced
</div>
{showAdvanced && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<div className="form-grid-3col">
<div className="form-group"><label className="form-label">Steps</label><input className="input" type="number" value={steps} onChange={(e) => setSteps(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Seed</label><input className="input" type="number" value={seed} onChange={(e) => setSeed(e.target.value)} /></div>
<div className="form-group"><label className="form-label">CFG Scale</label><input className="input" type="number" step="0.1" value={cfgScale} onChange={(e) => setCfgScale(e.target.value)} /></div>
@ -129,13 +129,13 @@ export default function VideoGen() {
<i className="fas fa-chevron-right" /> Image Inputs
</div>
{showImageInputs && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<div className="form-grid-2col">
<div className="form-group"><label className="form-label">Start Image</label><input type="file" accept="image/*" onChange={(e) => handleImageUpload(e, setStartImage)} className="input" /></div>
<div className="form-group"><label className="form-label">End Image</label><input type="file" accept="image/*" onChange={(e) => handleImageUpload(e, setEndImage)} className="input" /></div>
</div>
)}
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: '100%' }}>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-video" /> Generate Video</>}
</button>
</form>

View file

@ -1,60 +1,84 @@
/* LocalAI Theme - CSS Variables System */
:root,
[data-theme="dark"] {
--color-bg-primary: #0f1117;
--color-bg-secondary: #161921;
--color-bg-tertiary: #1c1f2a;
--color-bg-overlay: rgba(15, 17, 23, 0.95);
--color-bg-primary: #0b0d14;
--color-bg-secondary: #14172099;
--color-bg-secondary: #161923;
--color-bg-tertiary: #232838;
--color-bg-overlay: rgba(11, 13, 20, 0.95);
--color-primary: #4f7df5;
--color-primary-hover: #3d6be3;
--color-primary-active: #2f5ad1;
--color-primary-text: #FFFFFF;
--color-primary-light: rgba(79, 125, 245, 0.08);
--color-primary-border: rgba(79, 125, 245, 0.15);
--color-surface-raised: #1a1e2c;
--color-surface-sunken: #090b12;
--color-surface-hover: #242938;
--color-secondary: #64748B;
--color-secondary-hover: #475569;
--color-secondary-light: rgba(100, 116, 139, 0.1);
--color-primary: #f1f3f8;
--color-primary-hover: #ffffff;
--color-primary-active: #d8dce6;
--color-primary-text: #0a0c12;
--color-primary-light: rgba(241, 243, 248, 0.08);
--color-primary-border: rgba(241, 243, 248, 0.22);
--color-accent: #7c9cf5;
--color-accent-hover: #6889e3;
--color-accent-light: rgba(124, 156, 245, 0.1);
--color-secondary: #6b7487;
--color-secondary-hover: #525b6e;
--color-secondary-light: rgba(107, 116, 135, 0.12);
--color-text-primary: #E5E7EB;
--color-text-secondary: #94A3B8;
--color-text-muted: #64748B;
--color-text-disabled: #475569;
--color-accent: #e8a87c;
--color-accent-hover: #d89668;
--color-accent-light: rgba(232, 168, 124, 0.14);
--color-text-primary: #f1f3f8;
--color-text-secondary: #a8adbd;
--color-text-muted: #6c7084;
--color-text-disabled: #3e4253;
--color-text-inverse: #FFFFFF;
--color-border-subtle: rgba(255, 255, 255, 0.08);
--color-border-subtle: rgba(255, 255, 255, 0.06);
--color-border-default: rgba(255, 255, 255, 0.12);
--color-border-strong: rgba(79, 125, 245, 0.3);
--color-border-strong: rgba(255, 255, 255, 0.22);
--color-border-divider: rgba(255, 255, 255, 0.05);
--color-border-primary: rgba(79, 125, 245, 0.2);
--color-border-focus: rgba(79, 125, 245, 0.4);
--color-border-primary: rgba(255, 255, 255, 0.22);
--color-border-focus: rgba(241, 243, 248, 0.38);
--color-success: #22C55E;
--color-success-light: rgba(34, 197, 94, 0.1);
--color-success-border: rgba(34, 197, 94, 0.3);
--color-success-light: rgba(34, 197, 94, 0.14);
--color-success-border: rgba(34, 197, 94, 0.32);
--color-warning: #F59E0B;
--color-warning-light: rgba(245, 158, 11, 0.1);
--color-warning-border: rgba(245, 158, 11, 0.3);
--color-warning-light: rgba(245, 158, 11, 0.14);
--color-warning-border: rgba(245, 158, 11, 0.32);
--color-error: #EF4444;
--color-error-light: rgba(239, 68, 68, 0.1);
--color-error-border: rgba(239, 68, 68, 0.3);
--color-info: #4f7df5;
--color-info-light: rgba(79, 125, 245, 0.1);
--color-info-border: rgba(79, 125, 245, 0.3);
--color-accent-border: rgba(124, 156, 245, 0.3);
--color-modal-backdrop: rgba(0, 0, 0, 0.6);
--color-error-light: rgba(239, 68, 68, 0.14);
--color-error-border: rgba(239, 68, 68, 0.32);
--color-info: #7dc7d1;
--color-info-light: rgba(125, 199, 209, 0.14);
--color-info-border: rgba(125, 199, 209, 0.32);
--color-accent-border: rgba(232, 168, 124, 0.32);
--color-modal-backdrop: rgba(0, 0, 0, 0.7);
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.35);
--shadow-glow: 0 0 0 1px rgba(79, 125, 245, 0.15), 0 0 12px rgba(79, 125, 245, 0.2);
--shadow-sidebar: 1px 0 3px rgba(0, 0, 0, 0.25);
/* Data viz palette — distinct hues, readable on dark */
--color-data-1: #7dc7d1;
--color-data-2: #f87171;
--color-data-3: #c084fc;
--color-data-4: #fbbf24;
--color-data-5: #34d399;
--color-data-6: #fb923c;
--color-data-7: #f472b6;
--color-data-8: #22d3ee;
/* Log streams */
--color-log-stdout: #d1d5db;
--color-log-stderr: #fca5a5;
--color-log-info: #93c5fd;
--color-log-warn: #fcd34d;
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.45);
--shadow-md: 0 6px 18px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 16px 32px rgba(0, 0, 0, 0.55);
--shadow-glow: var(--shadow-md);
--shadow-sidebar: 1px 0 0 rgba(255, 255, 255, 0.06);
/* Inset top-edge highlight — fakes elevation in dark mode */
--shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.06);
--duration-fast: 150ms;
--duration-normal: 200ms;
@ -66,51 +90,77 @@
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-full: 9999px;
/* Typography scale */
--text-xs: 0.6875rem;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-lg: 1.0625rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--leading-tight: 1.25;
--leading-snug: 1.4;
--leading-normal: 1.55;
--leading-relaxed: 1.7;
--sidebar-width: 200px;
--sidebar-width-collapsed: 52px;
--color-toggle-off: #475569;
--color-toggle-off: #3a4152;
--color-toggle-on: var(--color-primary);
}
[data-theme="light"] {
--color-bg-primary: #f5f6f9;
--color-bg-secondary: #FFFFFF;
--color-bg-tertiary: #f0f1f5;
--color-bg-overlay: rgba(245, 246, 249, 0.9);
/* Pearl palette — soft cool off-white, clean layered surfaces */
--color-bg-primary: #f5f6f8; /* page: soft pearl */
--color-bg-secondary: #ffffff; /* raised surface */
--color-bg-tertiary: #e7e9ef; /* sunken wells */
--color-bg-overlay: rgba(245, 246, 248, 0.92);
--color-primary: #4068d9;
--color-primary-hover: #3358c7;
--color-primary-active: #2a4ab5;
--color-surface-raised: #ffffff;
--color-surface-sunken: #eef0f4;
--color-surface-hover: #edeff4;
--color-primary: #14161f; /* near-black (cool-leaning) */
--color-primary-hover: #000000;
--color-primary-active: #2a2e3a;
--color-primary-text: #FFFFFF;
--color-primary-light: rgba(64, 104, 217, 0.08);
--color-primary-border: rgba(64, 104, 217, 0.2);
--color-primary-light: rgba(20, 22, 31, 0.06);
--color-primary-border: rgba(20, 22, 31, 0.2);
--color-secondary: #475569;
--color-secondary-hover: #334155;
--color-secondary-light: rgba(71, 85, 105, 0.1);
--color-secondary: #525966;
--color-secondary-hover: #3b414c;
--color-secondary-light: rgba(82, 89, 102, 0.1);
--color-accent: #5a78c9;
--color-accent-hover: #4a67b7;
--color-accent-light: rgba(90, 120, 201, 0.1);
--color-accent: #c97b5a; /* muted terracotta accent */
--color-accent-hover: #b56a4a;
--color-accent-light: rgba(201, 123, 90, 0.12);
--color-text-primary: #1E293B;
--color-text-secondary: #64748B;
--color-text-muted: #94A3B8;
--color-text-disabled: #CBD5E1;
--color-text-primary: #14161f; /* crisp near-black, cool-leaning */
--color-text-secondary: #555a68;
--color-text-muted: #8a8e9b;
--color-text-disabled: #c2c5cf;
--color-text-inverse: #FFFFFF;
--color-border-subtle: rgba(15, 23, 42, 0.06);
--color-border-default: rgba(15, 23, 42, 0.1);
--color-border-strong: rgba(64, 104, 217, 0.3);
--color-border-divider: rgba(15, 23, 42, 0.04);
--color-border-primary: rgba(64, 104, 217, 0.2);
--color-border-focus: rgba(64, 104, 217, 0.4);
--color-border-subtle: rgba(20, 22, 31, 0.07);
--color-border-default: rgba(20, 22, 31, 0.14);
--color-border-strong: rgba(20, 22, 31, 0.32);
--color-border-divider: rgba(20, 22, 31, 0.05);
--color-border-primary: rgba(20, 22, 31, 0.22);
--color-border-focus: rgba(20, 22, 31, 0.45);
--color-success: #16A34A;
--color-success-light: rgba(22, 163, 74, 0.1);
@ -119,19 +169,38 @@
--color-warning-light: rgba(217, 119, 6, 0.1);
--color-warning-border: rgba(217, 119, 6, 0.3);
--color-error: #DC2626;
--color-error-light: rgba(220, 38, 38, 0.1);
--color-error-light: rgba(220, 38, 38, 0.09);
--color-error-border: rgba(220, 38, 38, 0.3);
--color-info: #4068d9;
--color-info-light: rgba(64, 104, 217, 0.1);
--color-info-border: rgba(64, 104, 217, 0.3);
--color-accent-border: rgba(90, 120, 201, 0.3);
--color-modal-backdrop: rgba(0, 0, 0, 0.5);
--color-info: #0f7583;
--color-info-light: rgba(15, 117, 131, 0.10);
--color-info-border: rgba(15, 117, 131, 0.3);
--color-accent-border: rgba(201, 123, 90, 0.3);
--color-modal-backdrop: rgba(20, 22, 31, 0.5);
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.08);
--shadow-glow: 0 0 0 1px rgba(64, 104, 217, 0.15), 0 0 8px rgba(64, 104, 217, 0.2);
--shadow-sidebar: 1px 0 3px rgba(0, 0, 0, 0.08);
--color-toggle-off: #CBD5E1;
/* Data viz palette */
--color-data-1: #0f7583;
--color-data-2: #c97b5a;
--color-data-3: #8b5cf6;
--color-data-4: #d97706;
--color-data-5: #059669;
--color-data-6: #dc2626;
--color-data-7: #db2777;
--color-data-8: #0891b2;
--color-log-stdout: #14161f;
--color-log-stderr: #b91c1c;
--color-log-info: #0f7583;
--color-log-warn: #b45309;
--shadow-subtle: 0 1px 2px rgba(20, 22, 31, 0.05);
--shadow-sm: 0 2px 6px rgba(20, 22, 31, 0.07);
--shadow-md: 0 8px 20px rgba(20, 22, 31, 0.08);
--shadow-lg: 0 20px 38px rgba(20, 22, 31, 0.1);
--shadow-glow: var(--shadow-md);
--shadow-sidebar: 4px 0 24px -12px rgba(20, 22, 31, 0.1), 1px 0 0 rgba(20, 22, 31, 0.06);
--shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.6);
--color-toggle-off: #c5c8d2;
--color-toggle-on: var(--color-primary);
}