Skip to main content

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:

OptionColor SwatchBody Class
Greenbg-green-500 circletheme-green
Bluebg-blue-500 circletheme-blue
Amberbg-amber-500 circletheme-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:

OptionIconValue
LightSun'light'
DarkMoon'dark'
SystemMonitor'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:

OptionPreview TextText ClassBody Class
SmallAatext-sm font-seriffont-small
DefaultAatext-base font-seriffont-default
LargeAatext-lg font-seriffont-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:

OptionDescriptionBody Class
CompactTighter spacing, more content visibledensity-compact
ComfortableBalanced spacing for everyday usedensity-comfortable
SpaciousMore breathing room between elementsdensity-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):

OptionMin WidthBody Class
Default120pxsidebar-default
Floating120pxsidebar-floating
Inset120pxsidebar-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:

OptionDescriptionBody Class
DefaultFull sidebar with labels visiblelayout-default
CompactIcon-only sidebar (collapsed)layout-compact
FullNo sidebar — maximum content arealayout-full

Active/selected state is border-primary bg-primary/5 with Check icon.

Default: 'default'

Layout Mode Effects

ModeSidebarContent Area
defaultFull sidebar (icons + labels)Normal width
compactIcon-only sidebar (collapsed, collapsible="icon")Slightly wider
fullNo sidebar at allFull 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

LayerMechanismScope
Cookietheme_settings (1-year max-age)Current device/browser
Phase 2 (planned)Database via POST /api/user/settingsCross-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).


The Appearance settings page is accessed via:

  1. Sidebar navigation → Settings submenu → Appearance
  2. 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 Aa preview with font-serif in correct size
  • Density and Layout modes use sm:grid-cols-3 grid with description text
  • Reset button: variant="outline" + RotateCcw icon
  • 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)