Files
immich-folio/docs/dev/theming-system.md

5.9 KiB
Raw Blame History

Theming System — Feature Plan

Make the app themeable so photographers with different aesthetic preferences can fully customize the look without touching component code.

Current State

The CSS architecture is already 80% of the way there:

  • Design tokens in tokens.css — colors, fonts, radii, transitions, spacing
  • Dark/light toggle via [data-theme] attribute
  • All components reference tokens exclusively (var(--text-primary), var(--font-serif), etc.)

What's NOT themeable yet:

  • The "Studio/Leica" aesthetic is hardcoded into globals.css (lines 607–783) — red dot, film grain, passepartout borders, uppercase titles, sharp corners
  • Header glass effect colors are hardcoded (rgba(26, 26, 26, 0.9))
  • Font imports are hardcoded in tokens.css
  • The OG image generator has hardcoded colors

Proposed Architecture

Theme as a YAML config

Add a theme section to gallery.yaml. Photographers configure their visual identity alongside their gallery structure — no CSS editing required.

# gallery.yaml

theme: editorial   # built-in preset (or "studio", "minimal", "classic")

# OR fully custom:
theme:
  preset: minimal                         # start from a base
  accent: "#2563eb"                       # brand color
  fonts:
    heading: "Inter"                      # Google Fonts name
    body: "Inter"
    caption: "JetBrains Mono"
  radius: 8                               # border-radius in px (0 = sharp)
  photoFrame: none                        # "none" | "passepartout" | "shadow"
  grain: false                            # film grain overlay
  headerDot: false                        # red dot in nav
  heroStyle: split                        # "split" | "fullbleed" | "minimal"

How it works

gallery.yaml (theme config)
       │
       ▼
  getConfig()  ← reads theme key
       │
       ▼
  RootLayout   ← injects CSS custom properties as inline style on <html>
       │         + conditionally loads Google Fonts <link>
       ▼
  tokens.css   ← contains fallback defaults only
       │
       ▼
  globals.css  ← component styles use var() tokens
                  conditional classes for optional features

Implementation Layers

Layer 1: Extract theme tokens from config

Files: lib/config.ts, app/layout.tsx

  1. Add ThemeConfig type to config.ts:
interface ThemeConfig {
  accent: string;
  fonts: { heading: string; body: string; caption: string };
  radius: number;
  photoFrame: 'none' | 'passepartout' | 'shadow';
  grain: boolean;
  headerDot: boolean;
  heroStyle: 'split' | 'fullbleed' | 'minimal';
}
  1. In layout.tsx, inject theme values as CSS custom properties:
<html style={{
  '--accent': theme.accent,
  '--font-serif': `'${theme.fonts.heading}', Georgia, serif`,
  '--font-sans': `'${theme.fonts.body}', system-ui, sans-serif`,
  '--radius-sm': `${theme.radius}px`,
  '--radius-md': `${theme.radius * 1.5}px`,
}}>
  1. Dynamically build the Google Fonts <link> URL from the font names.

Layer 2: Make component CSS conditional

Files: globals.css, tokens.css

  1. Move the "Studio-Inspired Defaults" block (lines 607–783) into a separate file themes/studio.css
  2. Create themes/minimal.css, themes/classic.css, themes/editorial.css with different aesthetic overrides
  3. The base globals.css becomes theme-agnostic — clean, neutral component styles
  4. Conditional features controlled by data attributes:
/* Only show if theme.grain is true */
[data-grain="true"] .photo-grid__item::after { /* grain overlay */ }

/* Only show if theme.headerDot is true */
[data-header-dot="true"] .header__nav::before { /* red dot */ }

/* Passepartout frame */
[data-photo-frame="passepartout"] .photo-grid__item { padding: 8px; border: 1px solid ... }

/* Shadow frame */
[data-photo-frame="shadow"] .photo-grid__item { box-shadow: 0 2px 20px rgba(0,0,0,0.3); }

Layer 3: Built-in presets

Ship 4 presets that photographers can pick with a single word:

Preset Aesthetic Accent Fonts Radius Frame Grain
studio Current Leica look #e60012 Playfair+DM Sans 0 passepartout ✓
minimal Clean, no distractions #666 Inter 0 none ✗
editorial Magazine feel #1a1a1a Libre Baskerville+Source Sans 4 shadow ✗
classic Warm, inviting #8b6914 Cormorant+Lato 8 none ✗

Presets are just default values — every property can be overridden individually.

Layer 4: OG image theming

Update /api/og/route.tsx to read accent color and fonts from config, replacing the hardcoded #0a0a0a / #d4af37.


File Changes Summary

File Change
lib/config.ts Add ThemeConfig type + parsing from YAML
app/layout.tsx Inject CSS vars inline, dynamic Google Fonts link
app/tokens.css Tokens become fallback defaults only
app/globals.css Remove hardcoded "Studio" overrides, use data attributes
themes/studio.css [NEW] Extracted studio/Leica aesthetic
themes/minimal.css [NEW] Clean minimal preset
themes/editorial.css [NEW] Magazine preset
themes/classic.css [NEW] Warm classic preset
app/api/og/route.tsx Read accent color from config
content/gallery.yaml.example Add theme: section
README.md Document theme customization

Migration Path

Zero breaking changes. If no theme key exists in gallery.yaml, the app defaults to studio (current look). Existing deployments see no visual change.


Not In Scope (Future)

  • Live theme preview — a /theme-editor page where you tweak and see changes in real time
  • Custom CSS injection — a customCss field in YAML for power users to inject arbitrary CSS
  • Per-album themes — different themes for different subpages
  • Background texture/pattern options beyond grain