# 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 `` 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 `` 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///`. - 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)