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

169 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```yaml
# 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`:
```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';
}
```
2. In `layout.tsx`, inject theme values as CSS custom properties:
```tsx
<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`,
}}>
```
3. 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:
```css
/* 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