levineuwirth.org/PHOTOGRAPHY.md

26 KiB

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.

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:

[
  {
    "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 ✓

  • Add photographyPattern family to Patterns.hs
  • Create build/Photography.hs with routing rules
  • Add photographyCompiler to Compilers.hs
  • Add photographyCtx to Contexts.hs
  • Add ("Photography", "photography") to homePortals in Site.hs
  • Create templates/photography.html, templates/photography-index.html, templates/partials/photo-card.html
  • Add minimal static/css/photography.css (no modes yet — single column is fine)
  • Manually drop one prepared JPEG into content/photography/{slug}/ and author its frontmatter by hand
  • Verify the photography portal appears in nav, the landing page lists the photo, the photo page renders
  • 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 ✓

  • Build masonry layout in photography.css (CSS grid + computed row spans)
  • Build uniform-grid mode
  • Build chronological mode
  • static/js/photography-modes.js — toggle UI, localStorage persistence
  • Extend static/js/lightbox.js with darkroom mode branch (gated on body[data-page-type="photography"])
  • 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 ✓

  • tools/extract-exif.py (uses exiftool if present, falls back to Pillow)
  • tools/extract-palette.py (Python + colorthief)
  • tools/import-photo.sh (resize, strip EXIF from delivered file, write sidecars, scaffold frontmatter)
  • Wire both into the Makefile, gated on .venv (silent-skip pattern matching embed.py)
  • Extend photographyCtx to merge sidecar EXIF + palette into the template context (frontmatter wins)
  • 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 ✓

  • tools/download-leaflet.sh — vendors Leaflet 1.9.4 + leaflet.markercluster 1.5.3 to static/leaflet/, sha256-pinned
  • build/Photography.hs map.json rule (Haskell, not Python — cleaner: Hakyll already has the metadata, no extra dep)
  • templates/photography-map.html
  • static/js/photography-map.js
  • Add map mode to the toggle on /photography/
  • 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 ✓

  • /photography/by-year/ and /photography/by-year/{year}/
  • /photography/contact-sheet/
  • /photography/feed.xml (Atom with thumbnails embedded inline in entry descriptions)
  • Series landing pages auto-generated from collection directories
  • Photography shelf on /library.html
  • 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)