From a9737ad8e744e2fe436c5c9d9b89cf302e09caa7 Mon Sep 17 00:00:00 2001 From: ralksta Date: Sun, 15 Feb 2026 12:21:02 +0100 Subject: [PATCH] feat: theming system, documentation, and photo grid regression tests - Add configurable theme system with 4 presets (studio, minimal, editorial, classic) - Theme controls fonts, colors, accent, photo frames, grain, and header dot - Resolve themes from gallery.yaml: string shorthand or full object override - Inject theme as CSS custom properties on element - Dynamic Google Fonts loading based on theme config - OG image route reads accent/background from theme - Refactor Studio overrides into conditional CSS via data-attributes Documentation: - Add docs/gallery-config.md with comprehensive configuration reference - Add docs/theming.md with preset reference and custom theme guide - Slim README to link to detailed docs Fix CSS regression: - Fix passepartout frame overflow:visible breaking next/image fill rendering - Remove duplicate .photo-grid__item block that stripped overflow:hidden Testing: - Add e2e/photo-grid.spec.ts with 3 regression tests: - Grid items render with real dimensions - overflow:hidden CSS guard (catches the exact bug fixed) - Images load with real pixel data (not blurhash) Tooling: - Add scripts/screenshots.ts for automated theme screenshot generation - Handles dev server lifecycle, theme cycling, FadeIn + lazy image handling --- .gitignore | 4 + README.md | 64 ++--------- app/api/og/route.tsx | 7 +- app/globals.css | 172 ++++++++++++++++-------------- app/layout.tsx | 31 +++++- app/tokens.css | 7 +- content/gallery.yaml.example | 21 ++++ docs/gallery-config.md | 113 ++++++++++++++++++++ docs/theming.md | 121 +++++++++++++++++++++ e2e/photo-grid.spec.ts | 75 +++++++++++++ lib/config.ts | 135 +++++++++++++++++++++++ scripts/screenshots.ts | 200 +++++++++++++++++++++++++++++++++++ 12 files changed, 810 insertions(+), 140 deletions(-) create mode 100644 docs/gallery-config.md create mode 100644 docs/theming.md create mode 100644 e2e/photo-grid.spec.ts create mode 100644 scripts/screenshots.ts diff --git a/.gitignore b/.gitignore index c166a07..9c59875 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ next-env.d.ts # internal dev docs dev-docs/ + +# generated screenshots +docs/screenshots/ + diff --git a/README.md b/README.md index 4c94f40..5b40bef 100644 --- a/README.md +++ b/README.md @@ -78,63 +78,21 @@ CACHE_TTL=300 # seconds, default: 300 RATE_LIMIT_RPM=120 # requests/min/IP, default: 120 ``` -### Gallery Config (`content/gallery.yaml`) +### Gallery Config + +All gallery structure — hero images, albums, subpages, grid layout, footer — is defined in `content/gallery.yaml`. + +→ **[Gallery Configuration Guide](docs/gallery-config.md)** + +### Theming + +Customize the visual identity of your gallery with built-in presets or fine-grained control over colors, fonts, corners, photo frames, and more. ```yaml -# Hero image(s) — single UUID or list for carousel -hero: 00000000-0000-0000-0000-000000000000 -# hero: -# - uuid-1 -# - uuid-2 - -# Standalone albums on the homepage -albums: - - 11111111-1111-1111-1111-111111111111 - - 22222222-2222-2222-2222-222222222222 - -# EXIF on hover (default: true) -# exifOnHover: false - -# Global grid layout -# grid: -# columns: 3 -# gap: 12 -# aspectRatio: "1" -# layout: masonry # "masonry" or "uniform" - -# Subpages — group albums into collections -subpages: - - name: Japan - grid: - columns: 4 - layout: uniform - aspectRatio: '3/2' - albums: - - uuid-tokyo - - uuid-kyoto - - - name: Wedding – Smith - password: clientpass123 - albums: - - uuid-wedding +theme: minimal # or: studio, editorial, classic ``` -See `content/gallery.yaml.example` for the full reference. - -### About Page (`content/about.md`) - -```markdown ---- -portrait: asset-uuid -name: Your Name -location: City, Country -gear: - - Camera Body - - Favorite Lens ---- - -Your bio text here. -``` +→ **[Theming Guide](docs/theming.md)** ## Docker diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 4ce325e..53a6510 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -1,12 +1,14 @@ /** * Dynamic OG image generator — renders social share previews. * Uses next/og (ImageResponse) to create 1200×630 cards. + * Reads accent color from theme config. * * GET /api/og?title=Album+Name&subtitle=12+photos */ import { ImageResponse } from 'next/og'; import { NextRequest } from 'next/server'; +import { getConfig } from '@/lib/config'; export const runtime = 'edge'; @@ -14,6 +16,7 @@ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const title = searchParams.get('title') || 'Gallery'; const subtitle = searchParams.get('subtitle') || ''; + const { theme } = getConfig(); return new ImageResponse(
@@ -75,7 +78,7 @@ export async function GET(request: NextRequest) { style={{ width: '60px', height: '2px', - backgroundColor: '#d4af37', + backgroundColor: theme.accent, marginTop: '40px', }} /> diff --git a/app/globals.css b/app/globals.css index af104f7..f7f3a0e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -279,12 +279,10 @@ flex-direction: column; justify-content: flex-end; padding: 24px; - background: linear-gradient( - to top, - rgba(0, 0, 0, 0.8) 0%, - rgba(0, 0, 0, 0.3) 40%, - transparent 100% - ); + background: linear-gradient(to top, + rgba(0, 0, 0, 0.8) 0%, + rgba(0, 0, 0, 0.3) 40%, + transparent 100%); opacity: 0; transition: opacity var(--transition-normal); } @@ -414,12 +412,10 @@ /* ── Loading States ───────────────────────────────── */ .skeleton { - background: linear-gradient( - 90deg, - var(--bg-card) 0%, - var(--bg-card-hover) 50%, - var(--bg-card) 100% - ); + background: linear-gradient(90deg, + var(--bg-card) 0%, + var(--bg-card-hover) 50%, + var(--bg-card) 100%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite; border-radius: var(--radius-sm); @@ -604,10 +600,11 @@ font-weight: 300; } -/* ── Studio-Inspired Defaults ────────────────────── */ +/* ── Theme Conditionals ──────────────────────────── */ +/* Features toggled via data attributes on , set by theme config */ -/* Red dot in header */ -.header__nav::before { +/* ─ Header dot (accent circle in nav) ─ */ +[data-header-dot='true'] .header__nav::before { content: ''; display: block; width: 10px; @@ -618,6 +615,78 @@ flex-shrink: 0; } +/* ─ Passepartout photo frame ─ */ +[data-photo-frame='passepartout'] .photo-grid__item { + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 8px 8px 24px; + margin-bottom: var(--grid-gap, 24px); + background: #111; + overflow: hidden; +} + +[data-photo-frame='passepartout'][data-theme='light'] .photo-grid__item { + border-color: rgba(0, 0, 0, 0.1); + background: #fff; +} + +[data-photo-frame='passepartout'] .photo-grid__item-exif { + position: relative; + bottom: auto; + left: auto; + right: auto; + background: none; + padding: 8px 0 0; + color: var(--text-muted); + font-family: var(--font-caption); + font-style: italic; + font-size: 0.75rem; + letter-spacing: 0.02em; + white-space: normal; + opacity: 1; + text-align: center; +} + +[data-photo-frame='passepartout'] .photo-grid__item:hover .photo-grid__item-exif { + opacity: 1; +} + +/* ─ Shadow photo frame ─ */ +[data-photo-frame='shadow'] .photo-grid__item { + border-radius: var(--radius-sm); + box-shadow: 0 2px 20px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +[data-photo-frame='shadow'][data-theme='light'] .photo-grid__item { + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12); +} + +/* ─ Film grain overlay ─ */ +[data-grain='true'] .photo-grid__item::after { + content: ''; + position: absolute; + top: 8px; + left: 8px; + right: 8px; + bottom: 24px; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E"); + background-size: 128px 128px; + pointer-events: none; + z-index: 1; + mix-blend-mode: overlay; +} + +/* Grain without passepartout — fill the whole item */ +[data-grain='true'][data-photo-frame='none'] .photo-grid__item::after, +[data-grain='true'][data-photo-frame='shadow'] .photo-grid__item::after { + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +/* ── Gallery Aesthetics (all themes) ─────────────── */ + /* Generous spacing — gallery exhibition feel */ .main { padding: 64px 40px 120px; @@ -639,68 +708,11 @@ letter-spacing: 0.2em; } -/* Photo grid — passepartout white borders */ +/* Photo grid gap */ .photo-grid { column-gap: var(--grid-gap, 24px); } -.photo-grid__item { - border: 1px solid rgba(255, 255, 255, 0.12); - padding: 8px 8px 24px; - margin-bottom: var(--grid-gap, 24px); - background: #111; - border-radius: 0; - cursor: pointer; - position: relative; - overflow: visible; -} - -[data-theme='light'] .photo-grid__item { - border-color: rgba(0, 0, 0, 0.1); - background: #fff; -} - -.photo-grid__item img { - border-radius: 0; -} - -/* Film grain overlay */ -.photo-grid__item::after { - content: ''; - position: absolute; - top: 8px; - left: 8px; - right: 8px; - bottom: 24px; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E"); - background-size: 128px 128px; - pointer-events: none; - z-index: 1; - mix-blend-mode: overlay; -} - -/* Print-style captions (replaces EXIF overlay) */ -.photo-grid__item-exif { - position: relative; - bottom: auto; - left: auto; - right: auto; - background: none; - padding: 8px 0 0; - color: var(--text-muted); - font-family: var(--font-caption, 'EB Garamond', Georgia, serif); - font-style: italic; - font-size: 0.75rem; - letter-spacing: 0.02em; - white-space: normal; - opacity: 1; - text-align: center; -} - -.photo-grid__item:hover .photo-grid__item-exif { - opacity: 1; -} - /* Shutter-click feedback */ .photo-grid__item:active img { filter: brightness(0.6); @@ -740,9 +752,9 @@ color: var(--accent); } -/* Album cards — sharper, no rounded corners */ +/* Album cards */ .album-card { - border-radius: 0; + border-radius: var(--radius-md); border-color: rgba(255, 255, 255, 0.08); } @@ -755,7 +767,7 @@ transform: none; } -/* Subpage grid — clean */ +/* Subpage grid */ .subpage-grid__item { border: 1px solid rgba(255, 255, 255, 0.08); } @@ -764,19 +776,17 @@ border-color: rgba(0, 0, 0, 0.08); } -/* Nav link accent on hover */ +/* Accent hover effects (works with any accent color) */ .header__nav-link:hover, .header__nav-link.active { color: var(--accent); border-bottom-color: var(--accent); } -/* Footer — red accent on hover */ .footer__link:hover { color: var(--accent); } -/* Scroll-to-top — red accent on hover */ .scroll-to-top:hover { border-color: var(--accent); color: var(--accent); @@ -846,7 +856,7 @@ opacity: 1; } - .photo-grid__item { + [data-photo-frame='passepartout'] .photo-grid__item { padding: 6px 6px 18px; } -} +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 4d14f86..a6892e8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ /** * Root layout — site header with portfolio-style navigation. * Shows all subpages + standalone albums as nav links. + * Injects theme CSS custom properties from gallery.yaml config. */ import type { Metadata } from 'next'; @@ -10,6 +11,7 @@ import { SubpageNav } from '@/components/SubpageNav'; import { ThemeToggle } from '@/components/ThemeToggle'; import { ScrollToTop } from '@/components/ScrollToTop'; import { Footer } from '@/components/Footer'; +import { getConfig, getGoogleFontsUrl } from '@/lib/config'; const siteTitle = process.env.SITE_TITLE || 'Gallery'; const siteDescription = 'A curated photography portfolio'; @@ -35,8 +37,35 @@ export const metadata: Metadata = { }; export default function RootLayout({ children }: { children: React.ReactNode }) { + const { theme } = getConfig(); + const fontsUrl = getGoogleFontsUrl(theme); + + // Inject theme values as CSS custom properties + const themeVars: Record = { + '--accent': theme.accent, + '--accent-dim': `${theme.accent}1f`, + '--font-serif': `'${theme.fonts.heading}', Georgia, 'Times New Roman', serif`, + '--font-sans': `'${theme.fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`, + '--font-caption': `'${theme.fonts.caption}', Georgia, serif`, + '--radius-sm': `${theme.radius}px`, + '--radius-md': `${Math.round(theme.radius * 1.5)}px`, + '--radius-lg': `${theme.radius * 2}px`, + }; + return ( - + + + + + +