mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(settings): share the appearance editor between desktop and phone
The live preview, the debounced save with its flush on leave, the colour mode save and the widget toggles live in one useAppearanceEditor hook. The contrast helpers and widget groups move to appearanceModel.
This commit is contained in:
@@ -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<string, string> = {
|
||||
sidebar: 'Right sidebar',
|
||||
@@ -52,17 +22,6 @@ const WIDGET_LABELS: Record<string, string> = {
|
||||
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<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
|
||||
const persistTimer = useRef<number | undefined>(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<AppearanceConfig | null>(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<AppearanceConfig>) => {
|
||||
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 (
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
@@ -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<Settings> = {}) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<AppearanceConfig>(() => normalizeAppearance(settings.appearance));
|
||||
const persistTimer = useRef<number | undefined>(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<AppearanceConfig | null>(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<AppearanceConfig>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="py-[6px]">
|
||||
@@ -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<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
|
||||
const persistTimer = useRef<number | undefined>(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<AppearanceConfig | null>(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<AppearanceConfig>) => {
|
||||
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) => (
|
||||
<MSetRow
|
||||
key={`${device}-${key}`}
|
||||
@@ -366,7 +267,7 @@ export default function MSettingsAppearance() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
|
||||
Reference in New Issue
Block a user