diff --git a/client/src/components/Settings/AppearanceSettingsTab.tsx b/client/src/components/Settings/AppearanceSettingsTab.tsx index 586617dff..f12b0eeb1 100644 --- a/client/src/components/Settings/AppearanceSettingsTab.tsx +++ b/client/src/components/Settings/AppearanceSettingsTab.tsx @@ -1,45 +1,15 @@ -import React, { useEffect, useRef, useState, type ReactNode } from 'react' +import React, { type ReactNode } from 'react' import { Paintbrush, Eye, LayoutDashboard, Sun, Moon, Monitor, RotateCcw } from 'lucide-react' import { useTranslation } from '../../i18n' -import { useSettingsStore } from '../../store/settingsStore' -import { useToast } from '../shared/Toast' import { DialogSection, fs } from '../shared/DialogShell' import { Segmented } from '../shared/dialogParts' import Section from './Section' import ToggleSwitch from './ToggleSwitch' import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit' -import { applyAppearance } from '../../theme/applyAppearance' import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes' -import { - DEFAULT_APPEARANCE, - normalizeAppearance, - APPEARANCE_SCALE_MIN, - APPEARANCE_SCALE_MAX, - type AppearanceConfig, -} from '@trek/shared' - -// ── WCAG contrast helpers (for the custom-accent legibility hint) ──────────── -function channelLum(v: number): number { - return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) -} -function relLuminance(hex: string): number { - const c = hex.replace('#', '') - const full = c.length === 3 ? c.split('').map((x) => x + x).join('') : c - const r = channelLum(Number.parseInt(full.slice(0, 2), 16) / 255) - const g = channelLum(Number.parseInt(full.slice(2, 4), 16) / 255) - const b = channelLum(Number.parseInt(full.slice(4, 6), 16) / 255) - return 0.2126 * r + 0.7152 * g + 0.0722 * b -} -function contrastRatio(a: string, b: string): number { - const la = relLuminance(a) - const lb = relLuminance(b) - const [hi, lo] = la > lb ? [la, lb] : [lb, la] - return (hi + 0.05) / (lo + 0.05) -} -const isHex = (v: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v) - -type DesktopWidgetKey = keyof AppearanceConfig['dashboard']['desktop'] -type MobileWidgetKey = keyof AppearanceConfig['dashboard']['mobile'] +import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared' +import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel' +import { useAppearanceEditor } from './useAppearanceEditor' const WIDGET_LABELS: Record = { sidebar: 'Right sidebar', @@ -52,17 +22,6 @@ const WIDGET_LABELS: Record = { daysTraveled: 'Days traveled', distanceFlown: 'Distance flown', } -// Grouped by where the widgets actually sit on the dashboard. The right sidebar -// has a master toggle (off → no sidebar, layout centers); its individual -// widgets only matter while the sidebar is shown. -const DESKTOP_GROUPS: { id: string; fallback: string; master?: DesktopWidgetKey; keys: DesktopWidgetKey[] }[] = [ - { id: 'belowHero', fallback: 'Below the hero', keys: ['atlas', 'tripsTotal', 'daysTraveled', 'distanceFlown'] }, - { id: 'rightSidebar', fallback: 'Right sidebar', master: 'sidebar', keys: ['currency', 'collections', 'timezones', 'upcomingReservations'] }, -] -const MOBILE_GROUPS: { id: string; fallback: string; keys: MobileWidgetKey[] }[] = [ - { id: 'belowHero', fallback: 'Below the hero', keys: ['tripsTotal', 'daysTraveled'] }, - { id: 'bottomOfPage', fallback: 'Bottom of page', keys: ['currency', 'collections', 'timezones', 'upcomingReservations'] }, -] /** A tile of the scheme grid: a colour dot and a name, raised and outlined while chosen. */ const SWATCH = 'flex min-w-0 items-center gap-2 rounded-[12px] border bg-surface-card px-3 py-2.5 text-start font-medium text-content transition-colors' @@ -74,75 +33,13 @@ const swatchLook = (active: boolean) => active const DOT = 'h-4 w-4 flex-none rounded-full shadow-[inset_0_0_0_1px_var(--border-faint)]' export default function AppearanceSettingsTab(): React.ReactElement { - const { settings, updateSetting } = useSettingsStore() const { t } = useTranslation() - const toast = useToast() const tr = (key: string, fallback: string) => t(key) || fallback - - const [cfg, setCfg] = useState(() => normalizeAppearance(settings.appearance)) - const persistTimer = useRef(undefined) - // What the pending timer would have written, so leaving the tab inside the - // debounce window still saves instead of silently dropping the change. - const pendingWrite = useRef(null) - - // Re-sync when settings change elsewhere (e.g. server reconcile / another tab). - useEffect(() => { - setCfg(normalizeAppearance(settings.appearance)) - }, [settings.appearance]) - - // Flush any pending persist on unmount. - useEffect(() => () => { - if (!persistTimer.current) return - window.clearTimeout(persistTimer.current) - // The component is gone, so a failure has nowhere to be shown. - if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {}) - }, [updateSetting]) - - const isDark = - settings.dark_mode === true || - settings.dark_mode === 'dark' || - (settings.dark_mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) - - // Live preview now (DOM), persist after a short debounce (API). - const update = (patch: Partial) => { - const next = { ...cfg, ...patch } - setCfg(next) - applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false }) - if (persistTimer.current) window.clearTimeout(persistTimer.current) - pendingWrite.current = next - persistTimer.current = window.setTimeout(() => { - pendingWrite.current = null - updateSetting('appearance', next).catch((e: unknown) => - toast.error(e instanceof Error ? e.message : t('common.error')) - ) - }, 350) - } - - const setMode = async (mode: string) => { - try { - await updateSetting('dark_mode', mode) - } catch (e: unknown) { - toast.error(e instanceof Error ? e.message : t('common.error')) - } - } - - const setWidget = (device: 'desktop' | 'mobile', key: string, on: boolean) => { - update({ - dashboard: { - ...cfg.dashboard, - [device]: { ...cfg.dashboard[device], [key]: on }, - }, - }) - } - - const resetAll = () => update({ ...DEFAULT_APPEARANCE }) - - const accentLight = cfg.accent?.light ?? '#4f46e5' - const accentDark = cfg.accent?.dark ?? '#6366f1' - const customRatio = contrastRatio(isDark ? accentDark : accentLight, '#ffffff') + const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } = + useAppearanceEditor() // The stored mode, read the way the old boolean values meant it. - const cur = settings.dark_mode + const cur = darkMode const mode = cur === true ? 'dark' : cur === false ? 'light' : String(cur ?? '') return ( diff --git a/client/src/components/Settings/appearanceModel.ts b/client/src/components/Settings/appearanceModel.ts new file mode 100644 index 000000000..c50c9e194 --- /dev/null +++ b/client/src/components/Settings/appearanceModel.ts @@ -0,0 +1,53 @@ +import type { AppearanceConfig } from '@trek/shared'; + +/** The appearance editor's pure parts, shared by the desktop tab and the phone section. */ + +// WCAG contrast helpers, for the custom accent legibility hint. +function channelLum(v: number): number { + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); +} +function relLuminance(hex: string): number { + const c = hex.replace('#', ''); + const full = + c.length === 3 + ? c + .split('') + .map((x) => x + x) + .join('') + : c; + const r = channelLum(Number.parseInt(full.slice(0, 2), 16) / 255); + const g = channelLum(Number.parseInt(full.slice(2, 4), 16) / 255); + const b = channelLum(Number.parseInt(full.slice(4, 6), 16) / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +export function contrastRatio(a: string, b: string): number { + const la = relLuminance(a); + const lb = relLuminance(b); + const [hi, lo] = la > lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} +export const isHex = (v: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v); + +type DesktopWidgetKey = keyof AppearanceConfig['dashboard']['desktop']; +type MobileWidgetKey = keyof AppearanceConfig['dashboard']['mobile']; + +// Grouped by where the widgets actually sit on the dashboard. The right sidebar +// has a master toggle (off: no sidebar, layout centers); its individual +// widgets only matter while the sidebar is shown. +export const DESKTOP_GROUPS: { id: string; fallback: string; master?: DesktopWidgetKey; keys: DesktopWidgetKey[] }[] = [ + { id: 'belowHero', fallback: 'Below the hero', keys: ['atlas', 'tripsTotal', 'daysTraveled', 'distanceFlown'] }, + { + id: 'rightSidebar', + fallback: 'Right sidebar', + master: 'sidebar', + keys: ['currency', 'collections', 'timezones', 'upcomingReservations'], + }, +]; +export const MOBILE_GROUPS: { id: string; fallback: string; keys: MobileWidgetKey[] }[] = [ + { id: 'belowHero', fallback: 'Below the hero', keys: ['tripsTotal', 'daysTraveled'] }, + { + id: 'bottomOfPage', + fallback: 'Bottom of page', + keys: ['currency', 'collections', 'timezones', 'upcomingReservations'], + }, +]; diff --git a/client/src/components/Settings/useAppearanceEditor.test.ts b/client/src/components/Settings/useAppearanceEditor.test.ts new file mode 100644 index 000000000..337c38be3 --- /dev/null +++ b/client/src/components/Settings/useAppearanceEditor.test.ts @@ -0,0 +1,116 @@ +// FE-COMP-APPEARANCE-EDITOR-001 onwards: the appearance editor behind both settings shells. +import { act, renderHook } from '@testing-library/react'; +import { DEFAULT_APPEARANCE, normalizeAppearance } from '@trek/shared'; + +import { buildSettings } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useSettingsStore } from '../../store/settingsStore'; +import { applyAppearance } from '../../theme/applyAppearance'; +import type { Settings } from '../../types'; +import { contrastRatio, DESKTOP_GROUPS, isHex, MOBILE_GROUPS } from './appearanceModel'; +import { useAppearanceEditor } from './useAppearanceEditor'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock('../../theme/applyAppearance', () => ({ applyAppearance: vi.fn() })); + +const updateSetting = vi.fn(); + +function seed(over: Partial = {}) { + seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false, ...over }), updateSetting }); +} + +beforeEach(() => { + resetAllStores(); + vi.useFakeTimers(); + toast.error.mockReset(); + updateSetting.mockReset().mockResolvedValue(undefined); + vi.mocked(applyAppearance).mockClear(); +}); +afterEach(() => vi.useRealTimers()); + +describe('appearanceModel', () => { + it('FE-COMP-APPEARANCE-EDITOR-001: contrast, hex check and widget groups', () => { + expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5); + expect(contrastRatio('#fff', '#ffffff')).toBeCloseTo(1, 5); + expect(isHex('#abc')).toBe(true); + expect(isHex('#a1b2c3')).toBe(true); + expect(isHex('abc')).toBe(false); + expect(DESKTOP_GROUPS.map((g) => g.master)).toEqual([undefined, 'sidebar']); + expect(MOBILE_GROUPS.map((g) => g.id)).toEqual(['belowHero', 'bottomOfPage']); + }); +}); + +describe('useAppearanceEditor', () => { + it('FE-COMP-APPEARANCE-EDITOR-002: seeds from the stored appearance and derives the accent hint', () => { + seed({ dark_mode: 'dark' }); + const { result } = renderHook(() => useAppearanceEditor()); + expect(result.current.cfg).toEqual(normalizeAppearance(useSettingsStore.getState().settings.appearance)); + expect(result.current.darkMode).toBe('dark'); + expect(result.current.isDark).toBe(true); + expect(result.current.accentLight).toBe('#4f46e5'); + expect(result.current.accentDark).toBe('#6366f1'); + expect(result.current.customRatio).toBeCloseTo(contrastRatio('#6366f1', '#ffffff'), 5); + }); + + it('FE-COMP-APPEARANCE-EDITOR-003: a change previews at once and persists once after the debounce', () => { + seed(); + const { result } = renderHook(() => useAppearanceEditor()); + act(() => result.current.update({ density: 'compact' })); + act(() => result.current.update({ transparency: false })); + expect(result.current.cfg.density).toBe('compact'); + expect(applyAppearance).toHaveBeenCalledTimes(2); + expect(updateSetting).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(350)); + expect(updateSetting).toHaveBeenCalledTimes(1); + expect(updateSetting).toHaveBeenCalledWith( + 'appearance', + expect.objectContaining({ density: 'compact', transparency: false }) + ); + }); + + it('FE-COMP-APPEARANCE-EDITOR-004: a failed persist shows the error', async () => { + seed(); + updateSetting.mockRejectedValue(new Error('nope')); + const { result } = renderHook(() => useAppearanceEditor()); + act(() => result.current.update({ reduceMotion: true })); + await act(async () => { + vi.advanceTimersByTime(350); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + expect(toast.error).toHaveBeenCalledWith('nope'); + }); + + it('FE-COMP-APPEARANCE-EDITOR-005: leaving inside the debounce still writes the pending change', () => { + seed(); + const { result, unmount } = renderHook(() => useAppearanceEditor()); + act(() => result.current.update({ fontScale: 1.2 })); + unmount(); + expect(updateSetting).toHaveBeenCalledWith('appearance', expect.objectContaining({ fontScale: 1.2 })); + }); + + it('FE-COMP-APPEARANCE-EDITOR-006: widgets toggle per device and reset goes back to the defaults', () => { + seed(); + const { result } = renderHook(() => useAppearanceEditor()); + const mobileBefore = result.current.cfg.dashboard.mobile; + act(() => result.current.setWidget('desktop', 'sidebar', false)); + expect(result.current.cfg.dashboard.desktop.sidebar).toBe(false); + expect(result.current.cfg.dashboard.mobile).toEqual(mobileBefore); + const before = result.current.cfg; + act(() => result.current.resetAll()); + expect(result.current.cfg).toEqual({ ...before, ...DEFAULT_APPEARANCE }); + expect(result.current.cfg.dashboard.desktop.sidebar).toBe(DEFAULT_APPEARANCE.dashboard.desktop.sidebar); + }); + + it('FE-COMP-APPEARANCE-EDITOR-007: the colour mode is saved straight away, with the error on failure', async () => { + seed(); + const { result } = renderHook(() => useAppearanceEditor()); + await act(() => result.current.setMode('dark')); + expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark'); + updateSetting.mockRejectedValueOnce('x'); + await act(() => result.current.setMode('light')); + expect(toast.error).toHaveBeenCalledWith('common.error'); + }); +}); diff --git a/client/src/components/Settings/useAppearanceEditor.ts b/client/src/components/Settings/useAppearanceEditor.ts new file mode 100644 index 000000000..f144625fd --- /dev/null +++ b/client/src/components/Settings/useAppearanceEditor.ts @@ -0,0 +1,94 @@ +import { DEFAULT_APPEARANCE, normalizeAppearance, type AppearanceConfig } from '@trek/shared'; +import { useEffect, useRef, useState } from 'react'; + +import { useTranslation } from '../../i18n'; +import { useSettingsStore } from '../../store/settingsStore'; +import { applyAppearance } from '../../theme/applyAppearance'; +import { useToast } from '../shared/Toast'; +import { contrastRatio } from './appearanceModel'; +import { useSettingSaver } from './useSettingSaver'; + +/** + * The appearance editor behind both settings shells (the desktop tab and the phone + * section render their own markup over this): every change previews on the page at + * once and is persisted after a short debounce, and a change still pending when the + * editor goes away is written on the way out. + */ +export function useAppearanceEditor() { + const { settings, updateSetting } = useSettingsStore(); + const { t } = useTranslation(); + const toast = useToast(); + const { save } = useSettingSaver(); + + const [cfg, setCfg] = useState(() => normalizeAppearance(settings.appearance)); + const persistTimer = useRef(undefined); + // What the pending timer would have written, so leaving the editor inside the + // debounce window still saves instead of silently dropping the change. + const pendingWrite = useRef(null); + + // Re-sync when settings change elsewhere (e.g. server reconcile / another tab). + useEffect(() => { + setCfg(normalizeAppearance(settings.appearance)); + }, [settings.appearance]); + + // Flush any pending persist on unmount. + useEffect( + () => () => { + if (!persistTimer.current) return; + window.clearTimeout(persistTimer.current); + // The component is gone, so a failure has nowhere to be shown. + if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {}); + }, + [updateSetting] + ); + + const isDark = + settings.dark_mode === true || + settings.dark_mode === 'dark' || + (settings.dark_mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches); + + // Live preview now (DOM), persist after a short debounce (API). + const update = (patch: Partial) => { + const next = { ...cfg, ...patch }; + setCfg(next); + applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false }); + if (persistTimer.current) window.clearTimeout(persistTimer.current); + pendingWrite.current = next; + persistTimer.current = window.setTimeout(() => { + pendingWrite.current = null; + updateSetting('appearance', next).catch((e: unknown) => + toast.error(e instanceof Error ? e.message : t('common.error')) + ); + }, 350); + }; + + const setMode = (mode: string) => save('dark_mode', mode); + + const setWidget = (device: 'desktop' | 'mobile', key: string, on: boolean) => { + update({ + dashboard: { + ...cfg.dashboard, + [device]: { ...cfg.dashboard[device], [key]: on }, + }, + }); + }; + + const resetAll = () => update({ ...DEFAULT_APPEARANCE }); + + const accentLight = cfg.accent?.light ?? '#4f46e5'; + const accentDark = cfg.accent?.dark ?? '#6366f1'; + const customRatio = contrastRatio(isDark ? accentDark : accentLight, '#ffffff'); + + return { + cfg, + darkMode: settings.dark_mode, + isDark, + update, + setMode, + setWidget, + resetAll, + accentLight, + accentDark, + customRatio, + }; +} diff --git a/client/src/mobile/screens/settings/MSettingsAppearance.tsx b/client/src/mobile/screens/settings/MSettingsAppearance.tsx index 6a7a7f689..1d1d68a03 100644 --- a/client/src/mobile/screens/settings/MSettingsAppearance.tsx +++ b/client/src/mobile/screens/settings/MSettingsAppearance.tsx @@ -1,54 +1,15 @@ -import React, { useEffect, useRef, useState } from 'react' +import React from 'react' import { Eye, LayoutDashboard, Paintbrush, RotateCcw, Smartphone } from 'lucide-react' import { useTranslation } from '../../../i18n' -import { useSettingsStore } from '../../../store/settingsStore' -import { useToast } from '../../../components/shared/Toast' -import { applyAppearance } from '../../../theme/applyAppearance' import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../../theme/schemes' -import { - DEFAULT_APPEARANCE, - normalizeAppearance, - APPEARANCE_SCALE_MIN, - APPEARANCE_SCALE_MAX, - type AppearanceConfig, -} from '@trek/shared' +import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX, type AppearanceConfig } from '@trek/shared' +import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from '../../../components/Settings/appearanceModel' +import { useAppearanceEditor } from '../../../components/Settings/useAppearanceEditor' import MToggle from '../../components/MToggle' import { MSetCard, MSetEyebrow, MSetSegments, MSetRow } from './MSettingsUi' import MMobileNavCustomizer from './MMobileNavCustomizer' import MMobileDashOrder from './MMobileDashOrder' -// ── WCAG contrast helpers (custom-accent legibility hint) ──────────────────── -function channelLum(v: number): number { - return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) -} -function relLuminance(hex: string): number { - const c = hex.replace('#', '') - const full = c.length === 3 ? c.split('').map((x) => x + x).join('') : c - const r = channelLum(Number.parseInt(full.slice(0, 2), 16) / 255) - const g = channelLum(Number.parseInt(full.slice(2, 4), 16) / 255) - const b = channelLum(Number.parseInt(full.slice(4, 6), 16) / 255) - return 0.2126 * r + 0.7152 * g + 0.0722 * b -} -function contrastRatio(a: string, b: string): number { - const la = relLuminance(a) - const lb = relLuminance(b) - const [hi, lo] = la > lb ? [la, lb] : [lb, la] - return (hi + 0.05) / (lo + 0.05) -} -const isHex = (v: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v) - -type DesktopWidgetKey = keyof AppearanceConfig['dashboard']['desktop'] -type MobileWidgetKey = keyof AppearanceConfig['dashboard']['mobile'] - -const DESKTOP_GROUPS: { id: string; master?: DesktopWidgetKey; keys: DesktopWidgetKey[] }[] = [ - { id: 'belowHero', keys: ['atlas', 'tripsTotal', 'daysTraveled', 'distanceFlown'] }, - { id: 'rightSidebar', master: 'sidebar', keys: ['currency', 'collections', 'timezones', 'upcomingReservations'] }, -] -const MOBILE_GROUPS: { id: string; keys: MobileWidgetKey[] }[] = [ - { id: 'belowHero', keys: ['tripsTotal', 'daysTraveled'] }, - { id: 'bottomOfPage', keys: ['currency', 'collections', 'timezones', 'upcomingReservations'] }, -] - function SliderRow({ label, sub, value, onChange }: { label: string; sub?: string; value: number; onChange: (v: number) => void }) { return (
@@ -79,72 +40,12 @@ function SliderRow({ label, sub, value, onChange }: { label: string; sub?: strin * density, text sizes) and the per-device dashboard widget configuration. */ export default function MSettingsAppearance() { - const { settings, updateSetting } = useSettingsStore() const { t } = useTranslation() - const toast = useToast() + const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } = + useAppearanceEditor() - const [cfg, setCfg] = useState(() => normalizeAppearance(settings.appearance)) - const persistTimer = useRef(undefined) - // What the pending timer would have written, so leaving the screen inside the - // debounce window still saves instead of silently dropping the change. - const pendingWrite = useRef(null) - - // Re-sync when settings change elsewhere (server reconcile / another tab). - useEffect(() => { - setCfg(normalizeAppearance(settings.appearance)) - }, [settings.appearance]) - - useEffect(() => () => { - if (!persistTimer.current) return - window.clearTimeout(persistTimer.current) - // The component is gone, so a failure has nowhere to be shown. - if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {}) - }, [updateSetting]) - - const isDark = - settings.dark_mode === true || - settings.dark_mode === 'dark' || - (settings.dark_mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) - - // Live preview now (DOM), persist after a short debounce (API). - const update = (patch: Partial) => { - const next = { ...cfg, ...patch } - setCfg(next) - applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false }) - if (persistTimer.current) window.clearTimeout(persistTimer.current) - pendingWrite.current = next - persistTimer.current = window.setTimeout(() => { - pendingWrite.current = null - updateSetting('appearance', next).catch((e: unknown) => - toast.error(e instanceof Error ? e.message : t('common.error')), - ) - }, 350) - } - - const setMode = async (mode: string) => { - try { - await updateSetting('dark_mode', mode) - } catch (e: unknown) { - toast.error(e instanceof Error ? e.message : t('common.error')) - } - } - - const setWidget = (device: 'desktop' | 'mobile', key: string, on: boolean) => { - update({ - dashboard: { - ...cfg.dashboard, - [device]: { ...cfg.dashboard[device], [key]: on }, - }, - }) - } - - const darkMode = settings.dark_mode const modeValue = darkMode === true || darkMode === 'dark' ? 'dark' : darkMode === 'auto' ? 'auto' : 'light' - const accentLight = cfg.accent?.light ?? '#4f46e5' - const accentDark = cfg.accent?.dark ?? '#6366f1' - const customRatio = contrastRatio(isDark ? accentDark : accentLight, '#ffffff') - const widgetToggle = (device: 'desktop' | 'mobile', key: string, on: boolean, disabled = false) => ( update({ ...DEFAULT_APPEARANCE })} + onClick={resetAll} className="mt-3 flex items-center gap-2 px-1 py-[6px] text-[0.78125rem] font-semibold text-m-muted" >