feat(settings): sidebar layout preference

Add a Sidebar layout section to Settings > Appearance with a
Fixed/Resizable mode control, a width slider (224 to 440 px, step 4),
and a targeted reset that restores the defaults without touching other
appearance values.

The sidebar layout hook now broadcasts the settings-changed event when
one of its setter writes actually changes localStorage, so every mounted
instance (the shell separator, the Settings slider, other tabs) converges
on the commit instead of holding a stale draft.

Settings search keywords gain sidebar/resize/layout aliases, and the
preference sync tests cover the targeted reset path: local defaults apply
immediately, one bus op carries both dirty fields, and the write degrades
to a migrate POST when no preference row exists yet.
This commit is contained in:
Anso
2026-09-08 09:53:34 -04:00
parent fa4bfba118
commit f16a4ae26f
6 changed files with 315 additions and 15 deletions
@@ -1,4 +1,5 @@
import { Check, Info, RotateCcw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { Combobox } from '@/components/ui/combobox';
import { Slider } from '@/components/ui/slider';
@@ -6,6 +7,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control';
import { TogglePill } from '@/components/ui/toggle-pill';
import { useDensity } from '@/hooks/use-density';
import type { Density } from '@/hooks/use-density';
import { useSidebarLayout, SIDEBAR_WIDTH, type SidebarMode } from '@/hooks/use-sidebar-layout';
import { useLogChipColorMode, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode';
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
@@ -22,6 +24,7 @@ import { AccentPicker } from '@/components/theme/AccentPicker';
import { ThemePreview } from '@/components/theme/ThemePreview';
import { TypeChips } from '@/components/theme/TypeChips';
import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from '@/components/theme/typeOptions';
import { resetSidebarLayout } from '@/lib/preferences/resetPreferences';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
@@ -63,6 +66,14 @@ const CHIP_COLOR_OPTIONS: { value: LogChipColorMode; label: string }[] = [
{ value: 'per-service', label: 'Per service' },
];
const SIDEBAR_MODE_OPTIONS: { value: SidebarMode; label: string }[] = [
{ value: 'fixed', label: 'Fixed' },
{ value: 'resizable', label: 'Resizable' },
];
/** Slider granularity in px; every reachable width is a valid stored integer. */
const SIDEBAR_WIDTH_STEP = 4;
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
// Preview swatches for the Visual style cards. Calm uses the muted ramp; Signature
@@ -157,6 +168,16 @@ export function AppearanceSection({
}) {
const [density, setDensity] = useDensity();
const [chipColorMode, setChipColorMode] = useLogChipColorMode();
const { sidebarMode, sidebarWidth, setSidebarMode, setSidebarWidth } = useSidebarLayout();
// The width slider holds a local draft only while the user drags. An effect
// re-syncs the draft from the shared preference whenever it changes through
// another surface (targeted reset, hydration, another tab), without issuing
// a write.
const [widthDraft, setWidthDraft] = useState(sidebarWidth);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- re-sync the draft from the shared preference (reset, hydration, another tab)
setWidthDraft(sidebarWidth);
}, [sidebarWidth]);
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
const [topNavMode, setTopNavMode] = useTopNavMode();
@@ -464,6 +485,53 @@ export function AppearanceSection({
</SettingsField>
</SettingsSection>
<SettingsSection title="Sidebar layout" kicker="your account">
<SettingsField
label="Sidebar mode"
helper="Fixed keeps the stacks sidebar at a set width. Resizable makes the divider between the sidebar and the workspace draggable, on desktop, between 224 and 440 px. Reset sidebar layout restores Fixed and the default width."
align="start"
>
<SegmentedControl
value={sidebarMode}
options={SIDEBAR_MODE_OPTIONS}
onChange={setSidebarMode}
ariaLabel="Sidebar mode"
/>
</SettingsField>
<SettingsField
label="Sidebar width"
helper="Preferred stacks-sidebar width while Resizable is active."
>
<div className="flex items-center gap-3">
<Slider
value={[widthDraft]}
min={SIDEBAR_WIDTH.min}
max={SIDEBAR_WIDTH.max}
step={SIDEBAR_WIDTH_STEP}
disabled={sidebarMode !== 'resizable'}
onValueChange={([v]) => setWidthDraft(v)}
onValueCommit={([v]) => setSidebarWidth(v)}
aria-label="Sidebar width"
/>
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
{widthDraft} px
</span>
</div>
</SettingsField>
<SettingsField
label="Reset sidebar layout"
helper="Restore the Fixed mode and default width; other appearance preferences are untouched."
align="start"
>
<SettingsSecondaryButton type="button" onClick={resetSidebarLayout} aria-label="Reset sidebar layout">
<RotateCcw className="h-4 w-4" />
Reset
</SettingsSecondaryButton>
</SettingsField>
</SettingsSection>
<SettingsSection title="Navigation" kicker="your account">
<SettingsField
label="Navigation style"
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, act, renderHook } from '@testing-library/react';
import { AppearanceSection } from '../AppearanceSection';
import { useTheme } from '@/hooks/use-theme';
import { SETTINGS_ITEMS } from '../registry';
import { SIDEBAR_WIDTH, SIDEBAR_MODE_KEY, SIDEBAR_WIDTH_KEY } from '@/hooks/use-sidebar-layout';
import { subscribeToPreferenceWrites } from '@/lib/preferences/preferenceEvents';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
// AppearanceSection drives the shared theme store. Reset it to a known Signature
// baseline (readability off, effects full) before each test so the disabled-state
@@ -18,7 +22,10 @@ function resetTheme() {
}
describe('AppearanceSection', () => {
beforeEach(() => resetTheme());
beforeEach(() => {
localStorage.clear();
resetTheme();
});
it('renders the four refresh sections above Theme', () => {
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
@@ -93,8 +100,12 @@ describe('AppearanceSection', () => {
it('readability locks the header + chart controls and disables the glow slider', () => {
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
// Baseline: nothing reduced, so no slider is disabled.
expect(container.querySelectorAll('[data-disabled]').length).toBe(0);
// Baseline: only the sidebar width slider is disabled (Fixed mode leaves
// it unapplied); the readability-gated controls are all active.
const glowLocked = () => !!container.querySelector('[aria-label="Ambient glow"][data-disabled]');
const sidebarLocked = () => !!container.querySelector('[aria-label="Sidebar width"][data-disabled]');
expect(glowLocked()).toBe(false);
expect(sidebarLocked()).toBe(true);
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBeNull();
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
@@ -104,7 +115,7 @@ describe('AppearanceSection', () => {
expect((screen.getByRole('switch', { name: 'Reduced effects' }) as HTMLButtonElement).disabled).toBe(true);
// Effective reduced (readability || reducedEffects) disables the glow slider
// even though reducedEffects itself is still off.
expect(container.querySelectorAll('[data-disabled]').length).toBeGreaterThan(0);
expect(glowLocked()).toBe(true);
});
it('reduced motion is independent of readability and toggles data-motion on <html>', () => {
@@ -231,3 +242,146 @@ describe('AppearanceSection', () => {
expect(screen.queryByText(/this browser/i)).toBeNull();
});
});
describe('AppearanceSection sidebar layout', () => {
beforeEach(() => {
localStorage.clear();
resetTheme();
});
// Radix puts the passed aria-label on the slider ROOT span (the element
// that also carries data-disabled and the keyboard handler); the visible
// thumb is a descendant with role="slider" and the value attributes.
const sliderRoot = (container: HTMLElement) =>
container.querySelector<HTMLElement>('[aria-label="Sidebar width"]');
const sliderThumb = (container: HTMLElement) =>
sliderRoot(container)?.querySelector<HTMLElement>('[role="slider"]');
it('renders the sidebar layout rows with Fixed as the default and the width slider locked', () => {
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
expect(screen.getByText('Sidebar layout')).toBeTruthy();
expect(screen.getByRole('radio', { name: 'Fixed' }).getAttribute('aria-checked')).toBe('true');
expect(screen.getByRole('radio', { name: 'Resizable' }).getAttribute('aria-checked')).toBe('false');
// Fixed mode leaves the width preference unapplied, so the slider locks.
expect(sliderRoot(container)?.getAttribute('data-disabled')).not.toBeNull();
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe('256');
});
it('switching to Resizable unlocks the width slider without writing the width field', () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '340');
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
const notify = vi.fn();
const unsub = subscribeToPreferenceWrites(notify);
fireEvent.click(screen.getByRole('radio', { name: 'Resizable' }));
expect(sliderRoot(container)?.getAttribute('data-disabled')).toBeNull();
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith('appearance', ['sidebarMode']);
// The mode write never changes the width field: the stored 340 survives
// and the slider keeps showing it.
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('340');
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe('340');
unsub();
});
it('the width slider drafts locally and writes once on commit', () => {
localStorage.setItem(SIDEBAR_MODE_KEY, 'resizable');
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
const notify = vi.fn();
const unsub = subscribeToPreferenceWrites(notify);
expect(sliderRoot(container)?.getAttribute('data-disabled')).toBeNull();
// Radix commits keyboard steps immediately (drag commits on release);
// onValueCommit is the single write path for both.
fireEvent.keyDown(sliderRoot(container)!, { key: 'End' });
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith('appearance', ['sidebarWidth']);
expect(Number(sliderThumb(container)?.getAttribute('aria-valuenow'))).toBe(SIDEBAR_WIDTH.max);
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe(String(SIDEBAR_WIDTH.max));
unsub();
});
it('a targeted sidebar reset restores Fixed and the default width, leaving other fields untouched', () => {
localStorage.setItem(SIDEBAR_MODE_KEY, 'resizable');
localStorage.setItem(SIDEBAR_WIDTH_KEY, '400');
localStorage.setItem('sencho.appearance.density', 'compact');
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Reset sidebar layout' }));
expect(localStorage.getItem(SIDEBAR_MODE_KEY)).toBe('fixed');
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe(String(SIDEBAR_WIDTH.default));
// Unrelated appearance fields survive the targeted reset.
expect(localStorage.getItem('sencho.appearance.density')).toBe('compact');
// The slider re-locks and the thumb lands on the default width.
expect(sliderRoot(container)?.getAttribute('data-disabled')).not.toBeNull();
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe(String(SIDEBAR_WIDTH.default));
});
it('the width draft re-syncs from the shared preference without issuing a write', () => {
localStorage.setItem(SIDEBAR_MODE_KEY, 'resizable');
localStorage.setItem(SIDEBAR_WIDTH_KEY, '300');
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
const notify = vi.fn();
const unsub = subscribeToPreferenceWrites(notify);
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe('300');
// An apply-path change (targeted reset, hydration, another tab) lands
// via the settings-changed event; the slider follows it with no write.
localStorage.setItem(SIDEBAR_WIDTH_KEY, '352');
act(() => {
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
});
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe('352');
expect(notify).not.toHaveBeenCalled();
unsub();
});
it('registers the sidebar search keywords on the appearance entry', () => {
const appearance = SETTINGS_ITEMS.find((item) => item.id === 'appearance');
expect(appearance).toBeTruthy();
for (const term of ['sidebar', 'resize', 'resizable', 'pane', 'width', 'layout']) {
expect(appearance?.keywords, `keyword ${term}`).toContain(term);
}
});
it('dragging the slider drafts locally and writes only on pointer release', () => {
localStorage.setItem(SIDEBAR_MODE_KEY, 'resizable');
localStorage.setItem(SIDEBAR_WIDTH_KEY, '300');
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
const notify = vi.fn();
const unsub = subscribeToPreferenceWrites(notify);
const root = sliderRoot(container)!;
expect(root.getAttribute('data-disabled')).toBeNull();
// Radix's pointer handlers consult the captured pointer position
// against the slider rect; pin both so the drag math is deterministic.
const rect: DOMRect = { width: 216, height: 20, top: 0, left: 0, bottom: 20, right: 216, x: 0, y: 0, toJSON: () => ({}) } as DOMRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (root.contains(this)) return rect;
return { width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect;
});
vi.spyOn(HTMLElement.prototype, 'hasPointerCapture').mockReturnValue(true);
vi.spyOn(HTMLElement.prototype, 'setPointerCapture').mockImplementation(() => {});
vi.spyOn(HTMLElement.prototype, 'releasePointerCapture').mockImplementation(() => {});
// Radix maps pointer x onto [min, max] over the track rect, then snaps
// to the 4px step. Compute the expected value the same way.
const valueAt = (x: number) => Math.min(SIDEBAR_WIDTH.max, Math.max(SIDEBAR_WIDTH.min,
Math.round((SIDEBAR_WIDTH.min + (x / 216) * (SIDEBAR_WIDTH.max - SIDEBAR_WIDTH.min)) / 4) * 4));
fireEvent.pointerDown(root, { pointerId: 1, clientX: 108, button: 0 });
const mid = valueAt(140);
fireEvent.pointerMove(root, { pointerId: 1, clientX: 140 });
// Mid-drag: the draft follows the pointer but nothing is queued.
expect(Number(sliderThumb(container)?.getAttribute('aria-valuenow'))).toBe(mid);
expect(notify).not.toHaveBeenCalled();
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('300');
fireEvent.pointerUp(root, { pointerId: 1, clientX: 140 });
// Release commits exactly once: one bus notify and one localStorage write.
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith('appearance', ['sidebarWidth']);
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe(String(mid));
unsub();
vi.restoreAllMocks();
});
});
+1 -1
View File
@@ -66,7 +66,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
group: 'personal',
label: 'Appearance',
description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to your account.',
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav', 'sync', 'account'],
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav', 'sync', 'account', 'sidebar', 'resize', 'resizable', 'pane', 'width', 'layout'],
tier: null,
scope: 'account',
},
@@ -94,12 +94,27 @@ describe('apply* write path', () => {
unsub();
});
it('skips the identical-value localStorage write but still broadcasts', () => {
it('skips the identical-value localStorage write and its broadcast', () => {
applySidebarModeValue('fixed');
let changed = 0;
const listener = () => { changed += 1; };
window.addEventListener(SENCHO_SETTINGS_CHANGED, listener);
applySidebarModeValue('fixed');
expect(changed).toBe(0);
window.removeEventListener(SENCHO_SETTINGS_CHANGED, listener);
});
it('broadcasts once when the setter path changes storage (shell separator commit)', () => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, '300');
const { result } = renderHook(() => useSidebarLayout());
let changed = 0;
const listener = () => { changed += 1; };
window.addEventListener(SENCHO_SETTINGS_CHANGED, listener);
act(() => result.current.setSidebarWidth(352));
// The writing instance keeps its own state; other mounted instances
// re-read storage on this event and converge on the new width.
expect(result.current.sidebarWidth).toBe(352);
expect(changed).toBe(1);
window.removeEventListener(SENCHO_SETTINGS_CHANGED, listener);
});
+19 -9
View File
@@ -73,14 +73,17 @@ export function currentSidebarWidth(): number {
/** Guarded localStorage write shared by every path in this module: skips the
* identical-value write and tolerates an unavailable store (private mode,
* quota). */
function writeStoredValue(key: string, value: string): void {
* quota). Reports whether a new value actually landed. */
function writeStoredValue(key: string, value: string): boolean {
try {
if (window.localStorage.getItem(key) !== value) {
window.localStorage.setItem(key, value);
return true;
}
return false;
} catch {
// ignore; localStorage may be unavailable (private mode, quota)
return false;
}
}
@@ -88,14 +91,14 @@ function writeStoredValue(key: string, value: string): void {
* instances arrives via the settings-changed event; localStorage is written
* here). Hydration-side writes do not notify the sync bus. */
export function applySidebarModeValue(next: SidebarMode): void {
writeStoredValue(SIDEBAR_MODE_KEY, next);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
const changed = writeStoredValue(SIDEBAR_MODE_KEY, next);
if (changed) window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}
/** Apply a preferred width (already sanitized) without notifying the bus. */
export function applySidebarWidthValue(next: number): void {
writeStoredValue(SIDEBAR_WIDTH_KEY, String(next));
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
const changed = writeStoredValue(SIDEBAR_WIDTH_KEY, String(next));
if (changed) window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}
interface SidebarLayoutState {
@@ -112,13 +115,20 @@ export function useSidebarLayout(): SidebarLayoutState {
// Persist-on-change, matching the use-density contract: the hook's own state
// is the write source, so a setter state change lands in localStorage even
// when no apply* path was involved. Guarded, so a re-read (same value) is a
// no-op write.
// no-op write. A setter write that actually changes storage also broadcasts
// the settings-changed event so other mounted instances (e.g. the Settings
// page while the shell separator commits) re-read; this is the setter-side
// counterpart of the apply* broadcasts above.
useEffect(() => {
writeStoredValue(SIDEBAR_MODE_KEY, sidebarMode);
if (writeStoredValue(SIDEBAR_MODE_KEY, sidebarMode)) {
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}
}, [sidebarMode]);
useEffect(() => {
writeStoredValue(SIDEBAR_WIDTH_KEY, String(sidebarWidth));
if (writeStoredValue(SIDEBAR_WIDTH_KEY, String(sidebarWidth))) {
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}
}, [sidebarWidth]);
useEffect(() => {
@@ -50,6 +50,7 @@ import {
hydrateAppearanceDocument,
hydrateNavigationDocument,
} from '../preferencesDocuments';
import { resetSidebarLayout } from '../resetPreferences';
interface MockResponse {
ok: boolean;
@@ -475,4 +476,56 @@ describe('preference sync layer', () => {
expect(recordedCalls().filter((c) => c.method === 'PUT')).toHaveLength(0);
expect(inspectQueue('appearance').kind).toBeNull();
});
it('the targeted sidebar reset writes defaults locally and enqueues one write with both fields', async () => {
// No prior row is known, so the PUT resolves its baseline first, finds the
// row absent, and converts to a create-if-absent migrate carrying the
// reset values (document rebuilt at send time).
apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
if (opts?.method === 'POST') {
return jsonResponse(201, { domain: 'appearance', revision: 1, row: { domain: 'appearance', schemaVersion: 1, revision: 1, updatedAt: 1 } });
}
return jsonResponse(200, { preferences: {} });
});
localStorage.setItem('sencho.appearance.sidebarMode', 'resizable');
localStorage.setItem('sencho.appearance.sidebarWidth', '400');
localStorage.setItem('sencho.appearance.theme', 'dim');
resetSidebarLayout();
// Local defaults land immediately, unrelated appearance fields untouched.
expect(localStorage.getItem('sencho.appearance.sidebarMode')).toBe('fixed');
expect(localStorage.getItem('sencho.appearance.sidebarWidth')).toBe('256');
expect(localStorage.getItem('sencho.appearance.theme')).toBe('dim');
await flushPendingWrites();
await vi.waitFor(() => expect(recordedCalls().some((c) => c.method === 'POST')).toBe(true));
const mutations = recordedCalls().filter((c) => c.method === 'PUT' || c.method === 'POST' || c.method === 'DELETE');
expect(mutations).toHaveLength(1);
expect(mutations[0].path).toBe('/user-preferences/appearance/migrate');
expect(mutations[0].body).toMatchObject({ sidebarMode: 'fixed', sidebarWidth: 256 });
expect(inspectQueue('appearance').settling).toBe(false);
});
it('the targeted sidebar reset after a known revision PUTs the defaults conditionally', async () => {
apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
if (opts?.method === 'PUT') {
return jsonResponse(200, { domain: 'appearance', revision: 5, updatedAt: 1 });
}
return jsonResponse(200, { preferences: {} });
});
adoptKnownRevision('appearance', 4);
localStorage.setItem('sencho.appearance.sidebarMode', 'resizable');
localStorage.setItem('sencho.appearance.sidebarWidth', '400');
resetSidebarLayout();
await flushPendingWrites();
await vi.waitFor(() => expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true));
const puts = recordedCalls().filter((c) => c.method === 'PUT');
expect(puts).toHaveLength(1);
expect(puts[0].path).toBe('/user-preferences/appearance');
expect(puts[0].body).toMatchObject({ expectedRevision: 4, sidebarMode: 'fixed', sidebarWidth: 256 });
expect(recordedCalls().filter((c) => c.method === 'DELETE')).toHaveLength(0);
expect(inspectQueue('appearance').settling).toBe(false);
});
});