UX Standards — 15: Appearance Settings Page
Governs: The Appearance / Theme settings page accessible via Settings > Appearance.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Overview
The Appearance settings page provides full control over the application's visual experience. It is a full page (not a Sheet or popup) located at:
/settings/appearance
It is accessed via the Settings submenu in the sidebar. The page automatically saves all changes immediately to a cookie (no explicit Save button needed).
Page Structure
┌───────────────────────────────────────────────────────────┐
│ Appearance [↺ Reset Defaults] │ ← Page header
│ Customize how the application looks... │
├───────────────────────────────────────────────────────────┤
│ [Theme Color Section] │
│ [Color Mode Section] │
│ [Font Size Section] │
│ [Content Density Section] │
│ [Sidebar Style Section] │
│ [Layout Mode Section] │
│ [Email Preview Section] │
│ [Auto-save Note banner] │
└───────────────────────────────────────────────────────────┘
Page outer wrapper:
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">{t('settings.appearance.title')}</h2>
<p className="text-sm text-muted-foreground">{t('settings.appearance.pageDescription')}</p>
</div>
<Button variant="outline" onClick={resetSettings}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('settings.appearance.resetDefaults')}
</Button>
</div>
{/* Sections follow */}
</div>
Section Card Pattern
All settings sections use the same card container:
<div className="rounded-lg border bg-card p-6">
<div className="mb-4 flex items-center gap-2">
<IconName className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">{sectionTitle}</h3>
</div>
<p className="mb-4 text-sm text-muted-foreground">{sectionDescription}</p>
{/* Options */}
</div>
Section 1 — Theme Color (ThemeColorSection)
Icon: Palette h-5 w-5 text-primary
Three color options displayed as horizontal chips:
| Option | Color Swatch | Body Class |
|---|---|---|
| Green | bg-green-500 circle | theme-green |
| Blue | bg-blue-500 circle | theme-blue |
| Amber | bg-amber-500 circle | theme-amber |
// Each color option button:
<button
className={cn(
'flex items-center gap-3 rounded-lg border p-4 transition-colors',
currentColor === value
? 'border-primary bg-primary/5'
: 'hover:border-muted-foreground/50'
)}
>
<div className={cn('h-8 w-8 rounded-full', color)} /> {/* Color circle */}
<span className="font-medium">{label}</span>
{currentColor === value && <Check className="ml-auto h-4 w-4 text-primary" />}
</button>
Active state: border-primary bg-primary/5 + Check icon on the right.
Section 2 — Color Mode (ColorModeSection)
Icon: Sun h-5 w-5 text-primary
Three mode options with icon + label:
| Option | Icon | Value |
|---|---|---|
| Light | Sun | 'light' |
| Dark | Moon | 'dark' |
| System | Monitor | 'system' |
Min width: min-w-[140px] per button.
When changed, calls both updateSettings({ colorMode: mode }) AND setTheme(mode === 'system' ? 'system' : mode) (for next-themes sync).
Default: 'system'
Section 3 — Font Size (FontSizeSection)
Icon: Type h-5 w-5 text-primary
Three size options shown as vertical cards with a text preview:
| Option | Preview Text | Text Class | Body Class |
|---|---|---|---|
| Small | Aa | text-sm font-serif | font-small |
| Default | Aa | text-base font-serif | font-default |
| Large | Aa | text-lg font-serif | font-large |
// Each font size button:
<button
className={cn(
'flex min-w-[100px] flex-col items-center justify-center rounded-lg border p-4 transition-colors',
currentSize === value ? 'border-primary bg-primary/5' : 'hover:border-muted-foreground/50'
)}
>
<span className={cn('mb-1 font-serif', textClass)}>{preview}</span>
<span className="text-sm font-medium">{label}</span>
{currentSize === value && <Check className="mt-1 h-4 w-4 text-primary" />}
</button>
Default: 'default'
Section 4 — Content Density (DensitySection)
Icon: Layout h-5 w-5 text-primary
Three density options in a grid gap-3 sm:grid-cols-3 grid — card style with title + description:
| Option | Description | Body Class |
|---|---|---|
| Compact | Tighter spacing, more content visible | density-compact |
| Comfortable | Balanced spacing for everyday use | density-comfortable |
| Spacious | More breathing room between elements | density-spacious |
// Each density card button:
<button
className={cn(
'flex flex-col items-start rounded-lg border p-4 text-left transition-colors',
currentDensity === value ? 'border-primary bg-primary/5' : 'hover:border-muted-foreground/50'
)}
>
<div className="mb-1 flex w-full items-center justify-between">
<span className="font-medium">{label}</span>
{currentDensity === value && <Check className="h-4 w-4 text-primary" />}
</div>
<span className="text-xs text-muted-foreground">{description}</span>
</button>
Default: 'comfortable'
Section 5 — Sidebar Style (LayoutSection — Sidebar part)
Icon: PanelLeft h-5 w-5 text-primary
Three sidebar styles as horizontal chips (flex flex-wrap gap-3):
| Option | Min Width | Body Class |
|---|---|---|
| Default | 120px | sidebar-default |
| Floating | 120px | sidebar-floating |
| Inset | 120px | sidebar-inset |
<button
className={cn(
'flex min-w-[120px] items-center gap-2 rounded-lg border p-4 transition-colors',
sidebarStyle === value ? 'border-primary bg-primary/5' : 'hover:border-muted-foreground/50'
)}
>
<span className="font-medium">{label}</span>
{sidebarStyle === value && <Check className="ml-auto h-4 w-4 text-primary" />}
</button>
Default: 'default'
Section 6 — Layout Mode (LayoutSection — Layout part)
Icon: Layout h-5 w-5 text-primary
Three layout modes in a grid gap-3 sm:grid-cols-3 grid — card with title + description:
| Option | Description | Body Class |
|---|---|---|
| Default | Full sidebar with labels visible | layout-default |
| Compact | Icon-only sidebar (collapsed) | layout-compact |
| Full | No sidebar — maximum content area | layout-full |
Active/selected state is border-primary bg-primary/5 with Check icon.
Default: 'default'
Layout Mode Effects
| Mode | Sidebar | Content Area |
|---|---|---|
default | Full sidebar (icons + labels) | Normal width |
compact | Icon-only sidebar (collapsed, collapsible="icon") | Slightly wider |
full | No sidebar at all | Full viewport width |
Section 7 — Email Preview Length (EmailPreviewSection)
An app-specific section for controlling email preview length. Uses useEmailPreviewLength hook (user preferences API). This section appears only in apps where email is a core feature.
Auto-Save Note
At the bottom of the page, a muted info banner:
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm text-muted-foreground">
{t('settings.appearance.autoSaveNote')}
</p>
</div>
Communicates to users that changes save automatically (no explicit Save button).
ThemeConfigProvider
The ThemeConfigProvider wraps the entire app (in the root layout) and provides theme settings via useThemeConfig().
// packages/shared/lib/providers/theme-config-provider.tsx
// Types:
export type ThemeColor = 'green' | 'blue' | 'amber';
export type ColorMode = 'light' | 'dark' | 'system';
export type FontSize = 'small' | 'default' | 'large';
export type ContentDensity = 'compact' | 'comfortable' | 'spacious';
export type SidebarPosition = 'left' | 'right';
export type SidebarStyle = 'default' | 'floating' | 'inset';
export type LayoutMode = 'default' | 'compact' | 'full';
export interface ThemeSettings {
themeColor: ThemeColor;
colorMode: ColorMode;
fontSize: FontSize;
contentDensity: ContentDensity;
sidebarPosition: SidebarPosition;
sidebarStyle: SidebarStyle;
layoutMode: LayoutMode;
}
// Defaults:
export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
themeColor: 'green',
colorMode: 'system',
fontSize: 'default',
contentDensity: 'comfortable',
sidebarPosition: 'left',
sidebarStyle: 'default',
layoutMode: 'default'
};
// Hook:
export function useThemeConfig(): ThemeConfigContextValue
export function useThemeConfigSafe(): ThemeConfigContextValue // Safe version — returns defaults if outside provider
Persistence
| Layer | Mechanism | Scope |
|---|---|---|
| Cookie | theme_settings (1-year max-age) | Current device/browser |
| Phase 2 (planned) | Database via POST /api/user/settings | Cross-device sync |
Changes save to cookie immediately via updateSettings(). No debounce needed for cookie.
Reset: resetSettings() restores DEFAULT_THEME_SETTINGS and clears cookie to defaults.
Body Class Application
When settings change, applySettingsToBody() removes old classes and applies new ones:
body.classList.add(`theme-${settings.themeColor}`); // theme-green | theme-blue | theme-amber
body.classList.add(`font-${settings.fontSize}`); // font-small | font-default | font-large
body.classList.add(`density-${settings.contentDensity}`); // density-compact | density-comfortable | density-spacious
body.classList.add(`sidebar-${settings.sidebarStyle}`); // sidebar-default | sidebar-floating | sidebar-inset
body.classList.add(`layout-${settings.layoutMode}`); // layout-default | layout-compact | layout-full
Note: colorMode is NOT applied as a body class here — it is handled separately by next-themes (class strategy).
Navigation to This Page
The Appearance settings page is accessed via:
- Sidebar navigation → Settings submenu → Appearance
- Header User Menu → Settings → navigates to
/settings/appearance
The sidebar Settings item expands to show submenu links including Appearance, Profile, Notifications, etc.
Violation Checklist
- Each section wrapped in
rounded-lg border bg-card p-6 - Section header: icon (
h-5 w-5 text-primary) + title (text-lg font-semibold) - Section description:
text-sm text-muted-foreground - Active option:
border-primary bg-primary/5+Check h-4 w-4 text-primary - Inactive option:
hover:border-muted-foreground/50 - Theme color options show colored circle swatch (
h-8 w-8 rounded-full) - Font size options show
Aapreview withfont-serifin correct size - Density and Layout modes use
sm:grid-cols-3grid with description text - Reset button:
variant="outline"+RotateCcwicon - Auto-save note at bottom:
rounded-lg border bg-muted/50 p-4 - No explicit Save button — all changes save immediately
-
useThemeConfig()hook used for reading/updating settings -
updateSettings()called on every change (partial ThemeSettings object)