6.8 KiB
title, tags, created
| title | tags | created | |||
|---|---|---|---|---|---|
| Settings & Theming |
|
2026-05-23 |
Settings & Theming
A drawer-based UI lets the end user toggle visual preferences. Settings persist in localStorage and rebuild the MUI theme on the fly.
[!info] Implementation:
frontend/src/settings/context/(state) +frontend/src/settings/drawer/(UI) +frontend/src/components/settings/(helpers).
1. What's user-controllable
| Axis | Values | Default | Persisted |
|---|---|---|---|
| Mode | light · dark · system |
system |
localStorage |
| Contrast | default · bold |
default |
localStorage |
| Layout | vertical · mini · horizontal |
vertical |
localStorage |
| Direction | ltr · rtl |
derived from locale | localStorage (overrides locale default) |
| Color preset | one of default, purple, cyan, blue, orange, red |
default |
localStorage |
| Font family | Public Sans Variable, DM Sans Variable, Inter Variable, Nunito Sans Variable |
Public Sans Variable |
localStorage |
| Compact navigation | boolean | false |
localStorage |
| Border radius | 0–24 | 8 | localStorage |
| Stretched container | boolean | false |
localStorage |
2. State shape
// frontend/src/settings/types.ts (approx)
export interface Settings {
mode: 'light' | 'dark' | 'system';
contrast: 'default' | 'bold';
layout: 'vertical' | 'mini' | 'horizontal';
direction: 'ltr' | 'rtl';
colorPreset: string;
fontFamily: string;
compactLayout: boolean;
primaryColor?: string;
stretch: boolean;
}
Stored in a single localStorage key (settings or settings-key). The context provider hydrates on mount, falls back to defaults if absent.
3. Context API
// frontend/src/settings/context/use-settings.ts (approx)
export interface SettingsContext {
state: Settings;
canReset: boolean;
onReset(): void;
onUpdate<K extends keyof Settings>(key: K, value: Settings[K]): void;
onUpdateField<K extends keyof Settings>(key: K, value: Settings[K]): void;
openDrawer: boolean;
onToggleDrawer(): void;
}
const { state, onUpdate, onToggleDrawer } = useSettings();
Use onUpdate to change any value. The context fires a re-render that:
- Updates localStorage.
- Triggers a new
buildTheme()invocation up atThemeProvider. - Components re-render with the new theme tokens.
4. Drawer UI
The drawer (settings/drawer/SettingsDrawer.tsx) is a right-side MuiDrawer with sections:
┌──────────────────────────────┐
│ Settings [reset] │
├──────────────────────────────┤
│ Mode [☀] [🌙] [⌐] │
│ │
│ Contrast ◯ Default ◯ Bold │
│ │
│ Direction ⬅ LTR ➡ RTL │
│ │
│ Layout ▤ Vertical │
│ ▣ Mini │
│ ━ Horizontal │
│ │
│ Color [●●●●●●] │
│ │
│ Font [Public Sans ▼] │
│ │
│ Border radius [—•—] 8 │
└──────────────────────────────┘
Each section uses the BlockOption helper component for consistent styling.
5. Wiring at the root
// frontend/src/app/layout.tsx (simplified)
<SettingsProvider defaultSettings={defaults}>
<ThemeProviderFromSettings>
{children}
<SettingsDrawer />
</ThemeProviderFromSettings>
</SettingsProvider>
ThemeProviderFromSettings reads the settings context and rebuilds the theme on every change.
6. Locale ↔ direction coupling
By default, switching to fa / ar flips direction: 'rtl'. The user CAN override (a Persian-speaker on a desktop might prefer LTR sometimes).
// In a settings change handler:
onUpdate('direction', i18n.language === 'fa' ? 'rtl' : 'ltr');
Or let the user pin direction independently — saved direction wins over locale-derived direction.
7. Mode = system
When mode === 'system':
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const effectiveMode = prefersDark ? 'dark' : 'light';
Subscribe to changes:
useEffect(() => {
if (settings.mode !== 'system') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => forceUpdate();
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [settings.mode]);
8. Color presets
Defined in frontend/src/theme/options/presets/ or similar. Each preset only swaps primary (and optionally secondary) — grey, semantic colors, and background stay constant so layouts feel consistent across presets.
Adding a preset:
- Add a
<name>.tsexporting{ lighter, light, main, dark, darker, contrastText }. - Register in
colorPresetsmap. - Add a swatch entry in the drawer's color picker block.
9. Font preset
Switching the font family applies at the theme level:
typography: { fontFamily: settings.fontFamily, ... }
If a font isn't bundled, dynamically import('@fontsource-variable/<name>') first to avoid FOUC.
10. Compact / stretched layout
| Setting | Effect |
|---|---|
compactLayout: true |
Reduces top spacing / padding on dashboard pages |
stretch: true |
Removes the centered max-width on content (full-bleed) |
11. Border radius
Slider exposed as 0 → 24. Affects theme.shape.borderRadius, cascading to every component using sx={{ borderRadius: 1 }} semantics.
12. Reset to defaults
onReset() clears localStorage and re-hydrates with the default object. canReset is true when current state differs from defaults — used to enable/disable the Reset button.
13. Hydration mismatch hazard
Because settings live in localStorage, the server render can't know them. The provider implements a 2-pass strategy:
- First render uses defaults (matches what the server emits → no hydration mismatch).
- After mount, the provider reads localStorage and re-renders with the user's settings.
Brief flash possible. To mitigate, either:
- Suppress the first paint (split layout into client-only).
- Or set the user's settings into a cookie at sign-in so server can pre-render correctly.
14. Related
- Design System Overview · Theme Configuration · Colors · Typography
- Layouts — layout variants drive sidebar
- Internationalization & RTL — direction coupling
- Frontend Architecture — provider tree