fix(desktop): put the settings rail on paper so the tab meets its content

The rounded tab's whole premise is that its paper fill runs unbroken into
the view it opens onto. Settings was the one place that did not hold: the
selected white tab sat directly on a grey panel and read as a card
stranded on it.

The cause is not in the tab strip. The settings rail is painted
--color-surface-container-low, which resolves to the same --cc-s0 the
strip's trough does, so the trough appeared to wrap around the corner and
continue down the left of the page with the tab marooned in it. Every
other page — chat, market, scheduled, trace, subagent — is already paper
at the top, which is why only this one showed.

So the rail moves onto paper and the existing border-r does the
separating, which is what every other secondary panel in the app already
does (the workbench, the diff split). The rail was the odd one out.

That also fixes something nobody had flagged: the selected rail entry is
--color-surface-hover, which against the old grey was 1.06:1 — a
highlight you cannot see. On paper it is 1.15:1.

ring-offset moves with it. It is a painted ring, not a transparent one,
so naming the old fill would have drawn a grey collar around the focus
ring on a white rail.

Two alternatives rejected: --color-surface-container (#FAFAF8) measures
1.045:1 against paper — indistinguishable, so it buys a special case and
nothing else; and having the tab's fill follow whatever page is below it
would leave Settings' tab with nothing but an outline, dropping the
selected state entirely.
This commit is contained in:
程序员阿江(Relakkes)
2026-07-31 03:12:44 +08:00
parent 21efd1cf92
commit 358f3e5806
2 changed files with 35 additions and 3 deletions
+14 -3
View File
@@ -231,8 +231,17 @@ export function Settings() {
{/* Tab navigation */}
{/* Narrow enough that the rail is not a gutter of dead space, wide
enough that the longest label in any locale — the Japanese
"コンピューター操作" — still clears the truncation on TabButton. */}
<div className="w-[220px] flex-shrink-0 flex flex-col overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-4">
"コンピューター操作" — still clears the truncation on TabButton.
Paper, separated by the rule, the way every other secondary panel
in the app is (the workbench, the diff split). It used to be
`--color-surface-container-low`, which is the same value the tab
strip's trough resolves to — so the Settings tab, the one tab whose
content this rail *is*, was the only selected tab in the app whose
paper fill met a different colour at its bottom edge. It read as a
white card stranded on a grey panel. Nothing else changed to fix
it: the tab is right, this was the odd one out. */}
<div className="w-[220px] flex-shrink-0 flex flex-col overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-4">
<div className="flex-1 flex flex-col gap-0.5">
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
@@ -296,7 +305,9 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
ref={ref}
onClick={onClick}
aria-current={active ? 'page' : undefined}
className={`w-full flex items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-[13.5px] text-left transition-[background-color,color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-container-low)] ${
// `ring-offset` has to name the rail's own fill — it is painted, not
// transparent, so it tracks whatever the rail is.
className={`w-full flex items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-[13.5px] text-left transition-[background-color,color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] ${
active
? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
@@ -74,4 +74,25 @@ describe('Settings section navigation', () => {
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
expect(useUIStore.getState().activeSettingsTab).toBe('diagnostics')
})
it('keeps the rail on paper so the Settings tab meets its own content', () => {
render(<Settings />)
const rail = screen.getByRole('button', { name: 'Providers' })
.closest('div[class*="w-[220px]"]')
// This rail is what sits directly under the Settings tab, and the tab is
// filled with paper precisely so its bottom edge runs unbroken into the
// view it opens onto. The rail used to be
// `--color-surface-container-low`, which resolves to the same value as the
// strip's trough — making Settings the one tab in the app whose paper met
// a different colour at its own bottom edge, a white card stranded on a
// grey panel. Paper plus the rule, the way the workbench and the diff
// split already do it.
expect(rail?.className).toContain('bg-[var(--color-surface)]')
expect(rail?.className).not.toContain('bg-[var(--color-surface-container-low)]')
// The rule is what separates the rail from the section beside it now, so
// it is load-bearing rather than trim.
expect(rail?.className).toContain('border-r')
})
})