refactor(settings): premium redesign — compact density, nav rail, unified controls (#690)
A design-council-driven overhaul of the Settings UI for a clean, professional,
compact-yet-gorgeous feel (Notion/Obsidian quality), addressing "looks amateur,
too tall, doesn't make sense":
- Typography: section titles move from mono-uppercase ("debug log" look) to
sans sentence-case 600; mono reserved strictly for data values. Three clear
type levels.
- Density: single-line ~32-40px rows (grid 1fr auto), hairline dividers instead
of card-per-row, one muted description max per row (SettingRow hardened so the
old double-description line is structurally impossible).
- Navigation: kill the rainbow per-tab accents → one --chrome-accent; ≥760px a
sticky vertical nav rail + a calm 760px content column (no stretch to the rail
height — the empty-void fix); <760px a no-wrap horizontal scroll strip.
- Controls: full-width horizontal grids for the font + theme pickers (were a
squeezed vertical stack); unified tile/toggle/input styling via a new
SettingsInput primitive; tokenized off-token literals.
- No tacky wrapping: descriptions wrap at a comfortable measure (text-wrap:
pretty, no orphans); short control values never break mid-word.
Pure presentation — no behavior, handler, prop, testid, role, or i18n-string
changes. 636 frontend tests pass; build clean; --chrome-* tokens only.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Opus 4.8
parent
823e222bd0
commit
53d00ab76a
@@ -4,31 +4,27 @@
|
||||
panel-specific chrome — all routed through --chrome-* tokens so they theme
|
||||
correctly across every theme. */
|
||||
|
||||
/* The font grid is taller than one line and wants the row's full width — let
|
||||
its control span and top-align instead of right-aligning a single control. */
|
||||
.appearance-panel__row--fonts .st-row__control {
|
||||
align-self: flex-start;
|
||||
justify-self: stretch;
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
/* The font picker spans the row's full width (st-row--stack) so its tiles lay
|
||||
out as a horizontal grid instead of a squeezed right-aligned column. */
|
||||
.appearance-panel__row--fonts .st-row__control { display: block; }
|
||||
|
||||
/* UI scale slider + live percentage readout */
|
||||
.appearance-panel__scale {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 200px;
|
||||
gap: var(--space-4);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.appearance-panel__scale input[type='range'] {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
min-width: 160px;
|
||||
accent-color: var(--chrome-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.appearance-panel__scale-val {
|
||||
min-width: 42px;
|
||||
min-width: 40px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg);
|
||||
@@ -39,34 +35,37 @@
|
||||
.appearance-panel__themes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.appearance-panel__theme-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--chrome-border);
|
||||
border: none;
|
||||
background: var(--dot-color, var(--chrome-fg-dim));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
/* Crisp ring drawn outside the dot — calm by default, accent-free until active. */
|
||||
box-shadow: 0 0 0 1px var(--chrome-border), 0 0 0 3px transparent;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.appearance-panel__theme-dot:hover {
|
||||
transform: scale(1.1);
|
||||
border-color: var(--chrome-border-strong);
|
||||
transform: scale(1.12);
|
||||
box-shadow: 0 0 0 1px var(--chrome-border-strong);
|
||||
}
|
||||
|
||||
.appearance-panel__theme-dot.is-active {
|
||||
border-color: var(--chrome-fg);
|
||||
box-shadow: 0 0 0 2px var(--chrome-hover-bg);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--chrome-bg),
|
||||
0 0 0 3px var(--chrome-fg);
|
||||
}
|
||||
|
||||
/* Font picker — grid of live previews instead of a select box */
|
||||
.appearance-panel__fonts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -75,15 +74,16 @@
|
||||
.appearance-panel__font-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
gap: var(--space-4);
|
||||
min-height: 44px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: var(--chrome-bg);
|
||||
color: var(--chrome-fg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.appearance-panel__font-tile:hover {
|
||||
@@ -93,18 +93,26 @@
|
||||
|
||||
.appearance-panel__font-tile.is-active {
|
||||
border-color: var(--chrome-accent);
|
||||
background: color-mix(in srgb, var(--chrome-accent) 6%, var(--chrome-bg));
|
||||
box-shadow: 0 0 0 1px var(--chrome-accent) inset;
|
||||
}
|
||||
|
||||
.appearance-panel__font-sample {
|
||||
font-size: 20px;
|
||||
font-size: 21px;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.appearance-panel__font-tile.is-active .appearance-panel__font-name {
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.appearance-panel__font-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* name renders in the tile's font too — keep it the same family */
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function AppearancePanel() {
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
className="appearance-panel__row--fonts"
|
||||
className="appearance-panel__row--fonts st-row--stack"
|
||||
align="start"
|
||||
title={fontLabel}
|
||||
control={
|
||||
@@ -122,9 +122,6 @@ export default function AppearancePanel() {
|
||||
subtitle={t('settings.autoplay_preview_label', {
|
||||
defaultValue: 'Play the output as soon as a render finishes',
|
||||
})}
|
||||
hint={t('settings.autoplay_preview_hint', {
|
||||
defaultValue: 'When off, a finished render no longer starts playing on its own — useful when batch-generating segments (#666).',
|
||||
})}
|
||||
control={
|
||||
<SettingsToggle
|
||||
checked={autoPlayPreview}
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function PerformancePanel() {
|
||||
<SettingRow
|
||||
title="Disable torch.compile (Windows)"
|
||||
subtitle={!isWindows ? (platform === null ? '…' : 'not applicable') : undefined}
|
||||
note={isWindows ? 'Falls back to eager mode — fixes Triton OOM on <16 GB GPUs.' : undefined}
|
||||
hint={
|
||||
<>
|
||||
Workaround for{' '}
|
||||
@@ -114,6 +115,7 @@ export default function PerformancePanel() {
|
||||
|
||||
<SettingRow
|
||||
title="Show live system metrics in header"
|
||||
note="Adds a live RAM / CPU / VRAM monitor to the top bar (off by default)."
|
||||
hint={
|
||||
<>
|
||||
Default off — the header keeps the model-status badge and Flush
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
.voicepanel__dropdown {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 260px;
|
||||
max-width: 50vw;
|
||||
width: auto;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -34,12 +35,12 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
font-size: 0.84rem;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-md);
|
||||
color: var(--chrome-fg);
|
||||
background: var(--chrome-input-bg, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--chrome-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ import InfoHint from './InfoHint';
|
||||
* The control is right-aligned; for a read-only data value pass it as `control`
|
||||
* with `mono` to render it monospace (covers About / Privacy value rows).
|
||||
*
|
||||
* @param {LucideIcon=} icon optional leading icon (size 15, dim)
|
||||
* @param {LucideIcon=} icon optional leading icon — rendered inline (size 14, dim) inside the title
|
||||
* @param {ReactNode} title the row label (already translated)
|
||||
* @param {ReactNode=} subtitle optional one-line muted sub-label under the title
|
||||
* @param {ReactNode=} hint optional help prose — rendered as an InfoHint next to the title
|
||||
* @param {ReactNode=} subtitle optional muted description line under the title (wraps cleanly)
|
||||
* @param {ReactNode=} note alias for `subtitle` — used as a fallback when `subtitle`
|
||||
* is absent. A row renders AT MOST ONE muted description line:
|
||||
* if both are given, `subtitle` wins (no stacked double-line).
|
||||
* @param {ReactNode=} hint optional long help prose / Learn-more link — rendered as an InfoHint
|
||||
* @param {ReactNode} control the right-aligned control or value
|
||||
* @param {boolean=} mono render the control monospace (read-only data value)
|
||||
* @param {'center'|'start'=} align vertical alignment of the control (default 'center')
|
||||
@@ -21,6 +24,7 @@ export default function SettingRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
note,
|
||||
hint,
|
||||
control,
|
||||
mono = false,
|
||||
@@ -32,17 +36,18 @@ export default function SettingRow({
|
||||
className={`st-row st-row--align-${align} ${className}`.trim()}
|
||||
data-mono={mono ? '' : undefined}
|
||||
>
|
||||
{Icon && (
|
||||
<span className="st-row__icon" aria-hidden="true">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
)}
|
||||
<div className="st-row__label">
|
||||
<span className="st-row__title">
|
||||
{Icon && <Icon size={14} aria-hidden="true" />}
|
||||
{title}
|
||||
{hint && <InfoHint>{hint}</InfoHint>}
|
||||
</span>
|
||||
{subtitle && <span className="st-row__subtitle">{subtitle}</span>}
|
||||
{/* One muted description line, max. `subtitle` wins; `note` is a
|
||||
fallback. This makes the double-description bug structurally
|
||||
impossible regardless of what a panel passes. */}
|
||||
{(subtitle || note) && (
|
||||
<span className="st-row__subtitle">{subtitle || note}</span>
|
||||
)}
|
||||
</div>
|
||||
{control != null && (
|
||||
<div className={`st-row__control ${mono ? 'st-row__control--mono' : ''}`.trim()}>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* SettingsInput — the token-styled text field for Settings.
|
||||
*
|
||||
* Replaces the ad-hoc `.settings-credential__input` / `.models-search` inputs
|
||||
* with one shared primitive that themes across all 6 chrome themes (it derives
|
||||
* from --chrome-* / --space-* / --text-* only). Drop it inside a SettingRow's
|
||||
* `control`, or use it standalone.
|
||||
*
|
||||
* @param {string} value
|
||||
* @param {function} onChange
|
||||
* @param {function=} onKeyDown
|
||||
* @param {string=} placeholder
|
||||
* @param {string=} type input type (default 'text')
|
||||
* @param {boolean=} mono render the value monospace (tokens, paths)
|
||||
* @param {boolean=} disabled
|
||||
* @param {string=} className extra class
|
||||
* @param {string=} 'aria-label'
|
||||
*/
|
||||
export default function SettingsInput({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
mono = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
...rest
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={`st-input ${mono ? 'st-input--mono' : ''} ${className}`.trim()}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export default function SettingsSection({
|
||||
style={accent ? { color: accent } : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon size={15} />
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
)}
|
||||
<div className="st-section__titles">
|
||||
|
||||
@@ -16,6 +16,7 @@ import './primitives.css';
|
||||
|
||||
export { default as SettingsSection } from './SettingsSection.jsx';
|
||||
export { default as SettingRow } from './SettingRow.jsx';
|
||||
export { default as SettingsInput } from './SettingsInput.jsx';
|
||||
export { default as InfoHint } from './InfoHint.jsx';
|
||||
export { default as SettingsToggle } from './SettingsToggle.jsx';
|
||||
export { default as Collapsible } from './Collapsible.jsx';
|
||||
|
||||
@@ -9,24 +9,27 @@
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
padding: var(--space-6) var(--space-6);
|
||||
padding: var(--space-5) var(--space-6);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.st-section + .st-section { margin-top: 0; }
|
||||
.st-section:last-child { margin-bottom: 0; }
|
||||
|
||||
.st-section__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.st-section__icon {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg-muted);
|
||||
background: var(--chrome-hover-bg);
|
||||
@@ -41,23 +44,22 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
/* nudge the title to optically center against the 26px icon tile */
|
||||
padding-top: 1px;
|
||||
padding-top: 0;
|
||||
}
|
||||
.st-section__title {
|
||||
margin: 0;
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
line-height: 1.4;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.st-section__desc {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-dim);
|
||||
line-height: 1.5;
|
||||
/* one line only — long copy belongs in an InfoHint */
|
||||
@@ -74,33 +76,31 @@
|
||||
}
|
||||
.st-section__body { display: block; }
|
||||
|
||||
/* ── SettingRow ───────────────────────────────────────────────── */
|
||||
/* ── SettingRow ───────────────────────────────────────────────────
|
||||
Notion/Obsidian rhythm: label (+ one muted description) on the left,
|
||||
control on the right, vertically centred, hairline divider between.
|
||||
~32px title-only, ~40px when a description wraps under the title. */
|
||||
.st-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
padding: var(--space-4) 0;
|
||||
gap: 1px var(--space-5); /* tight row-gap, breathable col-gap */
|
||||
padding: var(--space-4) 0; /* 8px → ~34px title-only row */
|
||||
min-height: 0;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.st-row:last-child { border-bottom: none; }
|
||||
.st-row--align-start { align-items: flex-start; }
|
||||
/* When there is no leading icon the first (auto) column collapses to 0; the
|
||||
label still starts flush because the grid gap only applies between cells. */
|
||||
.st-row:not(:has(.st-row__icon)) { grid-template-columns: 0 1fr auto; }
|
||||
|
||||
.st-row__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
.st-row__label {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
gap: 2px;
|
||||
/* Cap the description measure so short copy stays on one line and long
|
||||
copy wraps at a comfortable measure — never an orphaned last word. */
|
||||
max-width: 52ch;
|
||||
}
|
||||
.st-row__title {
|
||||
display: inline-flex;
|
||||
@@ -108,26 +108,32 @@
|
||||
gap: var(--space-2);
|
||||
color: var(--chrome-fg);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-md);
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.4;
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.st-row__subtitle {
|
||||
/* Leading icons render inline in the title (no separate grid column). */
|
||||
.st-row__title svg { color: var(--chrome-fg-dim); flex: none; }
|
||||
.st-row__subtitle { /* one muted description line; wraps cleanly */
|
||||
color: var(--chrome-fg-dim);
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
/* keep subtitles to a single visual line per the contract */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
text-wrap: pretty; /* avoid 1-word orphan last lines */
|
||||
}
|
||||
.st-row__subtitle a { color: var(--chrome-accent); text-decoration: none; }
|
||||
.st-row__subtitle a:hover { text-decoration: underline; }
|
||||
.st-row__control {
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
max-width: 60%;
|
||||
text-align: right;
|
||||
/* Short control values must never wrap mid-word. Long data values opt back
|
||||
into wrapping via the --mono rule below. */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.st-row__control--mono {
|
||||
font-family: var(--chrome-font-mono);
|
||||
@@ -139,6 +145,45 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Input rows: label on top, full-width control row below ─────
|
||||
Used where the control is a text input + buttons that needs the full
|
||||
width (proxy / ffmpeg / credentials), not a squeezed right-aligned slot. */
|
||||
.st-row--stack {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.st-row--stack .st-row__label { max-width: none; }
|
||||
.st-row--stack .st-row__control {
|
||||
justify-self: stretch;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
flex-wrap: wrap;
|
||||
white-space: normal; /* stacked controls hold inputs/tiles — allow wrap */
|
||||
}
|
||||
.st-row--stack .st-row__control .st-input { flex: 1 1 220px; }
|
||||
|
||||
/* ── Narrow: stack the row so the control drops below the label ── */
|
||||
@media (max-width: 560px) {
|
||||
.st-row { grid-template-columns: 1fr; gap: var(--space-2); }
|
||||
.st-row__control { justify-self: start; max-width: 100%; text-align: left; }
|
||||
}
|
||||
|
||||
/* ── SettingsInput — token-styled text input (replaces credential/search) ── */
|
||||
.st-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: color-mix(in srgb, var(--chrome-bg) 94%, white);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
.st-input--mono { font-family: var(--chrome-font-mono); }
|
||||
.st-input:focus { outline: none; border-color: var(--chrome-accent); }
|
||||
.st-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── InfoHint ─────────────────────────────────────────────────── */
|
||||
.st-hint {
|
||||
display: inline-flex;
|
||||
@@ -239,15 +284,15 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
+72
-177
@@ -6,20 +6,18 @@
|
||||
full-height panel instead of a stunted box floating in a void. min-height:100%
|
||||
is a FLOOR — tall tabs (Models/Logs) grow past it and scroll as before; if the
|
||||
parent height is ever indeterminate it simply no-ops. */
|
||||
.settings-page { padding: 10px 24px 24px; display: flex; flex-direction: column; min-height: 100%; box-sizing: border-box; }
|
||||
.settings-page h1 { font-size: 1.15rem; margin: 0 0 2px; }
|
||||
.settings-page .settings-subtitle{ font-size: 0.72rem; margin-bottom: 14px; }
|
||||
.settings-page { padding: var(--space-5) var(--space-7) var(--space-7); display: flex; flex-direction: column; min-height: 100%; box-sizing: border-box; }
|
||||
.settings-page h1 { font-size: var(--text-lg); margin: 0 0 2px; }
|
||||
.settings-page .settings-subtitle{ font-size: var(--text-base); margin-bottom: var(--space-5); }
|
||||
.settings-tabs-ui { margin-bottom: var(--space-3); }
|
||||
|
||||
.settings-section--compact { padding: 8px 10px; margin-bottom: 8px; }
|
||||
|
||||
.settings-row__mono { font-family: var(--chrome-font-mono); color: var(--chrome-fg-muted); }
|
||||
.settings-muted { color: var(--chrome-fg-dim); font-size: var(--text-md); font-family: var(--font-sans); }
|
||||
.settings-prose {
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--chrome-fg-muted);
|
||||
line-height: 1.6;
|
||||
font-size: 0.82rem;
|
||||
font-size: var(--text-md);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.settings-prose strong { color: var(--chrome-fg); font-weight: 600; }
|
||||
@@ -64,72 +62,61 @@
|
||||
/* The .settings-content bridge owns the gap below the bar (see below). */
|
||||
.settings-tabs-ui { margin-bottom: 0; }
|
||||
|
||||
/* The settings sub-nav has 10 tabs (General…Privacy). The base .ui-tabs is a
|
||||
non-wrapping inline-flex row, so on a narrow Settings pane the later tabs
|
||||
(Credentials/Logs/About/Privacy) overflow and clip out of view. Scope a wrap
|
||||
to this nav only (the shared primitive is unchanged) so every tab stays
|
||||
reachable — the pill bar grows to 2 rows instead of running off-screen.
|
||||
`.ui-tabs.settings-tabs-ui` outranks `.ui-tabs` so the overrides take
|
||||
regardless of stylesheet order. */
|
||||
.ui-tabs.settings-tabs-ui {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
row-gap: 3px;
|
||||
/* Settings has 11 sub-tabs — tighten the per-tab padding/gap so the row packs
|
||||
more pills before it has to wrap, while the wrap below stays as a safety
|
||||
net on very narrow panes. (Labels were shortened in en.json too.) */
|
||||
gap: 2px;
|
||||
/* Border-connect: the bar opens at the bottom into the content panel — flat
|
||||
bottom corners + no bottom border so the panel below reads as one
|
||||
continuous outlined container attached to the tabs. */
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Active tab adopts its own semantic accent (TAB_DEFS) instead of the global
|
||||
pink, so that colour can be echoed on the content edge below as the visual
|
||||
"connection". --ui-tab-accent is set per-trigger by the Tabs primitive.
|
||||
Scoped to the settings nav only. */
|
||||
.ui-tabs.settings-tabs-ui .ui-tabs__tab { padding-left: 10px; padding-right: 10px; }
|
||||
/* ── Tab nav: single-accent, no rainbow ───────────────────────────
|
||||
The active tab uses the one global --chrome-accent (TAB_DEFS no longer
|
||||
carries per-tab accents). At ≥760px the nav becomes a sticky vertical
|
||||
rail; below that it's a single no-wrap horizontal scroll strip. */
|
||||
.ui-tabs.settings-tabs-ui .ui-tabs__tab.is-active {
|
||||
background: color-mix(in srgb, var(--ui-tab-accent) 14%, transparent);
|
||||
color: var(--ui-tab-accent);
|
||||
border-color: color-mix(in srgb, var(--ui-tab-accent) 42%, transparent);
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-color: transparent;
|
||||
box-shadow: inset 2px 0 0 var(--chrome-accent);
|
||||
}
|
||||
.ui-tabs.settings-tabs-ui .ui-tabs__tab.is-active .ui-tabs__icon {
|
||||
color: var(--ui-tab-accent);
|
||||
color: var(--chrome-accent);
|
||||
}
|
||||
|
||||
/* Tab → content connection. The panel opens with a hairline painted in the
|
||||
active tab's accent (threaded in as --settings-accent by Settings.jsx), so
|
||||
the coloured active tab above and the coloured content edge below read as a
|
||||
single unit — subtle (a 1px rule blended toward the chrome border), not a
|
||||
slab, and wrap-proof (colour carries the meaning, not position). The margin
|
||||
+ padding give the panel deliberate breathing room under the bar. */
|
||||
.settings-content {
|
||||
/* Attached directly under the bar (no gap) as a 3-sided panel with an open
|
||||
top — the bar + panel form one outlined container, split where the tabs
|
||||
sit. The border is tinted by the active tab's accent so the active tab and
|
||||
its panel read as connected by a shared border. */
|
||||
margin-top: 0;
|
||||
/* Grow to fill the flex-column .settings-page so the bordered panel reaches
|
||||
the viewport bottom on short tabs (only adds space when there's slack —
|
||||
tall tabs keep their natural height + internal scroll). */
|
||||
flex: 1 1 auto;
|
||||
padding: var(--space-5) var(--space-5) var(--space-4);
|
||||
border: 1px solid color-mix(in srgb, var(--settings-accent) 38%, var(--chrome-border) 62%);
|
||||
/* Stronger top: a 2px full-accent edge at the seam with the bar, so the
|
||||
active tab's colour visibly "feeds" into the panel it opens. Top corners
|
||||
stay square to butt against the bar above; bottom corners round off. */
|
||||
border-top: 2px solid var(--settings-accent);
|
||||
border-bottom-left-radius: var(--chrome-radius-pill);
|
||||
border-bottom-right-radius: var(--chrome-radius-pill);
|
||||
transition: border-color var(--dur-fast, 120ms) var(--ease-out, ease);
|
||||
/* ── Wide (≥760px): vertical rail + content column ──────────────── */
|
||||
@media (min-width: 760px) {
|
||||
.settings-page { display: grid; grid-template-columns: 168px 1fr; gap: var(--space-5); align-content: start; }
|
||||
.settings-page h1,
|
||||
.settings-page .settings-subtitle { grid-column: 1 / -1; }
|
||||
.ui-tabs.settings-tabs-ui {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-1);
|
||||
border: none;
|
||||
background: transparent;
|
||||
position: sticky;
|
||||
top: var(--space-5);
|
||||
align-self: start;
|
||||
}
|
||||
.ui-tabs.settings-tabs-ui .ui-tabs__tab { justify-content: flex-start; width: 100%; }
|
||||
}
|
||||
/* The bar's accent already cues the active tab; inside the connected panel the
|
||||
first card sits flush to the panel's inner padding (no extra top margin). */
|
||||
|
||||
/* ── Narrow (<760px): horizontal no-wrap scroll strip ───────────── */
|
||||
@media (max-width: 759.98px) {
|
||||
.ui-tabs.settings-tabs-ui {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.ui-tabs.settings-tabs-ui::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
/* ── Content panel: a calm reading column — label↔control stay close on wide
|
||||
screens. ~760px keeps inline rows premium; expansive controls (font/theme
|
||||
grids, Models/Engines tables) get the full width via the opt-outs below. */
|
||||
.settings-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 760px;
|
||||
align-self: start; /* size to content — don't stretch to the taller nav rail */
|
||||
}
|
||||
/* The Models/Engines table wants the full width — opt those panels out. */
|
||||
.settings-content:has(.models-table) { max-width: none; }
|
||||
.settings-content > :first-child { margin-top: 0; }
|
||||
|
||||
/* ── Engines tab ─────────────────────────────────────────────── */
|
||||
@@ -161,7 +148,7 @@
|
||||
border-left: 2px solid var(--chrome-border-strong);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
font-size: var(--text-md);
|
||||
color: var(--chrome-fg-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -178,24 +165,6 @@
|
||||
.engines-list__reason { flex-basis: 100%; font-size: var(--text-xs); color: var(--chrome-fg-dim); font-family: var(--font-sans); padding-left: var(--space-4); }
|
||||
|
||||
/* ── Models tab ──────────────────────────────────────────────── */
|
||||
.reco-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 6px var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
border: 1px solid color-mix(in srgb, #8ec07c 30%, transparent);
|
||||
border-left: 2px solid #8ec07c;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, #8ec07c 5%, transparent);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.reco-banner__text { flex: 1; }
|
||||
.reco-banner__text strong { color: var(--chrome-fg); font-weight: 600; }
|
||||
.reco-banner__size { color: var(--chrome-fg-dim); font-size: var(--text-xs); }
|
||||
|
||||
.models-row__tag {
|
||||
margin-left: var(--space-2);
|
||||
padding: 1px 6px;
|
||||
@@ -237,10 +206,10 @@
|
||||
.reco-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-muted);
|
||||
border: 1px solid;
|
||||
@@ -254,8 +223,8 @@
|
||||
.reco-banner--pending {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 8px 12px 10px;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
border-color: color-mix(in srgb, #f3a5b6 25%, transparent);
|
||||
border-left-color: #f3a5b6;
|
||||
background: linear-gradient(135deg, color-mix(in srgb, #f3a5b6 4%, transparent), color-mix(in srgb, #d3869b 2%, transparent));
|
||||
@@ -273,7 +242,7 @@
|
||||
}
|
||||
.reco-banner__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.76rem;
|
||||
font-size: var(--text-md);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.reco-banner__btns {
|
||||
@@ -284,8 +253,8 @@
|
||||
.reco-banner__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0 16px;
|
||||
font-size: 0.68rem;
|
||||
gap: 0 var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.reco-banner__model {
|
||||
@@ -300,11 +269,11 @@
|
||||
.reco-banner__model--ok { color: var(--chrome-fg); }
|
||||
.reco-banner__model-size {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 0.6rem;
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
.reco-banner__req {
|
||||
font-size: 0.54rem;
|
||||
font-size: var(--text-2xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #d3869b;
|
||||
@@ -315,7 +284,7 @@
|
||||
}
|
||||
|
||||
/* Table — dense rows, fixed-width action column. */
|
||||
.models-table { border-radius: 8px; }
|
||||
.models-table { border-radius: var(--chrome-radius-pill); }
|
||||
.models-table .ui-table-header { padding: 5px var(--space-3); }
|
||||
.models-table__body { max-height: 560px; overflow-y: auto; position: relative; }
|
||||
.models-table__header { align-items: center; }
|
||||
@@ -362,7 +331,7 @@
|
||||
}
|
||||
|
||||
/* Inside the wizard embed the table stretches to fill — no fixed cap. */
|
||||
.setup-wizard__embed .settings-section--compact {
|
||||
.setup-wizard__embed .st-section {
|
||||
display: flex; flex-direction: column; height: 100%; min-height: 0;
|
||||
}
|
||||
.setup-wizard__embed .models-table {
|
||||
@@ -379,7 +348,7 @@
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 10px var(--space-3);
|
||||
padding: var(--space-5) var(--space-3);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
@@ -442,21 +411,9 @@
|
||||
display: flex; align-items: center; gap: var(--space-2);
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
.models-search {
|
||||
flex: 1; min-width: 120px;
|
||||
/* Slightly-raised surface derived from --chrome-bg so themed chrome
|
||||
(midnight/nord/…) carries through; ≈ #1d1d1d on the default theme. */
|
||||
background: color-mix(in srgb, var(--chrome-bg) 94%, white);
|
||||
border: 1px solid var(--chrome-border, #2a2a2a);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
color: var(--chrome-fg);
|
||||
padding: 6px 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.models-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--chrome-accent, #fabd2f);
|
||||
}
|
||||
/* Visuals come from .st-input (SettingsInput); .models-search only adds the
|
||||
in-toolbar flex sizing. */
|
||||
.models-search.st-input { flex: 1; min-width: 120px; font-size: var(--text-xs); }
|
||||
.models-row__size { font-family: var(--chrome-font-mono); font-size: var(--text-xs); color: var(--chrome-fg-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.models-row__actions {
|
||||
@@ -480,70 +437,8 @@
|
||||
|
||||
/* ── Engines tab: rows are NOT virtualised, so override the absolute
|
||||
positioning that .models-row inherits from the Models tab. ───── */
|
||||
.settings-section--compact > .models-table > .models-table__body > .models-row {
|
||||
.st-section > .models-table > .models-table__body > .models-row {
|
||||
position: relative;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
/* ── Credentials tab ────────────────────────────────────────── */
|
||||
.settings-credential {
|
||||
margin-bottom: var(--space-5);
|
||||
padding-bottom: var(--space-5);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.settings-credential:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||||
|
||||
.settings-credential__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.settings-credential__label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.settings-credential__row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.settings-credential__input {
|
||||
flex: 1;
|
||||
max-width: 380px;
|
||||
background: color-mix(in srgb, var(--chrome-bg) 94%, white);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
color: var(--chrome-fg);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--chrome-font-mono);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.settings-credential__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--chrome-accent, #fabd2f);
|
||||
}
|
||||
.settings-credential__help {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-dim);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.settings-credential__help a {
|
||||
color: var(--chrome-accent, #d3869b);
|
||||
text-decoration: none;
|
||||
}
|
||||
.settings-credential__help a:hover { text-decoration: underline; }
|
||||
|
||||
.settings-section__subtitle {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--chrome-fg-dim);
|
||||
margin: var(--space-5) 0 var(--space-3);
|
||||
font-family: var(--chrome-font-mono);
|
||||
}
|
||||
|
||||
+119
-112
@@ -30,7 +30,7 @@ import { setupDownloadStreamUrl } from '../api/setup';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { resolveAboutVersion } from '../utils/appVersion';
|
||||
import { Tabs, Segmented, Button, Badge, Table, Progress, Select } from '../ui';
|
||||
import { SettingsSection, SettingRow, Collapsible } from '../components/settings/primitives';
|
||||
import { SettingsSection, SettingRow, SettingsInput, Collapsible } from '../components/settings/primitives';
|
||||
import { useAppStore } from '../store';
|
||||
import ApiKeysPanel from '../components/settings/ApiKeysPanel';
|
||||
import LLMEndpointPanel from '../components/settings/LLMEndpointPanel';
|
||||
@@ -55,17 +55,17 @@ import './Settings.css';
|
||||
// engine stack (Models/Engines), feature areas (Capture/Sharing), secrets
|
||||
// (Credentials), maintenance (Updates/Logs), and reference (About/Privacy).
|
||||
const TAB_DEFS = [
|
||||
{ id: 'general', icon: Settings2, accent: '#83a598' },
|
||||
{ id: 'appearance', icon: Palette, accent: '#d3869b' },
|
||||
{ id: 'models', icon: Cpu, accent: '#f3a5b6' },
|
||||
{ id: 'engines', icon: Plug, accent: '#d3869b' },
|
||||
{ id: 'capture', icon: Keyboard, accent: '#83a598' },
|
||||
{ id: 'sharing', icon: Wifi, accent: '#83a598' },
|
||||
{ id: 'credentials', icon: KeyRound, accent: '#fe8019' },
|
||||
{ id: 'updates', icon: ArrowDownToLine, accent: '#b8bb26' },
|
||||
{ id: 'logs', icon: FileText, accent: '#fabd2f' },
|
||||
{ id: 'about', icon: Info, accent: '#8ec07c' },
|
||||
{ id: 'privacy', icon: ShieldCheck, accent: '#b8bb26' },
|
||||
{ id: 'general', icon: Settings2 },
|
||||
{ id: 'appearance', icon: Palette },
|
||||
{ id: 'models', icon: Cpu },
|
||||
{ id: 'engines', icon: Plug },
|
||||
{ id: 'capture', icon: Keyboard },
|
||||
{ id: 'sharing', icon: Wifi },
|
||||
{ id: 'credentials', icon: KeyRound },
|
||||
{ id: 'updates', icon: ArrowDownToLine },
|
||||
{ id: 'logs', icon: FileText },
|
||||
{ id: 'about', icon: Info },
|
||||
{ id: 'privacy', icon: ShieldCheck },
|
||||
];
|
||||
|
||||
const LOG_SOURCE_DEFS = [
|
||||
@@ -187,7 +187,7 @@ function GeneralTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection icon={Settings2} accent="#83a598" title={t('settings.general')}>
|
||||
<SettingsSection icon={Settings2} title={t('settings.general')}>
|
||||
<SettingRow
|
||||
icon={Globe}
|
||||
title={t('settings.language')}
|
||||
@@ -215,56 +215,62 @@ function GeneralTab() {
|
||||
/>
|
||||
|
||||
<Collapsible title={t('settings.advanced')} icon={Settings2}>
|
||||
<div className="settings-credential">
|
||||
<div className="settings-credential__header">
|
||||
<label className="settings-credential__label">{t('settings.proxy')}</label>
|
||||
{proxySaved && <Badge tone="success" size="xs">{t('credentials.saved')}</Badge>}
|
||||
</div>
|
||||
<p className="settings-credential__help">{t('settings.proxy_desc')}</p>
|
||||
<div className="settings-credential__row">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-credential__input"
|
||||
placeholder="http://127.0.0.1:7890 or socks5://127.0.0.1:7890"
|
||||
value={proxyUrl}
|
||||
onChange={e => setProxyUrl(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && saveProxy()}
|
||||
/>
|
||||
<Button size="sm" variant="subtle" onClick={saveProxy} loading={proxySaving} disabled={!proxyUrl.trim()}>
|
||||
{t('credentials.save')}
|
||||
</Button>
|
||||
{proxySaved && (
|
||||
<Button size="sm" variant="ghost" onClick={clearProxy} loading={proxySaving}>
|
||||
{t('settings.proxy_clear')}
|
||||
<SettingRow
|
||||
align="start"
|
||||
className="st-row--stack"
|
||||
title={
|
||||
<>
|
||||
{t('settings.proxy')}
|
||||
{proxySaved && <Badge tone="success" size="xs">{t('credentials.saved')}</Badge>}
|
||||
</>
|
||||
}
|
||||
note={t('settings.proxy_desc')}
|
||||
control={
|
||||
<>
|
||||
<SettingsInput
|
||||
placeholder="http://127.0.0.1:7890 or socks5://127.0.0.1:7890"
|
||||
value={proxyUrl}
|
||||
onChange={e => setProxyUrl(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && saveProxy()}
|
||||
/>
|
||||
<Button size="sm" variant="subtle" onClick={saveProxy} loading={proxySaving} disabled={!proxyUrl.trim()}>
|
||||
{t('credentials.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{proxySaved && (
|
||||
<Button size="sm" variant="ghost" onClick={clearProxy} loading={proxySaving}>
|
||||
{t('settings.proxy_clear')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="settings-credential">
|
||||
<div className="settings-credential__header">
|
||||
<label className="settings-credential__label">{t('settings.ffmpeg')}</label>
|
||||
<Badge tone={ffmpegOk ? 'success' : 'warn'} size="xs">
|
||||
{ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="settings-credential__help">
|
||||
{ffmpegCurrent ? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}` : t('settings.ffmpeg_desc')}
|
||||
</p>
|
||||
<div className="settings-credential__row">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-credential__input"
|
||||
placeholder="D:\ffmpeg\bin\ffmpeg.exe"
|
||||
value={ffmpegPath}
|
||||
onChange={e => setFfmpegPath(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && saveFfmpeg()}
|
||||
/>
|
||||
<Button size="sm" variant="subtle" onClick={saveFfmpeg} loading={ffmpegSaving} disabled={!ffmpegPath.trim()}>
|
||||
{t('credentials.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
align="start"
|
||||
className="st-row--stack"
|
||||
title={
|
||||
<>
|
||||
{t('settings.ffmpeg')}
|
||||
<Badge tone={ffmpegOk ? 'success' : 'warn'} size="xs">
|
||||
{ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
note={ffmpegCurrent ? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}` : t('settings.ffmpeg_desc')}
|
||||
control={
|
||||
<>
|
||||
<SettingsInput
|
||||
placeholder="D:\ffmpeg\bin\ffmpeg.exe"
|
||||
value={ffmpegPath}
|
||||
onChange={e => setFfmpegPath(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && saveFfmpeg()}
|
||||
/>
|
||||
<Button size="sm" variant="subtle" onClick={saveFfmpeg} loading={ffmpegSaving} disabled={!ffmpegPath.trim()}>
|
||||
{t('credentials.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Collapsible>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -862,7 +868,7 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<SettingsSection icon={Cpu} accent="#f3a5b6" title={t('settings.models')}>
|
||||
<SettingsSection icon={Cpu} title={t('settings.models')}>
|
||||
<div className="settings-muted">{t('common.loading')}</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -870,7 +876,7 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<section className="settings-section settings-section--compact">
|
||||
<section className="st-section">
|
||||
<div className="models-toolbar">
|
||||
<div className="models-toolbar__stats">
|
||||
<span><strong>{fmtBytes(data.total_installed_bytes)}</strong></span>
|
||||
@@ -1008,7 +1014,7 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
<input
|
||||
<SettingsInput
|
||||
type="search"
|
||||
className="models-search"
|
||||
placeholder={t('models.search_placeholder')}
|
||||
@@ -1112,7 +1118,7 @@ export function EnginesTab() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="settings-section settings-section--compact">
|
||||
<section className="st-section">
|
||||
<div className="models-toolbar">
|
||||
<div className="models-toolbar__stats">
|
||||
<Segmented
|
||||
@@ -1396,13 +1402,8 @@ export default function Settings() {
|
||||
: status?.status === 'loading' ? <Badge tone="warn"><RefreshCw size={11} className="spinner" /> {t('models.loading_badge')}</Badge>
|
||||
: <Badge tone="warn">{t('models.idle_badge')}</Badge>;
|
||||
|
||||
// The active tab's accent (from TAB_DEFS) threads down to the content edge as
|
||||
// --settings-accent, so the coloured active tab and the content panel read as
|
||||
// one connected unit (see .settings-content in Settings.css).
|
||||
const activeAccent = TAB_DEFS.find((d) => d.id === activeTab)?.accent || 'var(--chrome-accent)';
|
||||
|
||||
return (
|
||||
<div className="settings-page" style={{ '--settings-accent': activeAccent }}>
|
||||
<div className="settings-page">
|
||||
<Tabs
|
||||
items={TAB_DEFS.map(def => ({ ...def, label: t(`settings.${def.id}`) }))}
|
||||
value={activeTab}
|
||||
@@ -1454,7 +1455,6 @@ export default function Settings() {
|
||||
{activeTab === 'logs' && (
|
||||
<SettingsSection
|
||||
icon={FileText}
|
||||
accent="#fabd2f"
|
||||
title={t('settings.logs')}
|
||||
actions={
|
||||
<>
|
||||
@@ -1508,13 +1508,13 @@ export default function Settings() {
|
||||
)}
|
||||
|
||||
{activeTab === 'updates' && (
|
||||
<SettingsSection icon={ArrowDownToLine} accent="#b8bb26" title={t('settings.updates')}>
|
||||
<SettingsSection icon={ArrowDownToLine} title={t('settings.updates')}>
|
||||
<UpdatesPanel />
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{activeTab === 'about' && (
|
||||
<SettingsSection icon={Info} accent="#8ec07c" title={t('settings.about')}>
|
||||
<SettingsSection icon={Info} title={t('settings.about')}>
|
||||
<Row label={t('about.app')} value="OmniVoice Studio" />
|
||||
<Row label={t('about.version')} value={resolveAboutVersion(appVersion, info)} mono />
|
||||
<Row label={t('about.tauri_runtime')} value={tauriVersion || (isTauri() ? '—' : t('about.web_preview'))} mono />
|
||||
@@ -1657,7 +1657,7 @@ export default function Settings() {
|
||||
)}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<SettingsSection icon={ShieldCheck} accent="#b8bb26" title={t('settings.privacy')}>
|
||||
<SettingsSection icon={ShieldCheck} title={t('settings.privacy')}>
|
||||
<p className="settings-prose">
|
||||
<Trans i18nKey="privacy.desc" components={{ 1: <strong /> }} />
|
||||
</p>
|
||||
@@ -1817,7 +1817,6 @@ function HotkeyTab() {
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={Keyboard}
|
||||
accent="#83a598"
|
||||
title={t('settings.shortcut')}
|
||||
>
|
||||
{!tauri && (
|
||||
@@ -1904,7 +1903,6 @@ function CredentialsTab({ info }) {
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={KeyRound}
|
||||
accent="#fe8019"
|
||||
title={t('settings.credentials')}
|
||||
description={t('settings.credentials_desc')}
|
||||
>
|
||||
@@ -1920,41 +1918,50 @@ function CredentialsTab({ info }) {
|
||||
<Trans i18nKey="credentials.desc" components={{ 1: <strong /> }} />
|
||||
</p>
|
||||
{CREDENTIAL_FIELDS.filter(f => f.key !== 'HF_TOKEN').map(field => (
|
||||
<div key={field.key} className="settings-credential">
|
||||
<div className="settings-credential__header">
|
||||
<label className="settings-credential__label">{t(field.labelKey)}</label>
|
||||
{field.key === 'HF_TOKEN' && (
|
||||
<Badge tone={info?.has_hf_token || saved.HF_TOKEN ? 'success' : 'warn'} size="xs">
|
||||
{info?.has_hf_token || saved.HF_TOKEN ? t('credentials.saved') : t('credentials.not_set')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-credential__row">
|
||||
<input
|
||||
type={field.isPassword ? 'password' : 'text'}
|
||||
className="settings-credential__input"
|
||||
placeholder={field.placeholderKey}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
onKeyDown={e => e.key === 'Enter' && save(field.key)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
loading={saving === field.key}
|
||||
onClick={() => save(field.key)}
|
||||
disabled={!(values[field.key] || '').trim()}
|
||||
>
|
||||
{t('credentials.save')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="settings-credential__help">
|
||||
{t(field.helpKey)}
|
||||
{field.link && (
|
||||
<> <a href="#" onClick={e => { e.preventDefault(); openExternal(field.link); }}>{t('credentials.get_token')}</a></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
key={field.key}
|
||||
align="start"
|
||||
className="st-row--stack"
|
||||
title={
|
||||
<>
|
||||
{t(field.labelKey)}
|
||||
{field.key === 'HF_TOKEN' && (
|
||||
<Badge tone={info?.has_hf_token || saved.HF_TOKEN ? 'success' : 'warn'} size="xs">
|
||||
{info?.has_hf_token || saved.HF_TOKEN ? t('credentials.saved') : t('credentials.not_set')}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
<>
|
||||
{t(field.helpKey)}
|
||||
{field.link && (
|
||||
<> <a href="#" onClick={e => { e.preventDefault(); openExternal(field.link); }}>{t('credentials.get_token')}</a></>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
control={
|
||||
<>
|
||||
<SettingsInput
|
||||
type={field.isPassword ? 'password' : 'text'}
|
||||
mono
|
||||
placeholder={field.placeholderKey}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
onKeyDown={e => e.key === 'Enter' && save(field.key)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
loading={saving === field.key}
|
||||
onClick={() => save(field.key)}
|
||||
disabled={!(values[field.key] || '').trim()}
|
||||
>
|
||||
{t('credentials.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Collapsible>
|
||||
</SettingsSection>
|
||||
|
||||
Reference in New Issue
Block a user