--- title: Settings & Theming tags: [design-system, settings, theming] created: 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 ```ts // 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 ```ts // frontend/src/settings/context/use-settings.ts (approx) export interface SettingsContext { state: Settings; canReset: boolean; onReset(): void; onUpdate(key: K, value: Settings[K]): void; onUpdateField(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: 1. Updates localStorage. 2. Triggers a new `buildTheme()` invocation up at `ThemeProvider`. 3. 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 ```tsx // frontend/src/app/layout.tsx (simplified) {children} ``` `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). ```ts // 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'`: ```ts const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const effectiveMode = prefersDark ? 'dark' : 'light'; ``` Subscribe to changes: ```ts 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: 1. Add a `.ts` exporting `{ lighter, light, main, dark, darker, contrastText }`. 2. Register in `colorPresets` map. 3. Add a swatch entry in the drawer's color picker block. --- ## 9. Font preset Switching the font family applies at the theme level: ```ts typography: { fontFamily: settings.fontFamily, ... } ``` If a font isn't bundled, dynamically `import('@fontsource-variable/')` 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: 1. First render uses **defaults** (matches what the server emits → no hydration mismatch). 2. 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