Files
nick-doc/05 - Design System/Settings & Theming.md
Siavash Sameni dceaf82934 audit: 2026-05-30 full-codebase audit — report, issues, docs, runbooks
Full-codebase-audit 2026-05-30 outputs:
- Audit report: 09 - Audits/Full Codebase Audit - 2026-05-30.md
- 81 issue files ISSUE-055..135 (decisions + 1 skipped no-brainer).
- Scanner docs from scratch (was zero): architecture, data model, API ref, payment
  flow, operations runbook + repo README.
- Doc-sync updates across API reference, data models, flows, design system.
- Secret Rotation Runbook (08 - Operations) for the exposed credentials.
- Reusable workflow guide (07 - Development) + .claude/workflows/full-codebase-audit.js.

Issues remain status:open intentionally — the code fixes are uncommitted-then-committed
working-tree changes per repo and aren't "resolved" until merged/deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:48:04 +04:00

6.8 KiB
Raw Permalink Blame History

title, tags, created
title tags created
Settings & Theming
design-system
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 amaneh (warm-earth) — multi-swatch picker removed in v2.7.0 amaneh 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 024 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:

  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

// 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:

  1. Add a <name>.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:

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:

  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.