# 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)
