5.9 KiB
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
- Add
ThemeConfigtype toconfig.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';
}
- 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`,
}}>
- Dynamically build the Google Fonts
<link>URL from the font names.
Layer 2: Make component CSS conditional
Files: globals.css, tokens.css
- Move the "Studio-Inspired Defaults" block (lines 607–783) into a separate file
themes/studio.css - Create
themes/minimal.css,themes/classic.css,themes/editorial.csswith different aesthetic overrides - The base
globals.cssbecomes theme-agnostic — clean, neutral component styles - 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-editorpage where you tweak and see changes in real time - Custom CSS injection — a
customCssfield in YAML for power users to inject arbitrary CSS - Per-album themes — different themes for different subpages
- Background texture/pattern options beyond grain