diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index d779846b..d60ad9ac 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -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({ + + + + + + +
+ setWidthDraft(v)} + onValueCommit={([v]) => setSidebarWidth(v)} + aria-label="Sidebar width" + /> + + {widthDraft} px + +
+
+ + + + + Reset + + +
+ { - beforeEach(() => resetTheme()); + beforeEach(() => { + localStorage.clear(); + resetTheme(); + }); it('renders the four refresh sections above Theme', () => { render( {}} onResetNavigation={() => {}} />); @@ -93,8 +100,12 @@ describe('AppearanceSection', () => { it('readability locks the header + chart controls and disables the glow slider', () => { const { container } = render( {}} 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 ', () => { @@ -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('[aria-label="Sidebar width"]'); + const sliderThumb = (container: HTMLElement) => + sliderRoot(container)?.querySelector('[role="slider"]'); + + it('renders the sidebar layout rows with Fixed as the default and the width slider locked', () => { + const { container } = render( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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(); + }); +}); diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index d2310dc8..1c394339 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -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', }, diff --git a/frontend/src/hooks/__tests__/use-sidebar-layout.test.ts b/frontend/src/hooks/__tests__/use-sidebar-layout.test.ts index 8d98a6e8..046981e9 100644 --- a/frontend/src/hooks/__tests__/use-sidebar-layout.test.ts +++ b/frontend/src/hooks/__tests__/use-sidebar-layout.test.ts @@ -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); }); diff --git a/frontend/src/hooks/use-sidebar-layout.ts b/frontend/src/hooks/use-sidebar-layout.ts index e04f180a..3bc68f35 100644 --- a/frontend/src/hooks/use-sidebar-layout.ts +++ b/frontend/src/hooks/use-sidebar-layout.ts @@ -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(() => { diff --git a/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts b/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts index 73c63eae..cc90b8fb 100644 --- a/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts +++ b/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts @@ -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); + }); });