levineuwirth.org/PHOTOGRAPHY.md

485 lines
26 KiB
Markdown

# Photography
Design and implementation plan for the photography section of levineuwirth.org. This is the source of truth for the section's architecture, authoring conventions, and build pipeline. It sits alongside `WRITING.md` and `HOMEPAGE.md` as authoritative spec.
## Status
Pre-implementation. Decisions locked; phased build to follow.
---
## Goals
- A first-class photography section with the same architectural rigor as essays, music, and poetry.
- Custom metadata system tailored to photographs (location, capture date, camera, lens, film, exposure, palette) that is *distinct from* but *consistent with* the essay frontmatter system.
- Multiple ways to browse the same corpus: masonry, uniform grid, chronological, map, contact sheet, by tag, by series.
- Static-friendly throughout: every page renders at build time; JS is layered on top, never required.
- Web-optimized images only in the repo. Originals (RAW, full-resolution exports) live outside source control.
---
## Decisions (locked)
| Topic | Decision |
|-------|----------|
| Content model | Hybrid: flat singles + collection directories (mirrors `content/poetry/`) |
| Portal | Yes — 9th entry in `homePortals` |
| EXIF | Auto-extract via build-time tool, written to sidecar; frontmatter overrides |
| Map library | Leaflet, vendored locally |
| Map tiles | CartoDB Positron (free, attribution-only, monochrome) |
| Default geo precision | `city` (~10 km rounding); per-photo override allowed |
| Default visual mode | Masonry (native aspect ratios) |
| Mode toggle | Built day one: masonry / grid / chronological / map; persisted to localStorage |
| Contact sheet | Secondary view at `/photography/contact-sheet/` |
| Lightbox | Darkroom mode scoped to photography pages |
| Color palette | 5-color k-means strip, auto-extracted at build time |
| Feed | Separate `/photography/feed.xml` with thumbnails |
| Originals storage | Outside the repo; web-optimized JPEGs only are committed |
| `Filters/Images.hs` | Extended with a richer wrapper for photography-page images |
| Crop policy | CSS `object-fit: cover` for now; build-time crop later if needed |
---
## Content model & directory structure
```
content/photography/
├── index.md # /photography/ landing copy & frontmatter
├── reykjavik-rooftops.md # flat single (single photo entry)
├── reykjavik-rooftops/ # OR directory form for richer entries
│ ├── index.md
│ ├── photo.jpg # web-optimized; max 2400px long edge
│ ├── photo.exif.yaml # generated by build (gitignored)
│ └── photo.palette.yaml # generated by build (gitignored)
└── copenhagen-2025/ # series (collection)
├── index.md # series landing
├── 01-canal-morning.md
├── 02-tivoli-evening.md
└── ...
```
- **Flat single entries** are appropriate when the photo's metadata + a short caption are all you want.
- **Directory single entries** are used when the photo has co-located assets or longer prose.
- **Collection directories** group multiple photos under a series. Series have their own landing page at `/photography/{series}/`; individual photos live at `/photography/{series}/{photo}/`.
The hybrid pattern parallels `content/poetry/` and reuses the existing routing patterns in `build/Patterns.hs`.
---
## Frontmatter schema
All fields except `title` and one of (`date`, `captured`) are optional. Author-written values always win over auto-extracted EXIF.
```yaml
title: "Reykjavík Rooftops"
date: 2026-04-27 # publication date on this site
captured: 2026-03-15 # when the shutter clicked
# Location
location: "Reykjavík, Iceland" # human-readable
geo: [64.146, -21.940] # decimal lat/lon — author-set or from EXIF
geo-precision: city # exact | km | city | hidden (default: city)
# Camera & exposure (auto-fillable from EXIF)
camera: "Pentax K1000"
lens: "50mm f/1.4"
film: "Kodak Portra 400" # film stock for analog shots
exposure: "1/125 f/8 ISO 400" # combined string OR individual fields:
# shutter: "1/125"
# aperture: "f/8"
# iso: 400
# focal-length: "50mm"
# Process
process: "scanned" # scanned | digital-raw | darkroom | lightroom | jpeg-ooc
# Organization
series: copenhagen-2025 # optional — slug of containing series
tags: # hierarchical, slash-separated
- photography
- photography/landscape
- photography/film
featured: true # appears in curated landing rotation
nsfw: false # gate behind a click-through if true
# Display
orientation: portrait # portrait | landscape | square (auto-detected if omitted)
photo: photo.jpg # filename relative to entry — required for directory form
# License & provenance
license: "CC BY-SA 4.0" # license name; canonical URL auto-resolved
# for known shortcodes (CC variants, CC0,
# public domain). Omit for "All Rights Reserved".
license-url: "https://..." # explicit override; only needed for
# custom licenses or non-canonical wording
links: # outbound links — Wikimedia Commons,
- "Wikimedia Commons | https://commons.wikimedia.org/wiki/File:Foo.jpg"
- "Flickr | https://flickr.com/photos/levi/123"
# Flickr, exhibition catalog, print-sale
# page, etc. Same "Name | URL" pipe
# syntax used by authors/affiliation.
# Palette (auto-filled by build; override only for artistic reasons)
palette:
- "#2a3f5f"
- "#d4a574"
- "#7c8a8a"
- "#1a1a1a"
- "#e8d8c0"
```
### Field semantics
- **`date`** — when the photo was published to this site. Used for feed ordering and "recently added."
- **`captured`** — when the photograph was made. Used for `/by-year/` indexes and chronological mode.
- **`geo` + `geo-precision`** — coordinates and the precision at which they're rendered. The build rounds `geo` according to `geo-precision` before writing it to map data and the rendered page. Original precision is never delivered to the browser.
- **`tags`** — uses the existing slash-hierarchy tag system (`build/Tags.hs`). The top-level `photography` tag is implicit for all entries; sub-tags like `photography/landscape`, `photography/portrait`, `photography/film`, `photography/architecture` are freeform.
- **`series`** — slug-only. The series's own metadata lives in `content/photography/{series}/index.md`.
- **`license`** — license name as displayed (e.g., `"CC BY-SA 4.0"`). Canonical URL auto-resolved at build time for known shortcodes (`CC BY 4.0`, `CC BY-SA 4.0`, `CC BY-NC 4.0`, `CC BY-NC-SA 4.0`, `CC BY-ND 4.0`, `CC BY-NC-ND 4.0`, `CC0`, `Public Domain`). Author-supplied `license-url:` always wins. Omit for "All Rights Reserved" (renders as plain text without a link).
- **`links`** — outbound list of named external URLs. Same `"Name | URL"` syntax as `authors:` and `affiliation:`. Entries without a URL are dropped. Used for Wikimedia Commons, Flickr, exhibition catalog, print-sale page, etc.
### What is *not* in photography frontmatter
These essay fields do not apply and are not exposed on photography pages:
- Epistemic profile (`status`, `confidence`, `evidence`, `scope`, `novelty`, `practicality`)
- `abstract` (use a brief caption in the body instead)
- `further-reading`, `bibliography`, `csl`
- Reading time, word count
- Backlinks, similar-links (text-embedding signals are noise on visual content)
- TOC
---
## Routing & generated pages
| URL | Source | Notes |
|-----|--------|-------|
| `/photography/` | `content/photography/index.md` | Landing; default masonry view + mode toggle |
| `/photography/{slug}/` | `content/photography/{slug}.md` or `{slug}/index.md` | Single-photo page |
| `/photography/{series}/` | `content/photography/{series}/index.md` | Series landing |
| `/photography/{series}/{photo}/` | `content/photography/{series}/{photo}.md` | Photo within a series |
| `/photography/by-year/` | Auto-generated | Year index with photo counts |
| `/photography/by-year/{year}/` | Auto-generated | All photos with `captured` in that year |
| `/photography/contact-sheet/` | Auto-generated | Frame-numbered grid of all photos |
| `/photography/map/` | Auto-generated | Leaflet map of geo-tagged photos |
| `/photography/{tag}/` | Auto-generated | Tag pages via existing `Tags.hs` |
| `/photography/feed.xml` | Auto-generated | Atom feed with thumbnails |
Tag pages (`/photography/landscape/`, etc.) come "for free" from the existing tag system — sub-tags of `photography` automatically generate their own index pages.
---
## Visual system
### Modes (toggle from day one)
The `/photography/` landing offers four browsing modes via a toggle in the page header. Selection persists to localStorage under `photography-mode`, mirroring the existing settings panel pattern.
1. **Masonry** *(default)* — variable-height cells respecting native aspect ratios. CSS Grid with `grid-auto-rows: 1px` + `grid-row-end: span N` computed from each image's aspect ratio (shipped in HTML to avoid layout shift).
2. **Grid** — uniform square cells using `object-fit: cover`. Rhythmic, scannable.
3. **Chronological** — single column, large, ordered by `captured` (desc). One photo per row with caption beneath. Closest aesthetic to reading mode.
4. **Map** — switches the page to the `/photography/map/` route. (Or: inlines the map in place of the grid; decide during Phase 4.)
### Contact sheet (separate URL)
`/photography/contact-sheet/` renders all photos with thin white borders, frame numbers in the corner, and a subtle film-grain texture. Distinct from the toggle modes — a deep-cut alternate view, not a primary mode.
### Color palette strip
Beneath each photo on its detail page, a thin row of 5 swatches drawn from `palette` frontmatter. Hover reveals the hex value. CSS-only; no JS needed.
### Darkroom lightbox
Scoped to `body[data-page-type="photography"]` so essays' lightbox is unaffected.
- Page chrome fades to deep black on open (not just an overlay)
- Subtle vignette behind the photo
- Caption + key metadata appear below in muted Spectral italic
- Arrow keys / swipe navigate within the current series (or within the current page's photo list)
- `i` key toggles full EXIF reveal
- `Escape` closes; click outside the photo closes
### Visual identity rationale
Masonry default + clean grid alternate gives the considered/organic tone of essays a visual analog: each photo's geometry is honored, but a uniform mode is one click away for comparison. Contact sheet stays as a distinctive alternate URL — referenced from `/photography/` but not the default — so the analog aesthetic is available without being prescriptive.
---
## Image storage & pipeline
### What lives where
- **Originals (RAW, full-resolution exports)**: outside the repo. Levi's local archive (external drive, NAS, or backup service). Not Levi's responsibility to define for this plan; only the *contract* matters: originals never enter source control.
- **Web-optimized JPEGs**: committed to the repo at `content/photography/{slug}/photo.jpg` (or alongside `.md` for flat singles). Long edge ≤ 2400px, quality 85, sRGB, EXIF stripped before commit.
- **WebP companions**: generated at build time by the existing `tools/convert-images.sh`; gitignored (already covered by the existing `content/**/*.webp` rule on line 105 of `.gitignore`).
- **EXIF sidecar (`{photo}.exif.yaml`)**: generated at build time; gitignored.
- **Palette sidecar (`{photo}.palette.yaml`)**: generated at build time; gitignored.
### Defense-in-depth gitignore additions
Append to `.gitignore`:
```
# Photography: generated sidecars (recreated by build pipeline)
content/photography/**/*.exif.yaml
content/photography/**/*.palette.yaml
# Photography: defense-in-depth — refuse to commit RAW or oversize originals.
# To intentionally commit one (rare), use `git add -f path/to/file`.
content/photography/**/*.cr2
content/photography/**/*.cr3
content/photography/**/*.nef
content/photography/**/*.arw
content/photography/**/*.dng
content/photography/**/*.raf
content/photography/**/*.orf
content/photography/**/*.tif
content/photography/**/*.tiff
content/photography/**/*.psd
```
### Import workflow
`tools/import-photo.sh` (Phase 3): given a path to an original and a target slug, the script:
1. Resizes to ≤ 2400px long edge as JPEG quality 85, sRGB.
2. Strips all EXIF from the delivered JPEG with `exiftool -all=`.
3. Writes the EXIF sidecar to `{slug}/photo.exif.yaml` (so the metadata is preserved for display, but not embedded in the file shipped to viewers).
4. Computes the 5-color palette and writes `{slug}/photo.palette.yaml`.
5. Drops a frontmatter stub at `{slug}/index.md` ready for editing.
Until that script exists, Phase 1 + 2 work with manually prepared JPEGs.
### Build-pipeline integration
New steps slot into the Makefile alongside the existing `convert-images` and `pdf-thumbs` targets, all gated on tool availability (silent skip if missing, matching the `embed.py` pattern):
```
make build:
...
→ tools/extract-exif.py (gated on `exiftool` or `Pillow`)
→ tools/extract-palette.py (gated on Python + colorthief)
→ tools/build-map-data.py (always runs; no external deps)
→ tools/convert-images.sh (existing)
→ hakyll-build (existing)
...
```
---
## `Filters/Images.hs` extension
Currently `Filters/Images.hs` emits `<picture>` with WebP companion + lazy-loading for any image in any document. Photography pages need a richer wrapper that includes:
- The palette strip beneath the photo
- A small EXIF metadata block (camera, lens, exposure, captured-date) toggled by an "ⓘ" button
- A figure caption (Pandoc already handles this)
- The `data-photography="true"` attribute that scopes the darkroom lightbox
The cleanest approach: extend `Filters/Images.hs` to detect when the document being processed is a photography page (via document metadata or path pattern) and emit the richer wrapper in that case. Essays continue to get the simple `<picture>` wrapper unchanged.
Alternative considered: render the richer wrapper from the template instead of the filter. Rejected because the template loses the per-image palette/EXIF lookup; doing it in the filter keeps the data flow Pandoc-native.
---
## Map architecture
### Library & tiles
- **Leaflet 1.9.x** vendored to `static/leaflet/`. No CDN. `tools/download-leaflet.sh` mirrors the `download-pdfjs.sh` pattern.
- **CartoDB Positron** raster tiles. Free for any volume, attribution required ("© OpenStreetMap contributors © CARTO"). Monochrome, doesn't fight the typography.
- Fallback: if CartoDB ever rate-limits or disappears, swap to Stadia Maps or self-hosted.
### Build-time data
`tools/build-map-data.py` walks `content/photography/`, reads each entry's `geo` + `geo-precision`, applies the precision rounding, and emits `_site/photography/map.json`:
```json
[
{
"slug": "reykjavik-rooftops",
"title": "Reykjavík Rooftops",
"url": "/photography/reykjavik-rooftops/",
"thumb": "/photography/reykjavik-rooftops/photo.jpg",
"lat": 64.15,
"lon": -21.94,
"captured": "2026-03-15"
}
]
```
### Geo precision
Applied at build time before any data leaves the build directory:
| Precision | Rounding | Approximate |
|-----------|----------|-------------|
| `exact` | 4 decimals | ~10 m |
| `km` | 2 decimals | ~1 km |
| `city` *(default)* | 1 decimal | ~10 km |
| `hidden` | omit from `map.json` entirely | not pinned |
### Page-scoped JS/CSS
`static/leaflet/leaflet.js`, `static/leaflet/leaflet.css`, and `static/js/photography-map.js` are loaded **only** on `/photography/map/` via the per-page `js:` frontmatter mechanism (already supported — see `WRITING.md`). Other photography pages stay lightweight.
### Marker behavior
- Click marker → photo page.
- Marker thumbnail tooltip on hover (uses Leaflet's tooltip API).
- Marker clustering when zoomed out, via `leaflet.markercluster` plugin (also vendored).
---
## Templates
New files under `templates/`:
| File | Role |
|------|------|
| `photography.html` | Single photo page chrome — figure, palette strip, metadata, navigation within series |
| `photography-index.html` | `/photography/` landing — mode toggle, masonry/grid/chrono modes |
| `photography-series.html` | `/photography/{series}/` landing — series intro + photo list |
| `photography-map.html` | `/photography/map/` — Leaflet container, vendored JS/CSS |
| `photography-contact-sheet.html` | `/photography/contact-sheet/` — frame-numbered grid |
| `photography-by-year.html` | `/photography/by-year/{year}/` — chronological year index |
New partial:
| File | Role |
|------|------|
| `partials/photo-card.html` | Reusable photo-card markup for grids and listings |
| `partials/photo-meta.html` | Camera/lens/exposure/captured block, toggleable |
| `partials/photo-palette.html` | 5-swatch palette strip |
Existing partials reused unchanged: `nav.html`, `head.html`, `footer.html`.
---
## Build module structure
New Haskell modules under `build/`:
- **`build/Photography.hs`** — patterns, routing rules, contexts specific to photography. Separated from `Site.hs` for the same reason `Catalog.hs` and `Authors.hs` are separated: scoped concerns, easier to reason about.
Edits to existing modules:
- **`build/Patterns.hs`** — add `photographyPattern`, `photographySinglesPattern`, `photographySeriesPattern`, `photographyAssetsPattern`.
- **`build/Compilers.hs`** — add `photographyCompiler` (essay-pipeline minus epistemic/reading-time/backlinks/TOC).
- **`build/Contexts.hs`** — add `photographyCtx` with photo-specific fields, sidecar merge logic.
- **`build/Site.hs`** — add `("Photography", "photography")` to `homePortals`; wire photography rules from `Photography.hs`.
- **`build/Filters/Images.hs`** — extend to emit richer wrapper on photography pages.
---
## Phased implementation
Each phase has explicit exit criteria. Don't move to the next phase until the current one passes.
### Phase 1 — Skeleton end-to-end ✓
- [x] Add `photographyPattern` family to `Patterns.hs`
- [x] Create `build/Photography.hs` with routing rules
- [x] Add `photographyCompiler` to `Compilers.hs`
- [x] Add `photographyCtx` to `Contexts.hs`
- [x] Add `("Photography", "photography")` to `homePortals` in `Site.hs`
- [x] Create `templates/photography.html`, `templates/photography-index.html`, `templates/partials/photo-card.html`
- [x] Add minimal `static/css/photography.css` (no modes yet — single column is fine)
- [x] Manually drop one prepared JPEG into `content/photography/{slug}/` and author its frontmatter by hand
- [x] Verify the photography portal appears in nav, the landing page lists the photo, the photo page renders
- [x] **Bonus**: license + outbound-links metadata (auto-resolved canonical URLs for known CC variants)
**Exit criteria**: A photo renders at `/photography/{slug}/` with correct nav portal, basic styling, and is reachable from `/photography/` and `/library.html`. **Met.**
### Phase 2 — Visual system & toggle ✓
- [x] Build masonry layout in `photography.css` (CSS grid + computed row spans)
- [x] Build uniform-grid mode
- [x] Build chronological mode
- [x] `static/js/photography-modes.js` — toggle UI, localStorage persistence
- [x] Extend `static/js/lightbox.js` with darkroom mode branch (gated on `body[data-page-type="photography"]`)
- [x] Body `data-page-type="photography"` attribute (added in Phase 1 as a free hook)
**Exit criteria**: `/photography/` switches between three modes smoothly; localStorage persists choice; lightbox enters darkroom mode on photography pages only. **Met.** Visual verification pending Levi's run of the dev server.
### Phase 3 — EXIF, palette, and import pipelines ✓
- [x] `tools/extract-exif.py` (uses `exiftool` if present, falls back to `Pillow`)
- [x] `tools/extract-palette.py` (Python + colorthief)
- [x] `tools/import-photo.sh` (resize, strip EXIF from delivered file, write sidecars, scaffold frontmatter)
- [x] Wire both into the Makefile, gated on `.venv` (silent-skip pattern matching `embed.py`)
- [x] Extend `photographyCtx` to merge sidecar EXIF + palette into the template context (frontmatter wins)
- [x] Update `.gitignore` with photography sidecars and RAW patterns
- [ ] **Deferred** — Extend `Filters/Images.hs` with the richer photography wrapper
- [ ] **Deferred** — Build `partials/photo-meta.html` and `partials/photo-palette.html`
The two deferred items are now redundant with the Phase 1 template structure: the per-photo metadata block and palette strip live in `templates/photography.html` directly and consume `photographyCtx` fields. Extending `Filters/Images.hs` would only matter if Levi later writes long-form "photo essays" with multiple inline photos that should each carry the rich wrapper — defer until that content type exists.
**Exit criteria**: A photo with no manually authored camera/lens/exposure/palette frontmatter still displays full metadata + palette strip on its detail page, sourced entirely from sidecars. **Met.** Verified with `canto31.jpg` (no frontmatter `captured:`, `camera:`, etc.; `captured-display` and palette swatches both flowed from sidecar through to rendered HTML).
### Phase 4 — Map ✓
- [x] `tools/download-leaflet.sh` — vendors Leaflet 1.9.4 + leaflet.markercluster 1.5.3 to `static/leaflet/`, sha256-pinned
- [x] **`build/Photography.hs` map.json rule** (Haskell, not Python — cleaner: Hakyll already has the metadata, no extra dep)
- [x] `templates/photography-map.html`
- [x] `static/js/photography-map.js`
- [x] Add map mode to the toggle on `/photography/`
- [x] CartoDB Positron tile attribution wired into the page
**Exit criteria**: `/photography/map/` shows pins at city precision, marker thumbnails on hover, clicking a pin navigates to the photo. Leaflet JS/CSS load only on the map page. **Met.**
Implementation notes worth knowing:
- `map.json` is generated by Hakyll (`photographyMapDataRule` in `build/Photography.hs`), not a Python step. The original spec called for `tools/build-map-data.py`; Haskell turned out cleaner because Hakyll already has every photo's frontmatter loaded and the precision-rounding logic is six lines.
- `geo-precision: hidden` photos are dropped from `map.json` entirely.
- Pin URLs are stripped of trailing `index.html` so click-through goes directly to the canonical directory URL with no implicit redirect.
- UTF-8 in titles is decoded via `Data.Text.Lazy.Encoding.decodeUtf8` rather than `LBS.unpack` to avoid double-encoding bugs (em-dashes, accents, etc.).
- Map page is tile-rate-limit-friendly: scroll-zoom is disabled until the user clicks into the map (prevents accidental zoom while scrolling past), tiles cached aggressively (`fetch(..., {cache: 'force-cache'})`).
- `leaflet.markercluster` is loaded but degrades gracefully to plain `L.featureGroup` if the plugin failed to load.
### Phase 5 — Auxiliary surfaces ✓
- [x] `/photography/by-year/` and `/photography/by-year/{year}/`
- [x] `/photography/contact-sheet/`
- [x] `/photography/feed.xml` (Atom with thumbnails embedded inline in entry descriptions)
- [x] Series landing pages auto-generated from collection directories
- [x] Photography shelf on `/library.html`
- [x] Tag pages (`/photography/landscape/`, etc.) wired into the existing tag system; the bare `photography` tag is filtered out of the expansion in `Tags.getExpandedTags` to avoid colliding with the section-landing route
**Exit criteria**: All routing-table URLs from this document resolve and are linked from somewhere reachable. **Met.**
Implementation notes worth knowing:
- A new `allPhotoEntries` pattern in `Patterns.hs` enumerates every photographic file (top-level entries + series children); used by surfaces that need every frame (by-year, contact-sheet, feed, map). The original `photographyPattern` (top-level only) feeds the main `/photography/` landing and the library shelf, where a series should appear as a single aggregate card rather than once for the landing plus once per child.
- Series detection is purely structural: a directory has siblings ↔ it's a series. No `series: true` flag in frontmatter. `photographyEntryRules` uses a `Set String` of series-slugs computed once at rule-gen time to branch template selection (`photography-series.html` vs `photography.html`).
- Sibling photo URLs are canonical directory form: `/photography/<series>/<photo>/`.
- The `sectionOwnedTopLevelTags` filter in `Tags.hs` is named generally so other portal tags can be added if their content types ever feed `tagIndexable`.
- By-year extraction reads frontmatter `captured:` first, falling back to `date:`. Photos with neither are silently dropped from by-year only; they remain visible everywhere else. (Future improvement: also fall back to the EXIF sidecar's `captured:` so frontmatter-free photos appear automatically.)
---
## Open / deferred questions
These are non-blocking but worth tracking:
- **Build-time crop** — currently `object-fit: cover`. If the contact-sheet aesthetic feels weak with center-crops, introduce a crop-aware build step (Phase 6 or later).
- **`nsfw` gate** — frontmatter field is reserved but no UI is planned in initial phases. Add when first needed.
- **Print availability / contact info** — out of scope for v1; revisit if Levi wants to sell prints.
- **Diptych / triptych layouts** — frontmatter-driven pairing exists in concept (`pair: other-slug`) but unimplemented; defer until there's actual content that demands it.
- **Random photo entry point** — `/photography/random` redirect; trivial JS, defer.
- **EXIF reliability for film** — non-concern per design discussion; sidecar/frontmatter merge handles missing EXIF gracefully.
- **High-res download links** — out of scope. Originals are not online.
---
## References
- `WRITING.md` — frontmatter conventions for essays (template for the photography schema's structure)
- `HOMEPAGE.md` — homepage portal grid
- `build/Patterns.hs` — current content pattern definitions
- `build/Tags.hs` — slash-hierarchy tag system (reused for photography tags)
- `build/Filters/Images.hs` — current image filter (to be extended)
- `static/css/gallery.css` — exhibit/overlay system (reference for darkroom lightbox)
- `tools/convert-images.sh` — WebP companion generation (reused as-is)