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:
Palash Debnath
2026-06-25 14:57:45 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent 823e222bd0
commit 53d00ab76a
11 changed files with 379 additions and 374 deletions
@@ -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
View File
@@ -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
View File
@@ -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>