Spec dilemma

This commit is contained in:
Levi Neuwirth 2026-05-01 21:22:01 -04:00
parent 3a5326d92d
commit cd94227acb
110 changed files with 6421 additions and 187 deletions

39
.gitignore vendored
View File

@ -77,7 +77,13 @@ data/semantic-meta.json
IGNORE.txt IGNORE.txt
# Working notes / planning docs at the repo root (not site content). # Working notes / planning docs at the repo root (not site content).
content/drafts/
checklist.md checklist.md
README.profile.md
README.arcana.md
README.simd.md
README.icd.md
README.neuropose.md
# CV/résumé build pipeline (YAML → Jinja → xelatex). The canonical PDFs # CV/résumé build pipeline (YAML → Jinja → xelatex). The canonical PDFs
# live under static/ and ship with the site; the pipeline itself is # live under static/ and ship with the site; the pipeline itself is
@ -94,7 +100,40 @@ static/models/
# Download with: make download-pdfjs # Download with: make download-pdfjs
static/pdfjs/ static/pdfjs/
# Vendored Leaflet + leaflet.markercluster (~150 KB total, pinned in
# tools/download-leaflet.sh). Used by the /photography/map/ page only.
# Download with: make download-leaflet (runs as part of `make build`).
static/leaflet/
# Generated WebP companions (produced by tools/convert-images.sh at build time). # Generated WebP companions (produced by tools/convert-images.sh at build time).
# To intentionally commit a WebP, use: git add -f path/to/file.webp # To intentionally commit a WebP, use: git add -f path/to/file.webp
static/**/*.webp static/**/*.webp
content/**/*.webp content/**/*.webp
# Photography sidecars (produced by tools/extract-exif.py and
# tools/extract-palette.py at build time; consumed by Hakyll). Recreated
# from the photo file on every `make build`, so they don't belong in
# version control — committing them would just produce churn.
content/photography/**/*.exif.yaml
content/photography/**/*.palette.yaml
# Image-dimension sidecars (produced by tools/extract-dimensions.py at
# build time; consumed by build/Filters/Images.hs to emit width / height
# attrs on every <img> for CLS prevention). Same churn-avoidance reasons
# as the photography sidecars above; recreated on every `make build`.
**/*.dims.yaml
# Photography: defense-in-depth — refuse to commit RAW or oversize
# originals. Per PHOTOGRAPHY.md, only ≤2400px web-optimized JPEGs are
# committed; originals stay outside the repo. To intentionally commit
# one of these formats (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

View File

@ -1,4 +1,4 @@
.PHONY: build deploy sign download-model download-pdfjs compress-assets convert-images pdf-thumbs pdfs watch clean dev .PHONY: build deploy sign download-model download-pdfjs download-leaflet compress-assets convert-images pdf-thumbs pdfs watch clean dev
# Source .env for deploy / GitHub config if it exists. # Source .env for deploy / GitHub config if it exists.
# .env format: KEY=value (one per line, no `export` prefix, no quotes needed). # .env format: KEY=value (one per line, no `export` prefix, no quotes needed).
@ -28,6 +28,21 @@ build:
@./tools/convert-images.sh @./tools/convert-images.sh
@$(MAKE) -s pdf-thumbs @$(MAKE) -s pdf-thumbs
@./tools/download-pdfjs.sh @./tools/download-pdfjs.sh
@./tools/download-leaflet.sh
# Photography pipeline (Phase 3): generate per-photo EXIF + palette
# sidecars under content/photography/**/*.{exif,palette}.yaml so the
# Hakyll context can merge them with frontmatter at compile time.
# Plus per-image dimension sidecars across static/images/ and
# content/** so build/Filters/Images.hs can attach width / height
# attrs to body images for CLS prevention.
# Gated on .venv presence, same as embed.py — failures are non-fatal.
@if [ -d .venv ]; then \
uv run python tools/extract-exif.py || echo "Warning: EXIF extraction failed (build continues with frontmatter only)"; \
uv run python tools/extract-palette.py || echo "Warning: palette extraction failed (build continues with frontmatter only)"; \
uv run python tools/extract-dimensions.py || echo "Warning: dimension extraction failed (build continues without width/height attrs)"; \
else \
echo "Photography sidecars skipped: run 'uv sync' to enable EXIF + palette + dimension extraction (build continues with frontmatter only)"; \
fi
cabal run site -- build cabal run site -- build
pagefind --site _site pagefind --site _site
@if [ -d .venv ]; then \ @if [ -d .venv ]; then \
@ -56,6 +71,13 @@ download-model:
download-pdfjs: download-pdfjs:
@./tools/download-pdfjs.sh @./tools/download-pdfjs.sh
# Vendor Leaflet + leaflet.markercluster into static/leaflet/.
# Used only by /photography/map/. Runs automatically as part of `build`
# (skips when already present). Files are gitignored; sha256-verified
# against tools/leaflet-checksums.sha256.
download-leaflet:
@./tools/download-leaflet.sh
# Generate .gz and .br sidecars for compressible text assets in _site/. # Generate .gz and .br sidecars for compressible text assets in _site/.
# Runs automatically as part of `build`. Pairs with `gzip_static` / # Runs automatically as part of `build`. Pairs with `gzip_static` /
# `brotli_static` in the nginx vhost (see nginx/static-assets.conf). # `brotli_static` in the nginx vhost (see nginx/static-assets.conf).

484
PHOTOGRAPHY.md Normal file
View File

@ -0,0 +1,484 @@
# 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)

View File

@ -81,6 +81,7 @@ bibliography: data/custom.bib # optional; overrides data/bibliography.bib
csl: data/custom.csl # optional; overrides Chicago Author-Date csl: data/custom.csl # optional; overrides Chicago Author-Date
no-collapse: true # optional; disables collapsible h2/h3 sections no-collapse: true # optional; disables collapsible h2/h3 sections
repository: https://git.levineuwirth.org/levi/repo # optional; "Repository" link in metadata repository: https://git.levineuwirth.org/levi/repo # optional; "Repository" link in metadata
preprint: /papers/my-essay.pdf # optional; "Preprint" link in metadata (typeset PDF version)
js: scripts/my-widget.js # optional; per-page JS file (see Page scripts) js: scripts/my-widget.js # optional; per-page JS file (see Page scripts)
# js: [scripts/a.js, scripts/b.js] # or a list # js: [scripts/a.js, scripts/b.js] # or a list

View File

@ -7,6 +7,7 @@ module Compilers
, poetryCompiler , poetryCompiler
, fictionCompiler , fictionCompiler
, compositionCompiler , compositionCompiler
, photographyCompiler
, sidecarCompiler , sidecarCompiler
, readerOpts , readerOpts
, writerOpts , writerOpts
@ -201,6 +202,25 @@ fictionCompiler = essayCompiler
compositionCompiler :: Compiler (Item String) compositionCompiler :: Compiler (Item String)
compositionCompiler = essayCompiler compositionCompiler = essayCompiler
-- | Compiler for photography pages: body prose runs through the same
-- source preprocessors and AST filters as other content (so wikilinks,
-- smallcaps, sidenotes, image @<picture>@ wrapping, etc. all work in
-- caption / process-note prose), but skips TOC, word-count,
-- reading-time, citations, and further-reading. Visual content has no
-- meaningful word count, and the epistemic / bibliography surfaces in
-- 'essayCtx' don't apply here.
photographyCompiler :: Compiler (Item String)
photographyCompiler = do
body <- getResourceBody
let src = itemBody body
body' = itemSetBody (preprocessSource src) body
filePath <- getResourceFilePath
let srcDir = takeDirectory filePath
pandocItem <- readPandocWith readerOpts body'
pandocFiltered <- unsafeCompiler $ applyAll srcDir (itemBody pandocItem)
let pandocItem' = itemSetBody pandocFiltered pandocItem
return (writePandocWith writerOpts pandocItem')
-- | Reduced pipeline for tag-meta sidecar markdown files. Applies -- | Reduced pipeline for tag-meta sidecar markdown files. Applies
-- source-level preprocessors and AST filters (wikilinks, sidenotes, -- source-level preprocessors and AST filters (wikilinks, sidenotes,
-- smallcaps, links, etc.) so sidecar prose can use the same rich -- smallcaps, links, etc.) so sidecar prose can use the same rich

View File

@ -8,6 +8,7 @@ module Contexts
, poetryCtx , poetryCtx
, fictionCtx , fictionCtx
, compositionCtx , compositionCtx
, photographyCtx
, contentKindField , contentKindField
, abstractField , abstractField
, descriptionField , descriptionField
@ -24,17 +25,22 @@ module Contexts
) where ) where
import Data.Aeson (Value (..)) import Data.Aeson (Value (..))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as AK
import qualified Data.Aeson.KeyMap as KM import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V import qualified Data.Vector as V
import Data.List (intercalate, isPrefixOf, sortBy) import Data.List (intercalate, isPrefixOf, sortBy)
import Data.Maybe (fromMaybe, mapMaybe) import Data.Maybe (fromMaybe, mapMaybe)
import Data.Ord (comparing) import Data.Ord (comparing)
import qualified Data.Scientific as Sci
import Data.Time.Calendar (toGregorian) import Data.Time.Calendar (toGregorian)
import Data.Time.Clock (UTCTime, getCurrentTime, utctDay) import Data.Time.Clock (UTCTime, getCurrentTime, utctDay)
import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM) import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM)
import System.FilePath (takeDirectory, takeFileName) import System.Directory (doesFileExist)
import System.FilePath (takeDirectory, takeFileName, (</>))
import Text.Read (readMaybe) import Text.Read (readMaybe)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Yaml as Y
import Text.Pandoc (runPure, readMarkdown, writeHtml5String, writePlain, Pandoc(..), Block(..), Inline(..)) import Text.Pandoc (runPure, readMarkdown, writeHtml5String, writePlain, Pandoc(..), Block(..), Inline(..))
import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..)) import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..))
import Hakyll hiding (trim) import Hakyll hiding (trim)
@ -118,6 +124,7 @@ contentKindField = field "item-kind" $ \item -> do
| "poetry/" `isPrefixOf` r' -> "Poem" | "poetry/" `isPrefixOf` r' -> "Poem"
| "fiction/" `isPrefixOf` r' -> "Fiction" | "fiction/" `isPrefixOf` r' -> "Fiction"
| "music/" `isPrefixOf` r' -> "Composition" | "music/" `isPrefixOf` r' -> "Composition"
| "photography/" `isPrefixOf` r' -> "Photo"
| otherwise -> "Page" | otherwise -> "Page"
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -886,3 +893,399 @@ compositionCtx =
<> field "has-audio" <> field "has-audio"
(\i -> maybe (fail "no audio") (const (return "true")) (\i -> maybe (fail "no audio") (const (return "true"))
(movAudio (itemBody i))) (movAudio (itemBody i)))
-- ---------------------------------------------------------------------------
-- Photography context
-- ---------------------------------------------------------------------------
-- | Extract the photo entry's slug from its identifier.
--
-- * Flat single @content/photography/<slug>.md@ → @<slug>@
-- * Directory @content/photography/<slug>/index.md@ → @<slug>@
--
-- The slug is the URL segment under @/photography/@ and the directory
-- name into which co-located assets (the photo, future EXIF + palette
-- sidecars) are copied by the asset rule.
photoSlug :: Item a -> String
photoSlug item =
let fp = toFilePath (itemIdentifier item)
fname = takeFileName fp
in if fname == "index.md"
then takeFileName (takeDirectory fp)
else takeWhile (/= '.') fname
-- ---------------------------------------------------------------------------
-- Sidecar reader (Phase 3)
-- ---------------------------------------------------------------------------
--
-- @{photo}.exif.yaml@ and @{photo}.palette.yaml@ are produced by the
-- Python tools at @make build@ time (see @tools/extract-exif.py@ and
-- @tools/extract-palette.py@). They live alongside the photo file
-- under @content/photography/<slug>/@ and back-fill metadata that the
-- author chose not to write in frontmatter.
--
-- Read strategy: 'unsafeCompiler' + 'doesFileExist'. Sidecars are NOT
-- registered as Hakyll items, so this read bypasses the dependency
-- tracker. That is acceptable because:
--
-- * The Python tools always run before @cabal run site -- build@
-- (the Makefile orders them that way).
-- * Re-running the EXIF / palette extractor invalidates only those
-- fields' rendered output; rebuilding @make build@ from scratch
-- covers the dependency-edge case for free.
--
-- Resolution rule for every sidecar-backed field: frontmatter wins;
-- if frontmatter is absent OR empty, fall back to sidecar; if neither
-- supplies a value, return 'noResult' so the consuming template's
-- @$if(...)$@ guard suppresses the row.
-- | Compute the sidecar path for a photo entry.
--
-- @suffix@ is @".exif.yaml"@ or @".palette.yaml"@.
-- Returns @Nothing@ when the entry has no @photo:@ frontmatter or
-- when the entry is flat-form (no co-located asset directory).
photoSidecarPath :: String -> Item a -> Compiler (Maybe FilePath)
photoSidecarPath suffix item = do
meta <- getMetadata (itemIdentifier item)
let fp = toFilePath (itemIdentifier item)
isDir = takeFileName fp == "index.md"
case (isDir, lookupString "photo" meta) of
(True, Just photo) | not (null photo) ->
return $ Just $ takeDirectory fp </> photo ++ suffix
_ -> return Nothing
-- | Load a sidecar YAML file as an Aeson Object (same shape Hakyll
-- uses for frontmatter). Returns 'Aeson.empty' when the file is
-- missing or fails to parse — sidecars are advisory, never fatal.
loadSidecar :: FilePath -> IO Aeson.Object
loadSidecar path = do
exists <- doesFileExist path
if not exists
then return KM.empty
else do
decoded <- Y.decodeFileEither path
case decoded of
Right (Object obj) -> return obj
_ -> return KM.empty
-- | Read a sidecar object for a given suffix. Returns the empty object
-- when the entry has no resolvable sidecar path or when the file is
-- absent / malformed.
readPhotoSidecar :: String -> Item a -> Compiler Aeson.Object
readPhotoSidecar suffix item = do
mPath <- photoSidecarPath suffix item
case mPath of
Nothing -> return KM.empty
Just path -> unsafeCompiler (loadSidecar path)
-- | Coerce a YAML scalar value to a plain String for template
-- interpolation. Integers render without a trailing @.0@; structures
-- and arrays return 'Nothing' (callers needing those should branch
-- on 'Value' directly).
yamlAsString :: Value -> Maybe String
yamlAsString (String t) =
let s = T.unpack t
in if null (trim s) then Nothing else Just (trim s)
yamlAsString (Number n) =
case Sci.floatingOrInteger n :: Either Double Integer of
Right i -> Just (show i)
Left d -> Just (show d)
yamlAsString _ = Nothing
-- | Look up a key in a sidecar object, coercing scalar values to
-- String. Returns 'Nothing' for missing keys, empty strings, and
-- structural values (arrays / nested objects).
sidecarLookupString :: String -> Aeson.Object -> Maybe String
sidecarLookupString key obj = yamlAsString =<< KM.lookup (AK.fromString key) obj
-- | Generic frontmatter > EXIF-sidecar fallback field.
--
-- @key@ is the YAML key — same name on both sides. Frontmatter
-- wins when present and non-empty; otherwise the matching key in
-- @{photo}.exif.yaml@. 'noResult' fires when neither supplies a
-- value, so the consuming template's @$if(key)$@ guard suppresses
-- the row.
exifBackedField :: String -> Context String
exifBackedField key = field key $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString key meta of
Just v | not (null (trim v)) -> return (trim v)
_ -> do
obj <- readPhotoSidecar ".exif.yaml" item
case sidecarLookupString key obj of
Just v -> return v
Nothing -> noResult ("no " ++ key ++ " in frontmatter or EXIF sidecar")
-- | Canonical URL for a known license name.
--
-- The frontmatter @license:@ string is normalized — lowercased, with
-- internal whitespace collapsed — before lookup, so any of these all
-- resolve identically:
--
-- * @"CC BY-SA 4.0"@
-- * @"cc by-sa 4.0"@
-- * @" CC BY-SA 4.0 "@
--
-- For licenses not in this table (e.g. a custom license, or "All
-- Rights Reserved" which has no URL), the author can supply their
-- own @license-url:@ frontmatter field; the field-level resolver
-- (@licenseUrlField@) prefers explicit @license-url@ and falls back
-- to this lookup only when the author hasn't provided one.
canonicalLicenseUrl :: String -> Maybe String
canonicalLicenseUrl raw =
case unwords (words (map (\c -> if c == '_' then ' ' else toLowerC c) raw)) of
"cc by 4.0" -> Just "https://creativecommons.org/licenses/by/4.0/"
"cc by-sa 4.0" -> Just "https://creativecommons.org/licenses/by-sa/4.0/"
"cc by-nc 4.0" -> Just "https://creativecommons.org/licenses/by-nc/4.0/"
"cc by-nc-sa 4.0" -> Just "https://creativecommons.org/licenses/by-nc-sa/4.0/"
"cc by-nd 4.0" -> Just "https://creativecommons.org/licenses/by-nd/4.0/"
"cc by-nc-nd 4.0" -> Just "https://creativecommons.org/licenses/by-nc-nd/4.0/"
"cc0" -> Just "https://creativecommons.org/publicdomain/zero/1.0/"
"cc0 1.0" -> Just "https://creativecommons.org/publicdomain/zero/1.0/"
"public domain" -> Just "https://creativecommons.org/publicdomain/mark/1.0/"
_ -> Nothing
where
toLowerC c
| c >= 'A' && c <= 'Z' = toEnum (fromEnum c + 32)
| otherwise = c
-- | Context for photography pages and photo cards.
--
-- Phase 1: frontmatter-only. Auto-extracted EXIF + palette sidecars
-- land in Phase 3, where this context will gain a merge step that
-- reads @{photo}.exif.yaml@ / @{photo}.palette.yaml@ and exposes
-- their fields under the same template variables. Frontmatter wins.
--
-- Photography pages do not include the essay context's epistemic,
-- bibliography, backlinks, similar-links, TOC, word-count, or
-- reading-time fields — none of those apply to visual content. See
-- @PHOTOGRAPHY.md@ for the design rationale.
--
-- Exposed template variables:
-- @$photography$@ — flag, gates @photography.css@ in head.html
-- and the @data-page-type@ body attribute used
-- by the Phase 2 darkroom-mode lightbox
-- @$slug$@ — URL slug under @/photography/@
-- @$photo-url$@ — absolute URL of the photo file. Built as
-- @/photography/<slug>/<photo>@ when the entry
-- is directory-form; @noResult@ for flat
-- singles (templates use the @photo@
-- frontmatter directly there).
-- @$captured-display$@, @$captured-iso$@ — capture date in
-- human-readable and ISO forms; @noResult@
-- when @captured:@ is absent. Distinct from
-- the publication @date:@ shown in card lists.
-- @$photography-tags$@ — listField of @{tag-name, tag-url}@.
-- @$palette-swatches$@ — listField of @{swatch}@ (hex string).
-- @noResult@ when the @palette:@ frontmatter
-- is absent or empty so the template's
-- @$if(palette-swatches)$@ gate suppresses an
-- empty strip.
photographyCtx :: Context String
photographyCtx =
constField "photography" "true"
<> slugField
<> photoUrlField
<> photoWebpUrlField
-- EXIF-backed fields. Each prefers frontmatter and falls back to
-- @{photo}.exif.yaml@ produced by @tools/extract-exif.py@. Sidecars
-- absent on film scans (no EXIF on a film negative) is fine —
-- noResult propagates and the template's @$if(...)$@ gate hides
-- the row.
<> exifBackedField "camera"
<> exifBackedField "lens"
<> exifBackedField "exposure"
<> exifBackedField "shutter"
<> exifBackedField "aperture"
<> exifBackedField "iso"
<> exifBackedField "focal-length"
-- Pixel dimensions for CLS-prevention width/height attrs on every
-- <img>. Read from the EXIF sidecar produced by extract-exif.py;
-- frontmatter wins if the author wants to override (e.g., to
-- declare a different rendered size).
<> exifBackedField "width"
<> exifBackedField "height"
<> capturedDisplayField
<> capturedIsoField
<> paletteSwatchesField
<> licenseUrlField
<> photoLinksField
<> tagLinksField "photography-tags"
<> authorLinksField
<> affiliationField
<> dateField "date" "%-d %B %Y"
<> dateField "date-iso" "%Y-%m-%d"
<> revisionDateFields
<> siteCtx
where
slugField :: Context String
slugField = field "slug" (return . photoSlug)
-- Build @/photography/<slug>/<photo>@ when both the directory-form
-- entry and a @photo:@ frontmatter key are present. Flat singles
-- have no co-located asset directory, so @noResult@ there — the
-- template falls back to interpreting the @photo:@ frontmatter
-- as a literal URL.
photoUrlField :: Context String
photoUrlField = field "photo-url" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let fp = toFilePath (itemIdentifier item)
isDir = takeFileName fp == "index.md"
case (isDir, lookupString "photo" meta) of
(True, Just photo) ->
return $ "/photography/" ++ photoSlug item ++ "/" ++ photo
_ -> noResult "no co-located photo (flat single, or photo: key absent)"
-- WebP companion URL, mirroring 'photoUrlField'. Returns 'noResult'
-- when the @.webp@ companion doesn't exist on disk at compile time
-- (cwebp not installed, conversion not yet run, or this image
-- failed to convert) so the template's @$if(photo-webp-url)$@
-- guard suppresses the @<source>@ — the @<picture>@ then degrades
-- to a plain @<img>@ on the original-format src. Browsers do NOT
-- fall back from a 404'd @<source>@ to the nested @<img>@; the
-- file-existence check at build time is load-bearing.
photoWebpUrlField :: Context String
photoWebpUrlField = field "photo-webp-url" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let fp = toFilePath (itemIdentifier item)
isDir = takeFileName fp == "index.md"
case (isDir, lookupString "photo" meta) of
(True, Just photo) | not (null photo) -> do
let entryDir = takeDirectory fp
webpDisk = entryDir </> photoToWebp photo
exists <- unsafeCompiler (doesFileExist webpDisk)
if exists
then return $ "/photography/" ++ photoSlug item
++ "/" ++ photoToWebp photo
else noResult "no webp companion on disk"
_ -> noResult "no co-located photo (flat single, or photo: key absent)"
where
-- @photo.jpg@ → @photo.webp@; preserves any path components
-- the author might have written (rare but harmless).
photoToWebp :: String -> String
photoToWebp p =
let dotIdx = lastDotIndex p
in case dotIdx of
Just i -> take i p ++ ".webp"
Nothing -> p ++ ".webp"
lastDotIndex :: String -> Maybe Int
lastDotIndex s = go (length s - 1)
where
go i
| i < 0 = Nothing
| s !! i == '/' = Nothing -- crossed a path boundary
| s !! i == '.' = Just i
| otherwise = go (i - 1)
-- Resolve the @captured:@ ISO date with frontmatter > sidecar
-- precedence. Centralised so the display and ISO fields stay in
-- agreement on which source they read from.
resolveCapturedIso :: Item a -> Compiler (Maybe String)
resolveCapturedIso item = do
meta <- getMetadata (itemIdentifier item)
case lookupString "captured" meta of
Just v | not (null (trim v)) -> return (Just (trim v))
_ -> do
obj <- readPhotoSidecar ".exif.yaml" item
return (sidecarLookupString "captured" obj)
-- @captured:@ as "15 March 2026". Reads frontmatter, falls back to
-- the EXIF sidecar's @captured:@ key. Returns @noResult@ when
-- absent so @$if(captured-display)$@ gates the metadata row.
capturedDisplayField :: Context String
capturedDisplayField = field "captured-display" $ \item -> do
mIso <- resolveCapturedIso item
case mIso of
Nothing -> noResult "no captured date in frontmatter or EXIF sidecar"
Just iso ->
case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso
:: Maybe UTCTime of
Just t -> return (formatTime defaultTimeLocale "%-d %B %Y" t)
Nothing -> noResult "captured date does not parse as YYYY-MM-DD"
-- ISO form passed through unchanged (after a parse-validate round-trip
-- so a malformed value in either source doesn't reach the template).
capturedIsoField :: Context String
capturedIsoField = field "captured-iso" $ \item -> do
mIso <- resolveCapturedIso item
case mIso of
Nothing -> noResult "no captured date in frontmatter or EXIF sidecar"
Just iso ->
case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso
:: Maybe UTCTime of
Just t -> return (formatTime defaultTimeLocale "%Y-%m-%d" t)
Nothing -> noResult "captured date does not parse as YYYY-MM-DD"
-- @palette:@ list field. Frontmatter wins; otherwise pull the
-- list from @{photo}.palette.yaml@ (the @palette:@ key, an array
-- of hex strings produced by @tools/extract-palette.py@). Each
-- swatch exposes @$swatch$@.
paletteSwatchesField :: Context String
paletteSwatchesField = listFieldWith "palette-swatches" swCtx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let fmEntries = fromMaybe [] (lookupStringList "palette" meta)
fmVisible = filter (not . null . trim) fmEntries
swatches <- if null fmVisible
then do
obj <- readPhotoSidecar ".palette.yaml" item
case KM.lookup "palette" obj of
Just (Array vec) ->
return [ trim s
| val <- V.toList vec
, Just s <- [yamlAsString val]
, not (null (trim s)) ]
_ -> return []
else return fmVisible
if null swatches
then noResult "no palette swatches in frontmatter or palette sidecar"
else return $ zipWith
(\i s -> Item (fromFilePath ("palette-" ++ show i)) s)
([0 ..] :: [Int])
swatches
where
swCtx = field "swatch" (return . itemBody)
-- @$license-url-resolved$@: an explicit @license-url:@ frontmatter
-- value when present, otherwise a canonical URL looked up from the
-- @license:@ string for known licenses (CC variants, CC0, public
-- domain). Returns @noResult@ when neither is set, so
-- @$if(license-url-resolved)$@ gates the link wrapper.
--
-- Frontmatter @license:@ itself flows through @defaultContext@ as
-- @$license$@; the template renders the license name as link text
-- and uses @$license-url-resolved$@ as @href@.
licenseUrlField :: Context String
licenseUrlField = field "license-url-resolved" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "license-url" meta of
Just u | not (null (trim u)) -> return (trim u)
_ -> case lookupString "license" meta of
Nothing -> noResult "no license"
Just l -> case canonicalLicenseUrl l of
Just u -> return u
Nothing -> noResult "license not in canonical lookup"
-- @links:@ frontmatter — outbound links to other surfaces where
-- the photograph appears or can be acquired (Wikimedia Commons,
-- Flickr, exhibition catalog, print-sale page, etc.). Each entry
-- uses the same @"Name | URL"@ pipe syntax as @authors:@ /
-- @affiliation:@ — the existing site convention.
--
-- Each item exposes @$link-name$@ and @$link-url$@. Entries
-- without a URL are dropped (no point linking to nothing). Returns
-- @noResult@ on empty so @$if(photo-links)$@ guards the wrapper.
photoLinksField :: Context String
photoLinksField = listFieldWith "photo-links" lkCtx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let entries = fromMaybe [] (lookupStringList "links" meta)
parsed = filter (not . null . snd) (map parseEntry entries)
if null parsed
then noResult "no outbound links"
else return $ map (Item (fromFilePath "")) parsed
where
lkCtx = field "link-name" (return . fst . itemBody)
<> field "link-url" (return . snd . itemBody)
parseEntry s = case break (== '|') s of
(name, '|' : url) -> (trim name, trim url)
(name, _) -> (trim name, "")

View File

@ -1,6 +1,7 @@
{-# LANGUAGE GHC2021 #-} {-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
-- | Image filter: lazy loading, lightbox markers, and WebP <picture> wrappers. -- | Image filter: lazy loading, lightbox markers, WebP <picture>
-- wrappers, and CLS-preventing width/height attrs.
-- --
-- For local raster images (JPG, JPEG, PNG, GIF) whose @.webp@ companion -- For local raster images (JPG, JPEG, PNG, GIF) whose @.webp@ companion
-- exists on disk at build time, emits a @<picture>@ element with a WebP -- exists on disk at build time, emits a @<picture>@ element with a WebP
@ -17,16 +18,29 @@
-- --
-- SVG files and external URLs are passed through with only lazy loading -- SVG files and external URLs are passed through with only lazy loading
-- (and lightbox markers for standalone images). -- (and lightbox markers for standalone images).
--
-- Width / height attrs are looked up from @{image}.dims.yaml@ sidecars
-- produced by @tools/extract-dimensions.py@ at build time, on the same
-- path-resolution rules as the WebP companion check (absolute paths
-- under @static/@, relative under the source-file directory). When a
-- sidecar is missing the filter emits an attr-free <img> rather than
-- guessing — partial dimensions are worse than no dimensions, since
-- the browser would then size the image wrong on first paint.
module Filters.Images (apply) where module Filters.Images (apply) where
import Data.Char (toLower) import Data.Char (toLower)
import Data.Default (def)
import Data.List (isPrefixOf) import Data.List (isPrefixOf)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Scientific as Sci
import qualified Data.Yaml as Y
import Text.Pandoc.Definition
import qualified Text.Pandoc as Pandoc
import Text.Pandoc.Walk (walkM)
import System.Directory (doesFileExist) import System.Directory (doesFileExist)
import System.FilePath (replaceExtension, takeExtension, (</>)) import System.FilePath (replaceExtension, takeExtension, (</>))
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walkM)
import qualified Utils as U import qualified Utils as U
-- | Apply image attribute injection and WebP wrapping to the entire document. -- | Apply image attribute injection and WebP wrapping to the entire document.
@ -35,13 +49,76 @@ import qualified Utils as U
-- relative image paths when probing for the corresponding @.webp@ -- relative image paths when probing for the corresponding @.webp@
-- companion file. Absolute paths (leading @/@) are resolved against -- companion file. Absolute paths (leading @/@) are resolved against
-- @static/@ instead, matching the layout @convert-images.sh@ writes to. -- @static/@ instead, matching the layout @convert-images.sh@ writes to.
--
-- Two-pass walk:
--
-- 1. Block-level pass (@transformBlock@) intercepts standalone
-- figures so we can synthesize the entire @<figure>@ ourselves
-- when WebP wrapping kicks in. Without this pass, replacing the
-- inner @Image@ with a @RawInline@ would break Pandoc's
-- alt-vs-caption comparison and we'd lose the
-- @aria-hidden="true"@ hint on identical-text figcaptions.
-- 2. Inline-level pass (@transformInline@) handles every remaining
-- @Image@ — inline-in-prose, inside @Link@s, etc. Pandoc's writer
-- still applies its accessibility heuristics for figures we
-- didn't synthesize (notably the no-WebP case).
apply :: FilePath -> Pandoc -> IO Pandoc apply :: FilePath -> Pandoc -> IO Pandoc
apply srcDir = walkM (transformInline srcDir) apply srcDir doc = do
doc' <- walkM (transformBlock srcDir) doc
walkM (transformInline srcDir) doc'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Core transformation -- Core transformations
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- | Block-level pass. Currently only acts on the simple-figure shape
-- that Pandoc's Markdown reader produces for @![alt](src)@ standalone:
--
-- @Figure attr caption [Plain [Image imgAttr alt target]]@
--
-- When the image has a WebP companion on disk, we replace the whole
-- Figure with a @RawBlock@ containing the equivalent HTML — but with
-- the @<picture>@ wrapper inside and a manually-emitted
-- @aria-hidden="true"@ on the figcaption when alt text equals the
-- caption text. Anything more exotic (multi-image figures, mixed
-- block content inside the figure, no-WebP images) is left to
-- Pandoc's default emission, which is already correct for those
-- cases.
transformBlock :: FilePath -> Block -> IO Block
transformBlock srcDir b@(Figure figAttr caption [Plain [Image imgAttr alt target]]) = do
let src = T.unpack (fst target)
if not (isLocalRaster src)
then pure b
else do
hasWebp <- doesFileExist (webpPhysicalPath srcDir (fst target))
if not hasWebp
then pure b -- Pandoc handles aria-hidden naturally on the no-WebP path.
else synthesizeFigure srcDir figAttr caption imgAttr alt target
transformBlock _ b = pure b
-- | Build a @<figure>@ block from an Image and its surrounding
-- metadata. Used only on the WebP branch; the no-WebP branch leaves
-- Pandoc to emit the figure naturally.
--
-- Aria-hiding rule: when the caption's plain-text content equals the
-- alt text and both are non-empty, mark the @<figcaption>@ with
-- @aria-hidden="true"@. Screen readers then announce the alt
-- (via the @<img>@) and skip the figcaption (which would just
-- duplicate it). Non-matching captions render as visible content.
--
-- Caption inline rendering goes through Pandoc's HTML writer, so
-- formatting (italic, links, code, etc.) is preserved.
synthesizeFigure :: FilePath -> Attr -> Caption -> Attr -> [Inline] -> Target -> IO Block
synthesizeFigure srcDir figAttr caption imgAttr alt target = do
dims <- readDims srcDir (fst target)
let pictureHtml = renderPicture imgAttr alt target True dims
capInlines = captionInlines caption
capText = stringify capInlines
altText = stringify alt
useAriaHide = capText == altText && not (T.null altText)
pure $ RawBlock (Format "html") $
renderFigure figAttr pictureHtml (renderFigcaption capInlines useAriaHide)
transformInline :: FilePath -> Inline -> IO Inline transformInline :: FilePath -> Inline -> IO Inline
transformInline srcDir (Link lAttr ils lTarget) = do transformInline srcDir (Link lAttr ils lTarget) = do
-- Recurse into link contents; images inside a link get no lightbox marker. -- Recurse into link contents; images inside a link get no lightbox marker.
@ -60,21 +137,41 @@ wrapLinkedImg _ x = pure x
-- * Local raster with webp companion on disk → @<picture>@ with WebP @<source>@ -- * Local raster with webp companion on disk → @<picture>@ with WebP @<source>@
-- * Local raster without companion → plain @<img>@ (graceful degradation) -- * Local raster without companion → plain @<img>@ (graceful degradation)
-- * Everything else (SVG, URL) → plain @<img>@ with loading/lightbox attrs -- * Everything else (SVG, URL) → plain @<img>@ with loading/lightbox attrs
--
-- In all three branches, when a @{image}.dims.yaml@ sidecar is
-- present, @width@ and @height@ attrs are emitted on the rendered
-- @<img>@. The sidecar lookup is skipped for non-local sources
-- (HTTP URLs, data URIs) since there's no local file to measure.
renderImg :: FilePath -> Attr -> [Inline] -> Target -> Bool -> IO Inline renderImg :: FilePath -> Attr -> [Inline] -> Target -> Bool -> IO Inline
renderImg srcDir attr alt target@(src, _) lightbox renderImg srcDir attr alt target@(src, _) lightbox = do
| isLocalRaster (T.unpack src) = do let s = T.unpack src
isRaster = isLocalRaster s
local = not (isUrl s)
dims <- if local then readDims srcDir src else pure Nothing
if isRaster
then do
hasWebp <- doesFileExist (webpPhysicalPath srcDir src) hasWebp <- doesFileExist (webpPhysicalPath srcDir src)
if hasWebp if hasWebp
then pure $ RawInline (Format "html") then pure $ RawInline (Format "html")
(renderPicture attr alt target lightbox) (renderPicture attr alt target lightbox dims)
else pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr)) else pure $ Image (commonAttrs dims) alt target
alt target else
| otherwise = pure $ Image (commonAttrs dims) alt target
pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr)) alt target
where where
commonAttrs dims =
withDims dims
$ addAttr "decoding" "async"
$ addLightbox lightbox
$ addAttr "loading" "lazy" attr
addLightbox True a = addAttr "data-lightbox" "true" a addLightbox True a = addAttr "data-lightbox" "true" a
addLightbox False a = a addLightbox False a = a
withDims Nothing a = a
withDims (Just (w, h)) a =
addAttr "width" (T.pack (show w))
(addAttr "height" (T.pack (show h)) a)
-- | Physical on-disk path of the @.webp@ companion for a Markdown image src. -- | Physical on-disk path of the @.webp@ companion for a Markdown image src.
-- --
-- Absolute paths (@/images/foo.jpg@) resolve under @static/@ because that -- Absolute paths (@/images/foo.jpg@) resolve under @static/@ because that
@ -89,13 +186,49 @@ webpPhysicalPath srcDir src =
else srcDir </> s else srcDir </> s
in replaceExtension physical ".webp" in replaceExtension physical ".webp"
-- | Physical on-disk path of the @.dims.yaml@ sidecar for a Markdown
-- image src. Same path-resolution rules as 'webpPhysicalPath'; the
-- sidecar lives next to the original image with the literal
-- extension @.dims.yaml@ appended.
dimsPhysicalPath :: FilePath -> Text -> FilePath
dimsPhysicalPath srcDir src =
let s = T.unpack src
physical = if "/" `isPrefixOf` s
then "static" ++ s
else srcDir </> s
in physical ++ ".dims.yaml"
-- | Read the @{image}.dims.yaml@ sidecar and return @(width, height)@
-- when present and parseable. Returns 'Nothing' on absent file,
-- parse error, missing keys, or non-integer values — all of which
-- cause the filter to emit no width/height attrs (rather than a
-- guess that would size the image wrong on first paint).
readDims :: FilePath -> Text -> IO (Maybe (Int, Int))
readDims srcDir src = do
let path = dimsPhysicalPath srcDir src
exists <- doesFileExist path
if not exists
then pure Nothing
else do
decoded <- Y.decodeFileEither path
pure $ case decoded of
Right (Y.Object obj) -> do
w <- intValue =<< KM.lookup "width" obj
h <- intValue =<< KM.lookup "height" obj
Just (w, h)
_ -> Nothing
where
intValue :: Y.Value -> Maybe Int
intValue (Y.Number n) = Sci.toBoundedInteger n
intValue _ = Nothing
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- <picture> rendering -- <picture> rendering
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- | Emit a @<picture>@ element with a WebP @<source>@ and an @<img>@ fallback. -- | Emit a @<picture>@ element with a WebP @<source>@ and an @<img>@ fallback.
renderPicture :: Attr -> [Inline] -> Target -> Bool -> Text renderPicture :: Attr -> [Inline] -> Target -> Bool -> Maybe (Int, Int) -> Text
renderPicture (ident, classes, kvs) alt (src, title) lightbox = renderPicture (ident, classes, kvs) alt (src, title) lightbox dims =
T.concat T.concat
[ "<picture>" [ "<picture>"
, "<source srcset=\"", T.pack webpSrc, "\" type=\"image/webp\">" , "<source srcset=\"", T.pack webpSrc, "\" type=\"image/webp\">"
@ -105,7 +238,9 @@ renderPicture (ident, classes, kvs) alt (src, title) lightbox =
, " src=\"", esc src, "\"" , " src=\"", esc src, "\""
, attrAlt alt , attrAlt alt
, attrTitle title , attrTitle title
, dimsAttrs dims
, " loading=\"lazy\"" , " loading=\"lazy\""
, " decoding=\"async\""
, if lightbox then " data-lightbox=\"true\"" else "" , if lightbox then " data-lightbox=\"true\"" else ""
, renderKvs passedKvs , renderKvs passedKvs
, ">" , ">"
@ -114,13 +249,81 @@ renderPicture (ident, classes, kvs) alt (src, title) lightbox =
where where
webpSrc = replaceExtension (T.unpack src) ".webp" webpSrc = replaceExtension (T.unpack src) ".webp"
-- Strip attrs we handle explicitly above (id/class/alt/title) and the -- Strip attrs we handle explicitly above (id/class/alt/title) and the
-- attrs we always emit ourselves (loading, data-lightbox), so they don't -- attrs we always emit ourselves (loading, decoding, data-lightbox,
-- appear twice on the <img>. -- width, height), so they don't appear twice on the <img>.
passedKvs = filter passedKvs = filter
(\(k, _) -> k `notElem` (\(k, _) -> k `notElem`
["loading", "data-lightbox", "id", "class", "alt", "title", "src"]) [ "loading", "decoding", "data-lightbox"
, "id", "class", "alt", "title", "src"
, "width", "height"
])
kvs kvs
dimsAttrs Nothing = ""
dimsAttrs (Just (w, h)) =
" width=\"" <> T.pack (show w)
<> "\" height=\"" <> T.pack (show h) <> "\""
-- ---------------------------------------------------------------------------
-- <figure> synthesis (Block walk, WebP path only)
-- ---------------------------------------------------------------------------
-- | Build a @<figure>@ HTML element wrapping pre-rendered inner
-- content (typically a @<picture>@) and a pre-rendered figcaption.
-- Preserves any id / classes / kvs from the surrounding Pandoc
-- 'Figure' attr.
renderFigure :: Attr -> Text -> Text -> Text
renderFigure (figId, figClasses, figKvs) inner figcaption =
T.concat
[ "<figure"
, attrId figId
, attrClasses figClasses
, renderKvs figKvs
, ">\n"
, inner
, "\n"
, figcaption
, "\n</figure>"
]
-- | Build a @<figcaption>@ element. When @ariaHidden@ is true, emits
-- @aria-hidden="true"@ — used when the caption text exactly
-- duplicates the image alt (so screen readers don't announce the
-- same content twice). Caption inlines render through Pandoc's HTML
-- writer to preserve formatting.
renderFigcaption :: [Inline] -> Bool -> Text
renderFigcaption ils ariaHidden =
let body = renderInlinesToHtml ils
attrs = if ariaHidden then " aria-hidden=\"true\"" else ""
in "<figcaption" <> attrs <> ">" <> body <> "</figcaption>"
-- | Pandoc 'Caption' has a long form (@[Block]@) and an optional short
-- form (@Maybe ShortCaption@). We use the long form, flattening any
-- @Plain@ / @Para@ blocks into a single inline list. Multi-block
-- captions (rare) collapse to the inlines of their text-bearing
-- blocks; non-text blocks (like nested lists) are dropped, since
-- they don't make sense in a figcaption anyway.
captionInlines :: Caption -> [Inline]
captionInlines (Caption _ blocks) = concatMap go blocks
where
go (Plain ils) = ils
go (Para ils) = ils
go _ = []
-- | Render Pandoc 'Inline' nodes to HTML using Pandoc's own writer.
-- Wrapping the inlines in a @Plain@ block (rather than @Para@)
-- avoids the surrounding @<p>@ tag the writer would otherwise emit.
-- On writer failure (extremely unlikely for inline-only input),
-- falls back to the plain-text 'stringify' rendering — a worse but
-- still safe figcaption.
renderInlinesToHtml :: [Inline] -> Text
renderInlinesToHtml ils =
case Pandoc.runPure (Pandoc.writeHtml5String def doc) of
Right t -> T.strip t
Left _ -> stringify ils
where
doc = Pandoc mempty [Plain ils]
attrId :: Text -> Text attrId :: Text -> Text
attrId t = if T.null t then "" else " id=\"" <> esc t <> "\"" attrId t = if T.null t then "" else " id=\"" <> esc t <> "\""

View File

@ -16,6 +16,8 @@ module Patterns
, poetryPattern , poetryPattern
, fictionPattern , fictionPattern
, musicPattern , musicPattern
, photographyPattern
, allPhotoEntries
, standalonePagesPattern , standalonePagesPattern
-- * Aggregated patterns -- * Aggregated patterns
, allWritings -- essays + blog + poetry + fiction , allWritings -- essays + blog + poetry + fiction
@ -66,6 +68,44 @@ fictionPattern = "content/fiction/*.md"
musicPattern :: Pattern musicPattern :: Pattern
musicPattern = "content/music/*/index.md" musicPattern = "content/music/*/index.md"
-- | All photo entries — flat singles plus directory-form entries.
--
-- Phase 1 supports two shapes:
-- * flat: @content/photography/<slug>.md@
-- * directory: @content/photography/<slug>/index.md@
--
-- The section landing page at @content/photography/index.md@ is
-- excluded; it routes via 'Site.rules' as the catalog landing
-- (analogous to @content/music/index.md@), not as a photo entry.
--
-- Phase 5 will extend this pattern with collection-photo files
-- (@content/photography/<series>/<photo>.md@) when series support
-- lands; until then directory-form @index.md@ files are treated as
-- single-photo entries (a series is just a directory with siblings).
photographyPattern :: Pattern
photographyPattern =
("content/photography/*.md" .&&. complement "content/photography/index.md")
.||. "content/photography/*/index.md"
-- | Every photographic entry, including children of series. Distinct
-- from 'photographyPattern' (which enumerates only top-level entries
-- and series landings) for surfaces that should enumerate every
-- photograph individually:
--
-- * @/photography/by-year/<year>/@ — one frame per file
-- * @/photography/contact-sheet/@ — every frame in the roll
-- * @/photography/map.json@ — one pin per geotagged photo
-- * @/photography/feed.xml@ — one entry per shot
-- * Tag indexes — siblings have their own tags
--
-- The main @/photography/@ landing and the library shelf use
-- 'photographyPattern' instead, so a series shows up as a single
-- aggregate card rather than once for the landing plus once per child.
allPhotoEntries :: Pattern
allPhotoEntries =
photographyPattern
.||. ("content/photography/*/*.md" .&&. complement "content/photography/*/index.md")
-- | Top-level standalone pages (about, colophon, current, gpg, …) and -- | Top-level standalone pages (about, colophon, current, gpg, …) and
-- the curated routing pages under @content/cv/@ (which render with the -- the curated routing pages under @content/cv/@ (which render with the
-- same @templates/page.html@ pipeline and need the same backlink and -- same @templates/page.html@ pipeline and need the same backlink and
@ -98,6 +138,14 @@ allContent =
authorIndexable :: Pattern authorIndexable :: Pattern
authorIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion authorIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion
-- | Content shown on tag index pages — essays + blog posts. -- | Content shown on tag index pages — essays + blog posts + every
-- photographic entry (including sibling photos in series).
-- Photography sub-tags (@photography/landscape@, @photography/film@,
-- …) generate proper @/<sub-tag>/@ pages from this pattern; the
-- bare @photography@ top-level tag is filtered out in
-- 'Tags.getExpandedTags' to avoid colliding with the section
-- landing's route at @/photography/@.
tagIndexable :: Pattern tagIndexable :: Pattern
tagIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion tagIndexable =
(essayPattern .||. blogPattern .||. allPhotoEntries)
.&&. hasNoVersion

598
build/Photography.hs Normal file
View File

@ -0,0 +1,598 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Photography section — routing and per-page compilation.
--
-- Phase 1 (current): single-photo entries in flat and directory form,
-- plus the @/photography/@ landing page that lists every entry.
--
-- Phase 5 will extend this module with:
-- * collection-photo files (@content/photography/<series>/<photo>.md@)
-- * series landing pages
-- * @/photography/by-year/@ chronological indexes
-- * @/photography/contact-sheet/@ alternate view
-- * @/photography/feed.xml@ Atom feed
-- * @/photography/map/@ Leaflet map (Phase 4)
--
-- See @PHOTOGRAPHY.md@ at the repo root for the full design and
-- phased implementation plan.
module Photography
( photographyRules
) where
import Control.Monad (forM, forM_)
import Data.List (sortBy)
import qualified Data.Map.Strict as Map
import Data.Map.Strict (Map)
import Data.Maybe (mapMaybe, fromMaybe, catMaybes)
import qualified Data.Set as Set
import Data.Set (Set)
import Data.Ord (Down (..), comparing)
import System.FilePath (takeDirectory, takeFileName, replaceExtension)
import qualified Data.Aeson as Aeson
import Data.Aeson (Value (..), (.=))
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import qualified Data.Vector as V
import qualified Data.Scientific as Sci
import Hakyll
import Compilers (pageCompiler, photographyCompiler)
import Contexts (photographyCtx, pageCtx, siteCtx,
recentFirstByDisplay)
import qualified Patterns as P
-- ---------------------------------------------------------------------------
-- Rules
-- ---------------------------------------------------------------------------
-- | All photography rules. Called from 'Site.rules' once.
--
-- Order is intentional:
--
-- 1. Co-located assets first (so the photo file is in @_site/@
-- before any page that references it is compiled — Hakyll's
-- dependency tracker handles this anyway, but the surface
-- ordering reads top-down by data flow).
-- 2. Single-photo entries (flat + directory form).
-- 3. Section landing at @/photography/@ — loaded after the
-- photo entries so its @loadAll photographyPattern@ resolves
-- each photo's frontmatter through 'photographyCtx'.
photographyRules :: Rules ()
photographyRules = do
-- A directory is a "series" iff it has @.md@ siblings alongside
-- its @index.md@. Collected once at rule-gen time so the entry
-- rule can branch on series-landing template selection without
-- re-globbing per item.
siblingIds <- getMatches
( "content/photography/*/*.md"
.&&. complement "content/photography/*/index.md"
)
let seriesSlugs :: Set String
seriesSlugs = Set.fromList
[ takeFileName (takeDirectory (toFilePath ident))
| ident <- siblingIds
]
photographyAssetRules
photographyEntryRules seriesSlugs
photographySeriesPhotoRules
photographyLandingRules
photographyMapDataRule
photographyMapPageRule
photographyFeedRule
photographyByYearRules
photographyContactSheetRule
-- ---------------------------------------------------------------------------
-- Assets
-- ---------------------------------------------------------------------------
-- | Co-located assets — the photo file itself, and (Phase 3) the
-- generated @{photo}.exif.yaml@ + @{photo}.palette.yaml@ sidecars.
-- Two patterns are matched in sequence:
--
-- * @content/photography/<asset>@ — flat-single co-located assets
-- * @content/photography/<slug>/<asset>@ — directory-form co-located assets
--
-- Markdown files are excluded from both rules; they're compiled by
-- 'photographyEntryRules' and 'photographyLandingRules'.
--
-- The @.exif.yaml@ / @.palette.yaml@ sidecars produced by Phase 3
-- tooling will be added to the @.gitignore@ defense-in-depth list,
-- but copying them through the asset rule is harmless if a stray
-- one slips into the repo. The build is not load-bearing on
-- sidecar absence.
photographyAssetRules :: Rules ()
photographyAssetRules = do
-- Top-level non-Markdown files (flat-single co-located assets, plus
-- any future top-level photography assets like a landing-page hero).
--
-- Sidecars produced by the Phase 3 Python tooling
-- (@{photo}.exif.yaml@, @{photo}.palette.yaml@) are excluded —
-- they're consumed by Hakyll at build time and have no role in
-- the deployed site.
match ("content/photography/*"
.&&. complement "content/photography/*.md"
.&&. complement "content/photography/*.exif.yaml"
.&&. complement "content/photography/*.palette.yaml"
.&&. complement "content/photography/*.dims.yaml") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- Directory-form entries' co-located assets. Excludes the entry's
-- @index.md@, any other Markdown sibling files (collection photos
-- in Phase 5), and every build-time YAML sidecar (EXIF, palette,
-- dimensions).
match ("content/photography/*/*"
.&&. complement "content/photography/*/index.md"
.&&. complement "content/photography/*/*.md"
.&&. complement "content/photography/*/*.exif.yaml"
.&&. complement "content/photography/*/*.palette.yaml"
.&&. complement "content/photography/*/*.dims.yaml") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- ---------------------------------------------------------------------------
-- Single-photo entries
-- ---------------------------------------------------------------------------
-- | Compile each single-photo entry. Routing follows the essay
-- convention so the URL shape is predictable:
--
-- * @content/photography/<slug>.md@ → @photography/<slug>.html@
-- * @content/photography/<slug>/index.md@ → @photography/<slug>/index.html@
--
-- The @"content"@ snapshot is saved so a future @/photography/feed.xml@
-- (Phase 5) can render the rendered body as feed entry content.
photographyEntryRules :: Set String -> Rules ()
photographyEntryRules seriesSlugs =
match P.photographyPattern $ do
route photoEntryRoute
compile $ do
ident <- getUnderlying
let fp = toFilePath ident
isIndex = takeFileName fp == "index.md"
slug = takeFileName (takeDirectory fp)
isSeriesLanding = isIndex && slug `Set.member` seriesSlugs
template
| isSeriesLanding = "templates/photography-series.html"
| otherwise = "templates/photography.html"
ctx
| isSeriesLanding = seriesCtx
| otherwise = photographyCtx
photographyCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate template ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- | Sibling photos inside a series directory:
-- @content/photography/<series>/<photo>.md@. Compiled with the
-- single-photo template; routed to @<series>/<photo>/index.html@
-- so the URL is the canonical directory form (matches the rest of
-- the photography section's URL shape).
--
-- Series landings (@<series>/index.md@) are handled by
-- 'photographyEntryRules' with the @photographyPattern@ match;
-- they're explicitly excluded here so the two rules don't double-route.
photographySeriesPhotoRules :: Rules ()
photographySeriesPhotoRules =
match ("content/photography/*/*.md"
.&&. complement "content/photography/*/index.md") $ do
route $ customRoute $ \ident ->
-- Drop @"content/"@ prefix and @".md"@ suffix, then append
-- @"/index.html"@ to get directory-style URLs.
let fp = toFilePath ident
rel = drop (length contentPrefix) fp
stripped = take (length rel - 3) rel
in stripped ++ "/index.html"
compile $ photographyCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/photography.html" photographyCtx
>>= loadAndApplyTemplate "templates/default.html" photographyCtx
>>= relativizeUrls
where
contentPrefix = "content/" :: String
-- | Context for series-landing pages. Extends 'photographyCtx' with a
-- @series-photos@ list field that loads the directory's sibling
-- photos (the @<series>/<photo>.md@ files), most-recent-first.
--
-- The @is-series@ const flag lets the consuming template branch on
-- whether to render single-photo chrome (figure + EXIF dl + body)
-- or series chrome (intro + photo grid + body).
seriesCtx :: Context String
seriesCtx =
constField "is-series" "true"
<> listFieldWith "series-photos" photographyCtx loadSeriesChildren
<> photographyCtx
where
loadSeriesChildren parent = do
let ident = itemIdentifier parent
slug = takeFileName (takeDirectory (toFilePath ident))
pat = fromGlob ("content/photography/" ++ slug ++ "/*.md")
.&&. complement
(fromGlob ("content/photography/" ++ slug ++ "/index.md"))
.&&. hasNoVersion
recentFirstByDisplay =<< loadAll pat
-- | Route a photography entry to its public URL. The pattern check on
-- @takeFileName@ distinguishes flat (@content/photography/<slug>.md@)
-- from directory-form (@content/photography/<slug>/index.md@) without
-- re-globbing, since Hakyll has already pre-filtered to entries
-- matching 'P.photographyPattern'.
--
-- Mirrors the essay rule's customRoute (@Site.rules@) but stripped of
-- the dev-mode draft branch — drafts are an essay-only concept right
-- now.
photoEntryRoute :: Routes
photoEntryRoute = customRoute $ \ident ->
let fp = toFilePath ident
fname = takeFileName fp
isIndex = fname == "index.md"
in if isIndex
-- content/photography/<slug>/index.md
-- → photography/<slug>/index.html
then replaceExtension (drop (length contentPrefix) fp) "html"
-- content/photography/<slug>.md → photography/<slug>.html
else "photography/" ++ replaceExtension fname "html"
where
contentPrefix :: String
contentPrefix = "content/"
-- ---------------------------------------------------------------------------
-- Landing page
-- ---------------------------------------------------------------------------
-- | Section landing at @/photography/@. Loads all photo entries
-- resolved against 'photographyCtx' so each card has access to
-- slug / photo-url / captured-display / palette swatches.
--
-- Sorts by display date (creation date, or most-recent revision
-- when the entry has a @revised:@ entry — same ordering authority
-- that essay listings use). Phase 2 will replace this listing with
-- the masonry/grid/chronological mode toggle, but the underlying
-- data feed stays the same — the toggle is a JS layer over the
-- already-rendered grid markup.
photographyLandingRules :: Rules ()
photographyLandingRules =
match "content/photography/index.md" $ do
route $ constRoute "photography/index.html"
compile $ do
photos <- recentFirstByDisplay
=<< loadAll (P.photographyPattern .&&. hasNoVersion)
let ctx =
listField "photos" photographyCtx (return photos)
<> constField "photography" "true"
<> constField "list-page" "true"
<> pageCtx
pageCompiler
>>= loadAndApplyTemplate "templates/photography-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Map data (Phase 4)
-- ---------------------------------------------------------------------------
--
-- Two artifacts together:
--
-- * @/photography/map.json@ — JSON array of pin objects, fetched
-- by @static/js/photography-map.js@ at view time. Built directly
-- from frontmatter; no Python dependency.
-- * @/photography/map/@ — the page that renders the Leaflet
-- viewport. Lightweight HTML; the heavy lifting lives in the JS.
--
-- Privacy: every coordinate is rounded to the precision the author
-- declares in @geo-precision:@ (default @"city"@) BEFORE it leaves
-- this build step. Full-precision coords never reach @map.json@.
-- @geo-precision: hidden@ omits the entry entirely.
-- | Strip a trailing @"index.html"@ component so a Hakyll route
-- like @"photography/foo/index.html"@ becomes @"photography/foo/"@.
-- Used for map.json click-through URLs.
stripIndexHtml :: String -> String
stripIndexHtml r
| suffixMatches = take (length r - 10) r -- 10 = length "index.html"
| otherwise = r
where
suffix = "/index.html" :: String
suffixMatches = suffix == drop (length r - length suffix) r
-- | Round a decimal coordinate to the precision that matches the
-- author's @geo-precision:@ declaration.
--
-- * @exact@: 4 decimal places (~10 m)
-- * @km@ : 2 decimal places (~1 km)
-- * @city@ : 1 decimal place (~10 km) — default
-- * other : treated as @city@
--
-- @hidden@ is handled at the call site by skipping the pin entirely;
-- this function is not consulted in that case.
roundCoord :: String -> Double -> Double
roundCoord prec x =
let n = case prec of
"exact" -> 4
"km" -> 2
"city" -> 1
_ -> 1
scale = 10 ^^ (n :: Int) :: Double
in fromIntegral (round (x * scale) :: Integer) / scale
-- | Extract @[lat, lon]@ from a frontmatter @geo:@ list. Accepts only
-- exactly two numeric entries — anything else returns 'Nothing' so
-- the entry is silently skipped on the map.
parseGeo :: Aeson.Object -> Maybe (Double, Double)
parseGeo meta = case KM.lookup "geo" meta of
Just (Array vec) | V.length vec == 2 ->
case (asDouble (vec V.! 0), asDouble (vec V.! 1)) of
(Just lat, Just lon) -> Just (lat, lon)
_ -> Nothing
_ -> Nothing
where
asDouble (Number n) = Just (Sci.toRealFloat n)
asDouble _ = Nothing
-- | Build a single pin object from a photo entry. Returns 'Nothing'
-- when:
-- * the entry has no @geo:@ frontmatter, or
-- * it has @geo-precision: hidden@, or
-- * the entry has no resolvable route (shouldn't happen for
-- photographyPattern items, but be defensive).
buildPin :: Item String -> Compiler (Maybe Value)
buildPin item = do
let ident = itemIdentifier item
meta <- getMetadata ident
mRoute <- getRoute ident
case (parseGeo meta, lookupString "geo-precision" meta, mRoute) of
(_, Just "hidden", _) -> return Nothing
(Just (lat, lon), prec, Just r) ->
let prec' = fromMaybe "city" prec
rLat = roundCoord prec' lat
rLon = roundCoord prec' lon
fp = toFilePath ident
slug = takeFileName (takeDirectory fp)
title = fromMaybe slug (lookupString "title" meta)
photo = lookupString "photo" meta
-- Trim trailing "index.html" so the click-through URL
-- is the canonical directory form (no implicit redirect).
url = "/" ++ stripIndexHtml r
thumb = case photo of
Just p | not (null p) ->
"/photography/" ++ slug ++ "/" ++ p
_ -> ""
captured = lookupString "captured" meta
in return $ Just $ Aeson.object $
[ "slug" .= slug
, "title" .= title
, "url" .= url
, "lat" .= rLat
, "lon" .= rLon
] ++ (if null thumb then [] else ["thumb" .= thumb])
++ maybe [] (\c -> ["captured" .= c]) captured
_ -> return Nothing
-- | @/photography/map.json@ — JSON array of geo-tagged photo pins
-- for the Leaflet client. Excludes entries with @geo-precision:
-- hidden@ and entries with no @geo:@ frontmatter. Walks
-- 'allPhotoEntries' so series children with their own GPS land
-- on the map alongside top-level photos.
photographyMapDataRule :: Rules ()
photographyMapDataRule =
create ["photography/map.json"] $ do
route idRoute
compile $ do
photos <- loadAll (P.allPhotoEntries .&&. hasNoVersion)
:: Compiler [Item String]
pins <- mapMaybe id <$> mapM buildPin photos
-- LBS.unpack truncates each UTF-8 byte to a Char (Latin-1
-- mode), and Hakyll then re-encodes the String to UTF-8 on
-- write — producing double-encoded mojibake for any non-
-- ASCII title (em-dashes, accents, etc.). Decoding through
-- Text gives Hakyll a String of Unicode code points it can
-- re-encode cleanly.
makeItem $ TL.unpack $ TLE.decodeUtf8 $ Aeson.encode pins
-- ---------------------------------------------------------------------------
-- Map page (Phase 4)
-- ---------------------------------------------------------------------------
-- | @/photography/map/@ — the Leaflet-driven map view. Synthesised
-- page; no Markdown source. The @photography-map@ context flag
-- gates Leaflet CSS / JS loading in @head.html@ and @default.html@,
-- so other photography pages stay lightweight.
photographyMapPageRule :: Rules ()
photographyMapPageRule =
create ["photography/map/index.html"] $ do
route idRoute
compile $ do
let ctx = constField "title" "Map · Photography"
<> constField "photography" "true"
<> constField "photography-map" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/photography-map.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Atom feed (Phase 5)
-- ---------------------------------------------------------------------------
-- | Configuration for the photography-only Atom feed at
-- @/photography/feed.xml@. Distinct from the main @/feed.xml@ so
-- text-primary subscribers don't unexpectedly get image-heavy
-- entries in their reader.
photographyFeedConfig :: FeedConfiguration
photographyFeedConfig = FeedConfiguration
{ feedTitle = "Levi Neuwirth — Photography"
, feedDescription = "New photographs by Levi Neuwirth"
, feedAuthorName = "Levi Neuwirth"
, feedAuthorEmail = "levi@levineuwirth.org"
, feedRoot = "https://levineuwirth.org"
}
-- | Description field for Atom feed entries: prepends an absolute-URL
-- @<img>@ tag (so the photograph displays inline in the reader) to
-- the rendered prose body. Composed ABOVE 'bodyField' so it wins
-- when @$description$@ is consumed by the Atom template.
photographyFeedDescription :: Context String
photographyFeedDescription = field "description" $ \item -> do
let ident = itemIdentifier item
body <- itemBody <$> (loadSnapshot ident "content" :: Compiler (Item String))
meta <- getMetadata ident
let fp = toFilePath ident
isDir = takeFileName fp == "index.md"
slug = takeFileName (takeDirectory fp)
photo = lookupString "photo" meta
imgTag = case (isDir, photo) of
(True, Just p) | not (null p) ->
"<p><img src=\"https://levineuwirth.org/photography/"
++ slug ++ "/" ++ p ++ "\" alt=\"\"></p>\n"
_ -> ""
return (imgTag ++ body)
-- | @/photography/feed.xml@ — Atom feed of the most recent 30 photo
-- entries, with each photograph embedded inline at the top of its
-- entry description.
photographyFeedRule :: Rules ()
photographyFeedRule =
create ["photography/feed.xml"] $ do
route idRoute
compile $ do
photos <- fmap (take 30) . recentFirst
=<< loadAllSnapshots
(P.allPhotoEntries .&&. hasNoVersion)
"content"
let feedCtx =
dateField "updated" "%Y-%m-%dT%H:%M:%SZ"
<> dateField "published" "%Y-%m-%dT%H:%M:%SZ"
<> photographyFeedDescription
<> bodyField "description"
<> defaultContext
renderAtom photographyFeedConfig feedCtx photos
-- ---------------------------------------------------------------------------
-- By-year pages (Phase 5)
-- ---------------------------------------------------------------------------
--
-- @/photography/by-year/@ is the index of years that have photos;
-- @/photography/by-year/<year>/@ lists each year's photos
-- chronologically. Year is taken from @captured:@ frontmatter
-- (when present), falling back to @date:@. Photos with neither
-- field — or with a malformed date — are silently dropped from this
-- surface; they remain visible on the main grid and any tag pages
-- their frontmatter produces.
-- | Extract a four-digit year from a frontmatter @captured:@ or
-- @date:@ field. Returns 'Nothing' when neither is set or both are
-- shorter than four characters.
yearOfPhoto :: Metadata -> Maybe String
yearOfPhoto meta =
let firstFour s = if length s >= 4 then Just (take 4 s) else Nothing
in case lookupString "captured" meta >>= firstFour of
Just yr -> Just yr
Nothing -> lookupString "date" meta >>= firstFour
-- | All by-year rules: collect (year, identifier) pairs once, then
-- build the index page and one page per year.
photographyByYearRules :: Rules ()
photographyByYearRules = do
photoIds <- getMatches (P.allPhotoEntries .&&. hasNoVersion)
pairs <- forM photoIds $ \ident -> do
meta <- getMetadata ident
return $ fmap (\yr -> (yr, ident)) (yearOfPhoto meta)
let yearMap :: Map String [Identifier]
yearMap = Map.fromListWith (++) [(yr, [i]) | (yr, i) <- catMaybes pairs]
-- Years sorted descending so the most recent appear first.
years = map fst $ sortBy (comparing (Down . fst)) (Map.toList yearMap)
photographyByYearIndexRule yearMap years
forM_ years $ \yr -> photographyByYearPageRule yr (yearMap Map.! yr)
-- | @/photography/by-year/@ — top-level index. Lists each year that
-- has photos with the count, linking to the per-year page.
photographyByYearIndexRule :: Map String [Identifier] -> [String] -> Rules ()
photographyByYearIndexRule yearMap years =
create ["photography/by-year/index.html"] $ do
route idRoute
compile $ do
let yearItems =
[ Item (fromFilePath ("year-" ++ yr))
(yr, length (Map.findWithDefault [] yr yearMap))
| yr <- years
]
yrCtx =
field "year" (return . fst . itemBody)
<> field "year-url" (\i -> return $ "/photography/by-year/"
++ fst (itemBody i) ++ "/")
<> field "year-count"
(return . show . snd . itemBody)
ctx =
listField "years" yrCtx (return yearItems)
<> constField "title" "Photography by year"
<> constField "photography" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate
"templates/photography-by-year-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- | @/photography/by-year/<year>/@ — list of photos captured that year.
photographyByYearPageRule :: String -> [Identifier] -> Rules ()
photographyByYearPageRule yr idents =
create [fromFilePath ("photography/by-year/" ++ yr ++ "/index.html")] $ do
route idRoute
compile $ do
photos <- recentFirstByDisplay
=<< mapM (\i -> load i :: Compiler (Item String)) idents
let ctx =
listField "photos" photographyCtx (return photos)
<> constField "title" ("Photography · " ++ yr)
<> constField "year" yr
<> constField "photography" "true"
<> constField "list-page" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate
"templates/photography-by-year.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Contact sheet (Phase 5)
-- ---------------------------------------------------------------------------
-- | @/photography/contact-sheet/@ — alternate view of every photo in
-- a film-strip aesthetic: thin white-bordered frames, frame numbers
-- in the corner, slightly grainy backdrop. Distinct from the main
-- grid views; deep cut rather than primary surface.
--
-- Sort order: chronological by display date (asc). The contact-sheet
-- convention reads top-to-bottom in capture order — a roll of film,
-- not a recency feed. Each frame's index doubles as its frame
-- number. The CSS handles the frame numbering via a CSS counter so
-- we don't have to thread the index through the template.
photographyContactSheetRule :: Rules ()
photographyContactSheetRule =
create ["photography/contact-sheet/index.html"] $ do
route idRoute
compile $ do
-- Reverse the recent-first sort to get oldest-first
-- (capture chronology), matching the contact-sheet
-- convention.
photos <- reverse <$> (recentFirstByDisplay
=<< loadAll (P.allPhotoEntries .&&. hasNoVersion)
:: Compiler [Item String])
let ctx =
listField "photos" photographyCtx (return photos)
<> constField "title" "Contact sheet · Photography"
<> constField "photography" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate
"templates/photography-contact-sheet.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls

View File

@ -32,6 +32,7 @@ import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, c
contentKindField, recentFirstByDisplay, contentKindField, recentFirstByDisplay,
tagLinksFieldExcludingTopSegment) tagLinksFieldExcludingTopSegment)
import qualified Patterns as P import qualified Patterns as P
import Photography (photographyRules)
import Tags (buildAllTags, applyTagRules, sidecarIdentifier, import Tags (buildAllTags, applyTagRules, sidecarIdentifier,
portalIntroField, portalTooltipField) portalIntroField, portalTooltipField)
import Pagination (blogPaginateRules) import Pagination (blogPaginateRules)
@ -51,6 +52,7 @@ homePortals =
, ("Fiction", "fiction") , ("Fiction", "fiction")
, ("Poetry", "poetry") , ("Poetry", "poetry")
, ("Music", "music") , ("Music", "music")
, ("Photography", "photography")
, ("AI", "ai") , ("AI", "ai")
, ("Tech", "tech") , ("Tech", "tech")
, ("Miscellany", "miscellany") , ("Miscellany", "miscellany")
@ -179,8 +181,16 @@ rules = do
route $ gsubRoute "static/" (const "") route $ gsubRoute "static/" (const "")
compile compressCssCompiler compile compressCssCompiler
-- All other static files (fonts, JS, images, …) -- All other static files (fonts, JS, images, …). Build-time
match ("static/**" .&&. complement "static/css/*") $ do -- sidecars produced by the Python tooling (.dims.yaml, .exif.yaml,
-- .palette.yaml) are excluded — they're consumed by Hakyll at
-- compile time and have no role in the deployed site.
match ( "static/**"
.&&. complement "static/css/*"
.&&. complement "static/**/*.dims.yaml"
.&&. complement "static/**/*.exif.yaml"
.&&. complement "static/**/*.palette.yaml"
) $ do
route $ gsubRoute "static/" (const "") route $ gsubRoute "static/" (const "")
compile copyFileCompiler compile copyFileCompiler
@ -344,17 +354,21 @@ rules = do
>>= loadAndApplyTemplate "templates/default.html" essayCtx >>= loadAndApplyTemplate "templates/default.html" essayCtx
>>= relativizeUrls >>= relativizeUrls
-- Static assets co-located with directory-based essays (figures, data, PDFs, …) -- Static assets co-located with directory-based essays (figures, data, PDFs, …).
-- Build-time dimension sidecars are excluded; they're consumed by
-- Filters/Images.hs at compile time, not shipped.
match ("content/essays/**" match ("content/essays/**"
.&&. complement "content/essays/*.md" .&&. complement "content/essays/*.md"
.&&. complement "content/essays/*/index.md") $ do .&&. complement "content/essays/*/index.md"
.&&. complement "content/essays/**/*.dims.yaml") $ do
route $ gsubRoute "content/" (const "") route $ gsubRoute "content/" (const "")
compile copyFileCompiler compile copyFileCompiler
-- Static assets co-located with draft essays (dev-only). -- Static assets co-located with draft essays (dev-only).
when isDev $ match ("content/drafts/essays/**" when isDev $ match ("content/drafts/essays/**"
.&&. complement "content/drafts/essays/*.md" .&&. complement "content/drafts/essays/*.md"
.&&. complement "content/drafts/essays/*/index.md") $ do .&&. complement "content/drafts/essays/*/index.md"
.&&. complement "content/drafts/essays/**/*.dims.yaml") $ do
route $ gsubRoute "content/" (const "") route $ gsubRoute "content/" (const "")
compile copyFileCompiler compile copyFileCompiler
@ -464,6 +478,12 @@ rules = do
compositionCtx compositionCtx
>>= relativizeUrls >>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Photography — single-photo entries, asset copy, and section landing.
-- See build/Photography.hs and PHOTOGRAPHY.md for the design.
-- ---------------------------------------------------------------------------
photographyRules
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Blog index (paginated) -- Blog index (paginated)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -573,13 +593,14 @@ rules = do
-- Load every content item once, then partition by primary portal -- Load every content item once, then partition by primary portal
-- so each shelf draws from a pre-filtered list rather than -- so each shelf draws from a pre-filtered list rather than
-- re-scanning the whole corpus eight times. -- re-scanning the whole corpus nine times.
essays <- loadAll (allEssays .&&. hasNoVersion) essays <- loadAll (allEssays .&&. hasNoVersion)
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion) posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion) fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
poetry <- loadAll (allPoetry .&&. hasNoVersion) poetry <- loadAll (allPoetry .&&. hasNoVersion)
music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion) music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
let allContent = essays ++ posts ++ fiction ++ poetry ++ music photos <- loadAll (P.photographyPattern .&&. hasNoVersion)
let allContent = essays ++ posts ++ fiction ++ poetry ++ music ++ photos
:: [Item String] :: [Item String]
tagged <- mapM (\i -> (,i) <$> primaryPortalOf i) allContent tagged <- mapM (\i -> (,i) <$> primaryPortalOf i) allContent
let itemsByPortal :: Map.Map String [Item String] let itemsByPortal :: Map.Map String [Item String]

View File

@ -78,9 +78,33 @@ expandTag t =
let segs = wordsBy (== '/') t let segs = wordsBy (== '/') t
in [ intercalate "/" (take n segs) | n <- [1 .. length segs] ] in [ intercalate "/" (take n segs) | n <- [1 .. length segs] ]
-- | Top-level tags that own a section URL outside the tag system, and
-- therefore must NOT be created as tag pages — doing so would
-- collide with a section landing route. The literal @"photography"@
-- is the only one currently affected: every photo's @tags:@ list
-- begins with the bare @"photography"@ portal tag (per the section's
-- convention), and 'tagIdentifier' would route that to
-- @"photography/index.html"@ — already owned by
-- @photographyLandingRules@.
--
-- Sub-tags (@photography/landscape@, @photography/film@, …) are
-- unaffected; they keep their tag pages because no section landing
-- claims those URLs.
--
-- Other portal tags (@music@, @poetry@, @fiction@, …) don't appear
-- here because their content types don't currently feed
-- 'tagIndexable', so the top-level tag never enters the tag system.
-- Add to this set if that ever changes.
sectionOwnedTopLevelTags :: [String]
sectionOwnedTopLevelTags = ["photography"]
-- | All expanded tags for an item (reads the "tags" metadata field). -- | All expanded tags for an item (reads the "tags" metadata field).
-- Filters out any 'sectionOwnedTopLevelTags' to prevent route
-- collisions with section landings.
getExpandedTags :: MonadMetadata m => Identifier -> m [String] getExpandedTags :: MonadMetadata m => Identifier -> m [String]
getExpandedTags ident = nub . concatMap expandTag <$> getTags ident getExpandedTags ident =
filter (`notElem` sectionOwnedTopLevelTags) . nub . concatMap expandTag
<$> getTags ident
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------

View File

@ -41,11 +41,11 @@ constraints: any.Glob ==0.10.2,
any.cborg ==0.2.10.0, any.cborg ==0.2.10.0,
any.cereal ==0.5.8.3, any.cereal ==0.5.8.3,
any.citeproc ==0.8.1.1, any.citeproc ==0.8.1.1,
any.colour ==2.3.6, any.colour ==2.3.7,
any.commonmark ==0.2.6.1, any.commonmark ==0.2.6.1,
any.commonmark-extensions ==0.2.5.6, any.commonmark-extensions ==0.2.5.6,
any.commonmark-pandoc ==0.2.2.3, any.commonmark-pandoc ==0.2.2.3,
any.comonad ==5.0.9, any.comonad ==5.0.10,
any.conduit ==1.3.6.1, any.conduit ==1.3.6.1,
any.conduit-extra ==1.3.8, any.conduit-extra ==1.3.8,
any.containers ==0.6.7, any.containers ==0.6.7,
@ -70,7 +70,7 @@ constraints: any.Glob ==0.10.2,
any.distributive ==0.6.2.1, any.distributive ==0.6.2.1,
any.djot ==0.1.2.3, any.djot ==0.1.2.3,
any.dlist ==1.0, any.dlist ==1.0,
any.doclayout ==0.5, any.doclayout ==0.5.0.1,
any.doctemplates ==0.11.0.1, any.doctemplates ==0.11.0.1,
any.easy-file ==0.2.5, any.easy-file ==0.2.5,
any.emojis ==0.1.4.1, any.emojis ==0.1.4.1,
@ -85,7 +85,7 @@ constraints: any.Glob ==0.10.2,
any.ghc-prim ==0.10.0, any.ghc-prim ==0.10.0,
any.gridtables ==0.1.1.0, any.gridtables ==0.1.1.0,
any.haddock-library ==1.11.0, any.haddock-library ==1.11.0,
any.hakyll ==4.16.8.0, any.hakyll ==4.16.7.1,
hakyll -buildwebsite +checkexternal +previewserver +usepandoc +watchserver, hakyll -buildwebsite +checkexternal +previewserver +usepandoc +watchserver,
any.half ==0.3.3, any.half ==0.3.3,
any.hashable ==1.4.7.0, any.hashable ==1.4.7.0,
@ -181,7 +181,7 @@ constraints: any.Glob ==0.10.2,
any.text ==2.0.2, any.text ==2.0.2,
any.text-conversions ==0.3.1.1, any.text-conversions ==0.3.1.1,
any.text-icu ==0.8.0.5, any.text-icu ==0.8.0.5,
any.text-iso8601 ==0.1.1, any.text-iso8601 ==0.1.1.1,
any.text-short ==0.1.6.1, any.text-short ==0.1.6.1,
any.th-abstraction ==0.6.0.0, any.th-abstraction ==0.6.0.0,
any.th-compat ==0.1.7, any.th-compat ==0.1.7,
@ -232,4 +232,4 @@ constraints: any.Glob ==0.10.2,
any.yaml ==0.11.11.2, any.yaml ==0.11.11.2,
any.zip-archive ==0.4.3.2, any.zip-archive ==0.4.3.2,
any.zlib ==0.7.0.0 any.zlib ==0.7.0.0
index-state: hackage.haskell.org 2026-04-02T12:38:26Z index-state: hackage.haskell.org 2026-04-30T12:51:47Z

View File

@ -1,28 +0,0 @@
---
title: "The Specification Dilemma"
date: 2026-04-20 # required; used for ordering, feed, and display
abstract: > # optional; shown in the metadata block and link previews
As we approach AI, the increase in the ability of Artificial Intelligence models to infer a robust specification from a sparse prompt will lead to a devastating trend of homogeneity. We argue that this is the primary concern regarding the interaction of AI and human intelligence, rather than blanket claims that "AI reduces human cognitive ability."
tags: # optional; see Tags section
- ai
- tech
# Epistemic profile — all optional; the entire section is hidden unless `status` is set
status: "Draft" # Draft | Working model | Durable | Refined | Superseded | Deprecated
confidence: 85 # 0100 integer (%)
importance: 5 # 15 integer (rendered as filled/empty dots ●●●○○)
evidence: 3 # 15 integer (same)
scope: civilizational # personal | local | average | broad | civilizational
novelty: idiosyncratic # conventional | moderate | idiosyncratic | innovative
practicality: high # abstract | low | moderate | high | exceptional
confidence-history: # list of integers; trend arrow derived from last two entries
---
There are at least two distinct ways to reduce the search space over which AGI will have to operate. The first involves a harmonious interaction of agent and human, not transactional in origin, not fully autonomous nor fully human-driven, but rather collaborative in nature - the agent augments the capacity of the human, just as any other good tool for thought does, by working within the scope of something well specified and ideated upon. This is not to say that the agent cannot have a place in such planning, but rather that the human is ultimately the driver of the actions and tasks, defining the scope of what is to be done in as much detail as possible without being the one to actually do it.
The second is a starkly different picture: the human, who only has a vague idea of their own intentions and has not thought over this much, jumps straight into the work of creating via the agent, without thought on the nature of their specification. The agent is forced to infer the majority of the details, make the majority of the decisions, and the human makes none. We may already be seeing this with [Vibe Coding](https://en.wikipedia.org/vibe-coding), but as we continue scaling to AGI, I forsee it happening widely across all sorts of domains^[Some have argued of late that ["only the humanities will survive"](TODO: find source), but I am not so optimistic. If AGI does interact with us in the latter reductive manner that I describe here, then the humanities will be stripped of anything that actually makes them human, at least for the majority of participants.].
These two represent diverging definitions of *intelligence*, both for the models and for their users, or, if you prefer, their collaborators. The first is a definition of intelligence that depends both on what one has the capacity to specify and what one has the capacity to see through. The latter depends wholly on what one has the capacity to see through, and places even more emphasize on this metric than the first, for the amount of recalibration and prompt adjustment necessary to build a specification continuously throughout the duration of a task is always greater than paying the upfront cost of developing a strong specification from the onset. [We the programmers have known this for years](https://en.wikipedia.org/wiki/Hofstadter%27s_law). The first future is chiefly preferable, and the second, which seems to be the unfortunate reality we are racing towards, is not only a realization of the worst affect that AI could have on our cognition, but may also unnecessarily constrain the breadth of intelligence that AGI can achieve.
## Compression *is* Intelligence
How many bits constitute a project? The question is impossible to answer.

View File

@ -0,0 +1,9 @@
The thing you miss most isn't meetings. It's the sound of someone else figuring something out.
Three years into remote work, I've stopped noticing the absence of conference rooms. What I notice is that my desk faces a wall. Not metaphorically—the monitor is three feet from drywall, and the only sound during "deep work" hours is the refrigerator humming in the kitchen. I've traded the ambient noise of a floor full of engineers for the compressor cycling on my refrigerator, and somewhere in that trade, I stopped learning in the way I used to learn.
At my last office, I sat near a senior engineer named David who debugged out loud. This was in 2019, back when we still had an office. He wasn't performing for anyone—he simply thought audibly, and his thinking happened to be loud enough to carry across the half-empty desk area. I'd catch fragments: "so if that's null here, then... wait, no, the race condition's in the connection pool, not the handler." I'd half-listen while writing my own code, and within a week I'd internalized his debugging pattern—the way he traced null checks, the moment he switched from logging to reading source. I didn't learn this from a PR review or a pairing session; I learned it by proximity, the way you pick up a regional accent.
The post will argue that remote work's real cost is not productivity metrics or team cohesion, but something more difficult to measure: the erosion of ambient professional development, those fragments of expertise that used to drift across an office and become part of your mental inventory without you noticing. The question is whether this loss matters, and if it does, what we do about it when the office isn't coming back.

View File

@ -0,0 +1,9 @@
The Figma file is paused on slide fourteen — the navigation prototype that David spent three weeks refining, the one he's been quietly confident about. Twenty-three people are on the call. A pause stretches long enough to become its own element in the composition. David's camera is off, which means he's either checking his phone or staring at his own face in a way that makes him want to disappear. No one speaks.
This is not awkwardness. Awkwardness would be a start — a signal that something in the room has weight. What happened instead is structural: the pause has nowhere to land, so it dissolves into chat threads that no one will read and private pings that say nothing. David will ship the navigation in the morning.
There was a time when critique meant something because it cost something. The best critiques I received early in my career happened in person, in rooms where the air conditioning was too loud and someone's coffee had gone cold. You could feel the temperature shift when someone picked apart your logic — not their tone, which is easy to perform, but the specific quality of silence when a senior designer reconsidered their own position in real time. That awkwardness was the mechanism, not a side effect. It meant someone was willing to sit in the discomfort of being wrong alongside you.
What remote work removed was not a convenience but a structure. The room held the awkwardness so that critique could be sharp, so that someone could say *this isn't working* without the labor of softening it into something digestible. Without that container, feedback becomes an act of maintenance rather than craft. It becomes the work of keeping peace in a distributed silence.

View File

@ -0,0 +1,7 @@
The debate over where knowledge work gets performed has never actually been about geography. This will strike anyone who has sat through a corporate all-hands in the past eighteen months as either self-evident or deliberately contrarian, depending on how many such meetings they have survived. The conventional framing presents the question as one of flexibility versus productivity, individual preference against collective output, employee autonomy in tension with managerial oversight. Senior HR professionals have learned to navigate these conversations with the diplomatic language of "employee choice" and "manager discretion," while their counterparts in the C-suite invoke metrics they cannot produce to justify mandates they did not need a year prior. The rhetorical structure is familiar enough to feel natural, which is precisely what makes it worth examining.
What becomes visible when one traces the actual fault lines in these disputes is something other than a disagreement about the optimal configuration of desks and Zoom links. The organizations most intent on mandating physical presence are not, by any measure, struggling to maintain baseline operational function. Their systems work. Their clients are served. The imperative to return is not arising from failure but from something more fundamental—a perceived erosion of the mechanisms through which institutional authority has traditionally been exercised. This is not a problem that can be solved by offering more generous transit benefits or reconfiguring the floor plan to include more collaborative zones.
The language HR leaders reach for when discussing these matters reveals the bind. Phrases like "guidance rather than mandate" and "managerial judgment within guardrails" attempt to thread a needle that may no longer have an eye. The underlying question being asked, in boardrooms and offsites and the quiet conversations that happen between sessions, is not whether a given employee can productively work from a location of their choosing. It is whether the people who hold titled authority over organizational units retain the right to make that determination, and by what warrant that right persists when the material conditions that once justified it have shifted beneath it. The hybrid debate is a proxy war over managerial legitimacy—over who decides what work looks like, and by what authority they claim the right to decide.

View File

@ -0,0 +1,11 @@
The sandwich board outside Hernandez Hardware has been there since 2015, weathered and warped, advertising $4.99 box nails that probably cost more now but nobody's changed the sign. Three blocks up, in the brick building with the blue awning that used to be a shoe repair shop, Lisa Chen runs a tax preparation service out of two rooms upstairs—she's been there eleven years. Between them, the coffee shop on the corner just changed hands for the second time in three years.
This is Maple Street in Riverside, or maybe it's Main. It doesn't matter. The point is the rhythm of it: the hardware store that opens at seven-thirty, the tax office where people wait with their receipts in manila envelopes, the café that cycles through owners like seasons. These places survived the first year of the pandemic, when half the storefronts on this block went dark. They survived the second year, when everyone said downtown was dead. What they're surviving now is something else entirely.
The conversation about remote work usually happens in a different language—productivity metrics, talent retention, hybrid schedules. Nobody at the hardware store talks that way. Elena Vargas, who runs Hernandez Hardware with her husband, doesn't have a remote work policy because there is no remote work at a hardware store. You cannot sell nails over Zoom.
Here's what I keep coming back to: the people who left downtown in March 2020 never really came back. They kept their jobs, they just stopped coming into the office. Meanwhile, everyone who makes their life function—the ones who fix their plumbing, ring up their groceries, deliver their packages—never stopped showing up. That's the part nobody wants to say out loud.
Remote work works great. For some people. The rest of us just keep the lights on.

View File

@ -0,0 +1,13 @@
The child's voice comes through the door like a weather event: high, seamless, indifferent to what it interrupts. I am on a call with three men in different cities who cannot see that my hand has gone to the doorknob, that I am already half-out of my chair. The door opens. My daughter is holding a cup of water she does not want, only wanted to give me, and she stands there in her underwear with a stain on it I will later identify as marker.
The men are discussing Q3 projections. One of them has a shelf behind him, the kind with decorative books that are never read. I am watching my daughter tip the cup toward the floor to see what happens, and the doorknob is warm in my hand because she has been holding it.
My manager pauses. He is waiting for me to speak. I have not muted myself, which is a mistake, but the camera stays on because I've learned that the camera staying on is the cost of looking present. My daughter sits down on the carpet, still holding the cup, and the men continue talking about projections. I think: this is not a disruption. This is my life in the same frame as their shelf with its unread books.
I will take notes during this call and review them after she falls asleep, which means I will be awake until 1 a.m. This is not a complaint. It is the shape of what happened when the commute ended but the work did not. What changed was who could see it.
Before, I left in the morning and my daughter went to the place where other people watched her, and no one at my workplace had to know about it because it happened in a different geography. They saw me arrive on time with my coffee. They did not see who had gotten her dressed, or the negotiation over shoes, or the twenty minutes she screamed because the wrong sweater. Then I stayed home and they saw my kitchen, sometimes her walking behind me, sometimes the sound of something falling, and somehow this seeing made them think the work had vanished. The infrastructure dissolved—backup care, emergency coverage, the assumption that someone, somewhere was being paid to do this. The logic was: if you can do it while caring for a child, why should we pay for the care?
The work had not vanished. Only the seeing had.

View File

@ -0,0 +1,7 @@
A 2023 survey of 500 remotefirst firms reported that 84% have a formal onboarding program. That sounds like a solid investment in new hires. Yet only 19% of those programs include structured mentorship beyond the first week, and just 12% allocate budget for ongoing skillbuilding during the first three months. In other words, the headline figure masks a quiet transfer of onboarding costs from the employer to the employee.
Remotefirst companies treat onboarding as a oneoff event rather than an ongoing process, and the financial incentive to skimp is clear: firm pays a fixed salary while the employee bears the hidden penalty of slower skill acquisition and a thin professional network. In a traditional office, a manager can pair a new analyst with a senior colleague and walk them through key procedures, checking their work in real time. When everyone is distributed, those informal touchpoints disappear unless the company deliberately invests in them. Data from the same survey shows that only one in five remotefirst firms assigns a dedicated mentor for the first ninety days, and fewer still schedule regular checkins beyond HR paperwork. The result is a silent gap: junior staff learn by trial and error, often repeating mistakes that a brief intro session would have prevented.
If youre a recent graduate stepping into your first remote role, the implication is straightforward: dont assume the companys onboarding will equip you for success. Ask upfront who your point of contact is for the first month, request a written skilldevelopment plan, and proactively seek virtual coffee chats with peers a year ahead of you. The burden of building your own network is real, but you can shift it by asking. Companies that underinvest rarely volunteer assistance, so the responsibility falls to you—to demand support that accelerates your growth.

View File

@ -0,0 +1,7 @@
"The public realm is the space of appearance, in which each person appears to others and to oneself." — Hannah Arendt
When commentators invoke this sentence to deplore remote labor, they assume the office is the only stage where one can be seen. The phrase “space of appearance,” however, was never a description of a physical address; it named the political condition whereby actions become visible to peers. To claim that working from a kitchen table empties that space is to confuse a medium with the mode of visibility itself. What has actually changed is not the disappearance of appearance but the temporal rhythm through which it occurs: exchanges that once hinged on synchronous presence now drift across hours, days, even weeks. That drift began with the earliest electronic mail, when scholars first could reply at leisure rather than in real time. The debate over “remote work” thus catches a much older transformation in its net, and mistakes the net for the river.
The industrial vocabulary of “remote” suggests a binary—here versus there, work versus home—and invites us to measure quantity of work in the old factory sense. Yet the deeper shift is the asynchronous restructuring of institutional life, a process that began with the advent of email in the eighties, accelerated through learning management systems, and now extends to virtual colloquia and digital peer review. Its stakes are not how much scholars produce in a given day but whether the temporality of intellectual exchange remains communal or becomes an archipelago of isolated transmissions. The conversation about remote work, framed as a problem of quantity, obscures the more unsettling question: what happens to scholarly community when presence is no longer measured in minutes, but in the asynchronous drift of messages? And this shift touches every corner of academic life right now.

View File

@ -0,0 +1,13 @@
The break room at the distribution center smells like industrial cleaner and burnt coffee. It's 5:47 on a Tuesday, and Marcus Delano is finishing his third shift as a warehouse selector. He's heard the news stories about the remote work debate—the return-to-office fights at tech companies, the think pieces about hybrid schedules. He has thoughts.
"They're fighting over who has to sit in a chair," he says, not looking up from his phone. "Both sides act like the chair is the problem."
This observation, offered without malice in a fluorescent-lit room where the overhead lights hum at a frequency designed to keep workers awake, captures something that gets lost in the endless discourse about the future of work. The debate over remote versus office has consumed business pages, podcast episodes, and management conferences for years now. But it remains, fundamentally, an argument between two groups of people who have the same thing: jobs that could theoretically be done from anywhere, if technology and corporate policy allowed it. The discourse treats this as a universal question—when "everyone" debates remote work—but the word everyone excludes roughly two-thirds of the American workforce. The majority of workers in this country cannot work from home because their jobs require physical presence: building things, moving things, caring for people, serving customers face-to-face. They don't have a side in this argument because they were never invited to the negotiation.
Delano is thirty-four, married with two kids, and has worked at this facility for six years. He selects roughly four hundred items per hour, pushing a cart through aisles organized by alphanumeric code, his phone tracking a daily step count that hovers around fifteen thousand. The remote-work debate, he suggests, is strange because both sides seem to agree on something: that work should be a matter of choice, that the ability to work remotely is a kind of freedom worth fighting for. What's odd, he says, is that neither side seems to notice they're arguing about the same thing—a desk, a chair, the question of where a person sits—while the larger question of what work should mean goes untouched.
He finishes his coffee. The shift change begins in thirteen minutes, and the floor needs prepped for overnight orders. He has never worked from home. His father was a plumber. His sister is a nursing aide at the hospital across town. None of them have been asked about their preferred work arrangement, because the question does not apply to labor that cannot be digitized.
This is what gets obscured in the remote-work debate: it is a conversation between two bubbles, and everyone else is just trying to get through the day.

View File

@ -0,0 +1,19 @@
Last week, a client I've worked with since 2017 asked if I could hop on a call at 9pm on a Saturday. "Since you're remote anyway," they said, the way people say it now—treating my home office like a convenience store that's never not open. This wasn't a thing before 2020.
The request itself would have been unusual enough to warrant explanation, but now it's just... how remote work works? Except it isn't—it's how people who've only been remote for four years think it works. They came into this arrangement with assumptions shaped by pandemic-era emergency measures, and those assumptions have become the new baseline.
I've been doing this for fifteen years. Before remote work was a corporate initiative, before it was something you put on a resume, I was already here—figuring out the hard costs of a standing desk in a one-bedroom apartment, calculating what my health insurance actually cost versus what an employer would cover. Those calculations don't show up in any "remote work tips" article because those articles weren't written yet.
The discourse treats this like something new. The discourse is not for me.
For people like me—freelancers, contractors, solo consultants in creative and technical fields—remote work was never a lifestyle choice or a productivity hack. It was the only option that existed. I didn't "go remote" in 2020. I've been remote since before the phrase meant anything, and I certainly wasn't looking for tips on how to make it work from people who were just trying to survive a pandemic while keeping their jobs.
What the pandemic did was put everyone else in my office. And like any good tenant, they brought their own expectations about how the space should be run—expectations built on three years of emergency remote work, not fifteen years of building a sustainable practice from scratch.
The ergonomics discourse alone is enough to make me tired. Everyone's an expert now. Everyone has an opinion about standing desks and blue light glasses and the "right" way to set up a home office, as if I haven't been iterating on this for over a decade. As if I don't know exactly what my setup costs, down to the electricity bill.
Here's what changed: rates are harder to maintain. Client expectations around availability have shifted. The vocabulary around this work—words that used to describe my actual business—has been colonized by people who've never had to bid on a project while explaining why their home office isn't a tax write-off.
That's the thesis here. The pandemic didn't change my work. It changed everyone else's—and then they wrote about it like they'd discovered something I was still learning.

View File

@ -0,0 +1,7 @@
Most founders who claim to be remotefirst are running a distributed office, not a remote company. They hang a banner on the wall of their Slack workspace, declare “were remotefirst,” and then default to a schedule that mirrors a downtown HQ: morning standups, midday syncs, and endofday video huddles that require everyone to be online at the same hour. The label sounds modern, but the operational habits underneath it are still rooted in a collocated mindset.
The practical effect is simple: every decision that could wait a day gets pushed into a live call, and every message that could be asynchronous is answered within minutes. Teams adopt the same tooling—Slack, Zoom, Notion—but treat those tools as a digital conference room rather than a message bus that can be read on each person's own time zone. The result is a constant, lowgrade pressure to be present, which burns out early hires and blindsides founders who think they have solved the location problem.
Remotefirst and asyncfirst are not synonyms. The former describes where work happens; the latter describes how work moves between people. When a seedstage startup confuses the two, it pays a hidden tax: coordination drag that can stretch six to twelve months before anyone notices the slowdown. Meetings pile up, contextswitching increases, and the team spends cycles aligning schedules instead of shipping product. The fix is not a new tool but a design decision to treat communication as a delayed, written medium by default, reserving synchronous sessions for truly collaborative moments. Founders who recognize the difference early can redirect meeting time to focused work, cut flood of Slack threads, and stop equating activity with progress. A team that works async ships a prototype in the same week it would otherwise spend aligning calendars, and that edge grows as product matures.

View File

@ -0,0 +1,13 @@
Three days. That's how long it took me to notice that the database migration had stalled. In an office, I would have seen it in the first hour—someone's screen would have been stuck on an error log, their keyboard would have gone quiet, and I would have leaned over and asked what was happening. Instead, I was in a video call about something else entirely when Alex finally mentioned it, almost as an aside. "Oh, the migration thing. I hit a blocker on Tuesday. Been working around it."
I could feel my stomach drop.
The thing is, Alex had mentioned concerns about that migration in our group call the previous week. I remembered now—I'd been half-listening, nodding along while triaging my inbox, and I'd said something like "keep me posted" before moving to the next agenda item. In an office, I would have caught the note of frustration in his voice. I would have seen him still at his desk when everyone else had gone to lunch. I would have walked by and asked.
We didn't lack for tools. We had Slack, we had documentation, we had a weekly update template that everyone filled out religiously. What we didn't have was a way to notice what wasn't being said.
This is the trap of remote team management. The conversation about remote work has been dominated by tooling—Which chat platform? Which video software? How often should you meet?—as if the problem were logistical. It isn't. The problem is that so much of what makes a team functional happens below the threshold of explicit communication, and remote work strips that away without asking permission. In an office, you absorb information passively: the colleague who didn't laugh at the joke, the developer who left early three days in a row, the design decision that everyone quietly disagreed about but no one escalated. Remote work demands that you become intentional about noticing what you used to just see.
The thesis isn't that tools and rituals don't matter. They do—they're the scaffolding that holds a distributed team together. But they are necessary, not sufficient. The real work is internal. It's the discipline of asking questions whose answers you're not sure you want to hear. It's the humility to admit that your team is constantly sending signals you're missing, not because they're failing to communicate but because you're not paying attention in the right way. And it's the willingness to sit with that discomfort, over and over, without inventing a dashboard to make it go away.

View File

@ -0,0 +1,9 @@
"The thing is," she told me, "I used to leave the office at seven and my manager would see me carrying my bag out. Now I work until midnight and my husband just thinks I'm scrolling on my phone."
The woman, who asked not to be named because she signed a non-disparagement agreement, described a kind of labor that leaves no trace. No badge swipe at 11 p.m. No security guard nodding as she exits the parking garage. No row of darkened desks to prove she was there. Her employer, a mid-sized logistics company that shifted to remote operations three years ago, had technically closed at 6 p.m. for over a decade. But the culture of after-hours availability, once contained by the building's physical boundaries, had simply migrated home. The work did not stop at the door. It followed her into the kitchen, onto the couch, and eventually into the space between waking and sleeping that she could not quite name.
This is the quiet mechanism by which remote labor has rewritten the geometry of wage theft. For decades, the physical office served as an involuntary witness to exploitation. Overtime violations left paper trails: badge swipes, elevator logs, security footage. Workers compensation claims documented injuries from overwork. The union grievance that cited a supervisor's signature on a mandatory Saturday shift carried the weight of a paper trail. The violations were visible because the workers were visible.
Now that visibility has dissolved. A worker responding to emails at 1 a.m. generates no timestamped record for a labor board to audit. A misclassified employee working from a bedroom in Phoenix performs the same labor as a full-time employee in Chicago, but the power differential has no physical manifestation. The boss cannot see them. The state cannot see them. And increasingly, neither can the worker themselves.

View File

@ -0,0 +1,13 @@
The dinner was supposed to be at River Café, which meant I had ironed a shirt. This was March 2020, and the client—I'll call him David, though that wasn't his name—was in town from London. I'd been chasing him for eight months. He was the kind of prospect other reps whispered about in the way people whisper about someone who might be famous: not because he'd done anything, but because everyone agreed something would happen with him eventually.
The email canceling arrived at 2:47 PM. Something about travel restrictions. Something about reevaluating priorities. I remember standing in my kitchen, still holding the iron, watching the steam rise and thinking: this is fine. We can do this over Zoom.
You know what I'm going to say next, don't you? You're already nodding. The dinner was never really about dinner.
What I didn't understand then—what none of us understood, I think, standing in our separate kitchens with our irons still warm—was that I'd spent the previous eleven years learning to perform a very specific kind of magic. Not over the phone or in slides, but in rooms. The magic was about room presence: how you enter, where you sit, the way your silence lands differently than other people's silence. I'd been studying this without ever calling it study.
The physical room was the instrument. I don't know how else to say it without sounding like I'm mourning something embarrassing, so I'll just say it: I was a musician whose instrument got taken away, and for two years I pretended the synthesizer they handed me was the same thing.
The discourse around remote work has been so determined to be forward-looking that it's refused to admit what we all actually lost. Not productivity. Not flexibility. But a particular, strange craft that required bodies in rooms together—a dependency we should have been honest about from the start.

View File

@ -0,0 +1,9 @@
The second-graders had forgotten how to pass a ball.
Not a complex ball—a playground ball, the kind you buy in bulk from a catalog. We spent three weeks on it that spring. Passing in a circle. Passing while walking. Passing without talking, then passing with specific phrases: "Ready?" "Got it." "My turn." The unit was called "Team Building," though the real work beneath that label was teaching twelve children how to occupy the same space without touching each other, how to read the body language of the person holding the ball, how to wait without焦虑—not exactly anxiety but something adjacent to it, a kind of low-grade social panic that six-year-olds carry in their shoulders.
This did not translate to remote. I cannot say it more plainly than that. We tried. I held up a ball to my laptop screen and watched seventeen faces stare back at me, waiting for instructions on how to feel like a group. The bandwidth could not carry the thing that happens when bodies learn together in a room—the way Marcus finally stopped flinching when the ball came toward him, the way Priya started saying "good pass" unprompted, the way the whole unit was not really about balls at all but about something harder to name.
The white-collar world is having its argument now. Offices versus home. Productivity versus culture. The people who missed the water cooler and the people who found it toxic, both sides certain their experience is universal. But the rest of the economy already ran this experiment. We called it school. The results were messy and obvious in ways that had nothing to do with test scores, and they were ignored because the subjects were children—a population that does not vote, does not unionize effectively, and cannot file economic impact reports about their own attention spans.

View File

@ -0,0 +1,9 @@
On the fourth floor of Lakewood Regional Medical Center, a 310-bed community hospital in the upper Midwest, the utilization review team occupies what was once a storage closet. Two analysts work from home permanently; a third, the team lead, comes in Tuesdays for meetings that could have been emails. Their monitors face the hallway through a interior window, displaying a dashboard of bed occupancy and payer authorization status. The room has a minifridge and a Keurig, the institutional markers of a department that has accepted it will not return to its pre-2020 configuration.
Walk thirty feet down the corridor, through a fire door that clicks shut behind you, and you reach the clinical documentation improvement office. Six cubicles, wired phones, a supervisor who has never worked remotely and does not expect to. These are the people who flag chart deficiencies for coders, who sit in on physician queries, whose work requires them to be in the building when the physicians are. Their job has not substantively changed since March 2020, except that the hospital bought them better chairs.
The two teams are administratively adjacent but operationally diverging. Utilization review has become a remote-capable function through a combination of electronic health record integration, payer portal access, and the administrative distance that authorization management naturally maintains from bedside activity. Clinical documentation remains irreducibly on-site because it depends on real-time access to the medical record, informal hallway conversations with providers, and the physical presence of the chart itself.
This separation is not unique to Lakewood. Across mid-sized hospitals, a structural bifurcation is occurring: the administrative infrastructure that processes, schedules, authorizes, and bills for healthcare is quietly decoupling from the clinical infrastructure that delivers it. The portion of hospital labor that can be performed remotely—scheduling, billing, revenue cycle management, utilization review, telehealth triage—is accelerating away from the portion that cannot. What is emerging is a within-industry class division that labor policy has not yet mapped, much less addressed. The remote workforce is growing in numbers and visibility; the on-site workforce is shrinking in institutional voice. These trends are not parallel. They are converging toward a structural reordering that the industry has not停下来 named.

View File

@ -0,0 +1,9 @@
"Hybrid work arrangements will no longer be permitted effective September 3rd. All attorneys and staff are expected to report to their designated office location a minimum of four days per week, with flexibility granted at the discretion of practice group leadership."
This is how Whitfield, Castellano & Partners LLP, a firm of approximately 1,100 attorneys across fourteen offices, opened its return-to-office memorandum in late June—a document that has since been circulated among BigLaw associates with the kind of weary resignation typically reserved for annual billing target adjustments. The memo goes on to reference "collaborative culture," "mentorship opportunities," and "the firm's commitment to client service excellence." It does not mention productivity, because the productivity argument has always been a tertiary concern dressed in the language of principle.
The actual purpose is legible in what the memo does not say: nothing about output metrics, nothing about quality control, nothing about any measurable decline in work product since the pandemic forced the profession into remote or hybrid arrangements. What the memo does say, read carefully, is that the firm's economics depend on a specific ratio of associates to partners, and that ratio requires a certain kind of body presence—not for collaboration's sake but for the gravitational pull that keeps junior lawyers oriented toward senior ones in ways that produce both billable hours and partnership-track socialization. The RTO push is not a productivity argument; it is a leverage argument dressed in the lexicon of institutional culture.
When firms say they need people in offices for mentorship, what they mean is that the partnership pipeline requires associates to internalize the firm's expectations through proximity rather than remote instruction. The leverage ratio—historically somewhere between 2.5 to 1 and 4 to 1, depending on practice area—depends on a steady throughput of junior lawyers who have been sufficiently molded to partnership-track norms. Remote work, whatever its other virtues, disrupted the mechanism by which firms reproduce their own hierarchy. The RTO mandate is not about where attorneys do their work; it is about maintaining the conditions under which a firm can continue to promote roughly one partner for every three or four associates it hires, year after year, in a profession where partnership remains the only meaningful wealth creation vehicle. The memo speaks of culture because culture is what the firm cannot admit is economics.

View File

@ -0,0 +1,7 @@
# The Infrastructure of Solitude
I want to open with Thoreau because he's the obvious choice but also the right one—he is, after all, the patron saint of anyone who has ever imagined that the antidote to modern life might be a small cabin, a woodstove, and the luxury of one's own silence. But what we remember about Thoreau is not what he actually wrote about his time at Walden, which was that the cabin was never as isolated as the legend suggests: friends walked out to see him, his mother brought food, neighbors stopped by for conversation, and the railroad whistle sounded through the trees as regularly as a church bell. The solitude was real, but it was undergirded by an infrastructure of care and connection that the mythology has always elided, because the myth requires it. What we wanted—what we still want—was not a true accounting of how one person lived alone in the woods for two years, but a story about what solitude can make of a mind unburdened by the social. The fantasy has never been about the actual cabin; it's about what the cabin represents, which is the belief that clarity of thought arises from removal from the world rather than engagement with it.
The contemporary fantasy of remote work—in which we imagine that liberation might be found in a home office, a cabin in the woods, a life arranged at sufficient distance from the noise of collective endeavor—is downstream of this older American myth, and it carries the same structural flaw. We imagine that the worker who logs off from the Zoom call and steps into silence has achieved something like what Thoreau achieved, which is to say: we imagine that the silence itself is the achievement. But the silence is never just silence, and the solitude is never merely the absence of others. There is always an infrastructure that makes it possible—the systems and supports and material conditions that allow one person to be alone while others are not, that permit withdrawal from the social without paying its full cost. The fantasy of the isolated genius has always been, in this sense, a fantasy about infrastructure: it is the story we tell ourselves about what makes solitude possible while pretending that nothing made it possible at all.

View File

@ -0,0 +1,7 @@
Tuesday morning, six-fifteen. I'm standing in front of the schedule board at a hardware store in suburban Ohio, watching the assistant manager physically move a closing shift from one associate to another because someone's kid has a fever. The scheduling software—expensive, cloud-based, marketed as the solution to everything—sits untouched on her laptop. Nobody uses it the way the vendors imagined. They use it the way real life demands: as a suggestion box with a calendar attached.
This is what labor looks like in the part of the economy that doesn't get think pieces written about it. The conversation about remote work treats every job like it's a desk in a high-rise, but most workers don't have desks. They have stations. They're on their feet. They're serving customers who walked in off the street, not responding to emails at midnight in sweatpants. And when policymakers talk about "the future of work," they're not standing in that hardware store. They're in a hearing room taking testimony from people who work for companies that make the software nobody actually uses.
Here's what's annoying: the loudest voices in this "revolution" are a narrow slice of workers, mostly in coastal industries, and they've convinced everyone that their experience is universal. Legislation gets drafted. Guidance gets issued. And it all assumes the world looks like a Zoom call, not a stockroom on a Tuesday when two people called out and the third is doing the work of one. The revolution isn't universal. It's a fraction of the workforce with a megaphone, writing policy for everyone else. I'm not opposed to flexibility—I want it for the people I manage too—but let's stop pretending this is a revolution and start calling it what it is: a very loud argument between people who can work from home and everyone else who can't.

View File

@ -0,0 +1,11 @@
For three years, Marcus asked for a consistent later start time. Not remote work—not at first. Just a schedule that let him arrive at 10 instead of 9, so he could manage his chronic pain without calling it a medical appointment every single morning. His supervisor said the nonprofit couldn't accommodate that; clients expected early availability, even though no clients ever called before 9:30. HR rejected his request twice, once invoking policy and once suggesting he consider whether the sector was right for him.
Then March 2020 happened. Within a week, everyone worked from home, and Marcus's "unreasonable" accommodation became standard practice. Not because the organization suddenly believed in accessibility, but because remote work was now necessary for all staff. His flexible schedule wasn't a favor anymore—it was just how the office operated.
He's still remote. He's still productive. His clients have never noticed a difference. What changed was not the work or his capacity to do it, but the organization's willingness to see what had been right in front of them for years.
This is the part that keeps me up at night. I helped design hiring programs at a nonprofit for six years. I sat in meetings where we discussed accessibility as a compliance checkbox, not a lived reality. We celebrated our "people-first" culture in annual reports while my colleague Marcus drove to work in pain because we couldn't imagine letting him skip the 9am standup.
Now, as organizations announce return-to-office mandates, I'm watching us quietly undo the only real accessibility win I've witnessed in two decades of nonprofit work. We're not firing disabled employees outright—that would draw legal scrutiny. Instead, we're asking them to prove, again, why they deserve a workplace that works for their bodies. Most won't. They'll leave. And we'll call it a "return to normal," as if normal ever included them.

View File

@ -0,0 +1,9 @@
In Boise, Idaho, the commercial real estate price index now sits at 94 while residential has climbed to 127—relative to a 2019 baseline of 100 for both. Five years ago, the two moved in tight tandem. Today they diverge by more than thirty points, and the gap is widening.
This is the number that matters most in the remote work debate, though it rarely appears in discussions of labor market flexibility or productivity metrics. What we are witnessing is not a collapse of office demand—that narrative, while dramatic, obscures the more interesting transfer occurring beneath it. Downtown commercial real estate in mid-sized cities is quietly losing value while residential property in the same metro areas, and particularly in high-amenity alternatives, appreciates at a sustained pace. The wealth is moving, not disappearing.
Consider the mechanics. When a knowledge worker relocates from San Francisco to Bozeman, their income continues to flow through payroll taxes to California and federal coffers, but their consumption—housing, retail, services—redirects entirely to Montana. The commercial square footage they once occupied in a downtown tower sits vacant or underpriced, its underlying asset value declining. The residential home they purchase in Bozeman appreciates not merely because demand has risen, but because the supply constraint is severe and the buyer pool now includes remote workers earning coastal salaries. The tax base shifts with them, though with a lag that most municipal budgets have not yet incorporated.
The second-order effects are only beginning to register. Local sales tax revenues in Bozeman and similar destinations have outpaced projections, but commercial property tax assessments—tied to downtown office valuations—are declining. School districts funded primarily by property taxes face asymmetry: residential values rise while commercial bases erode. Transit authorities dependent on downtown commuter ridership confront declining fare revenue precisely as their cost structures remain fixed. The wealth transfer is real, measurable, and structurally advantaged to residential holders in ways that commercial landlords cannot easily arbitrage. The data is clear. The implications are not yet fully appreciated.

View File

@ -0,0 +1,9 @@
The desk sits against the wall where the closet used to be. This is significant: the closet, not the window. Light enters from the left, through a window that was never designed to illuminate a desk, and falls unevenly across an office chair whose lumbar support has been compromised by years of use. The desk itself is a kitchen countertop from a big-box retailer, supported by two file cabinets pushed sideways—solutions so common they have become architectural convention, the way a certain kind of American makes do with what the housing market provides. The monitor faces the wall. Not a window, not a view—the wall. A white wall, or near-white, the color of capitulation.
This is the room where roughly a third of American workers now spend their days. Not a converted bedroom with French doors, not a finished basement with recessed lighting—but this: the residual space left over after the program's priorities have been satisfied. The living room got the bay window. The kitchen got the breakfast nook. This is what remains—a leftover rectangle that shares a wall with the hallway, where footsteps and the doorbell become part of the workday's acoustic texture.
The architecture profession has largely ignored this room. When it does appear in design publications, it is dressed in warm wood and task lighting, photographed for its aspirational qualities. But the profession's real failure is not one of aesthetic ambition—it is one of attention. For decades, the American home was designed around the assumption that work happened elsewhere. The home office existed as a niche, a privilege, a room for the partner who occasionally brought work home. Now it is central to how we live, and the profession has not adequately addressed this spatial inversion.
The home office is the worst-designed room in the American house. What was once a private design failure has become a collective one, and the profession's silence on the matter is deafening.

View File

@ -0,0 +1,7 @@
The kettle clicks off and the kitchen is silent except for the low hum of the refrigerator. I lean against the counter, coffee in hand, watching a streetlight flicker through the window. Its 6:47a.m., and Im rehearsing the day—a series of Slack pings, a missed call from a coworker I never quite heard, the feeling that Im both present and elsewhere. The house feels full of ghosts: the chair where I used to eat breakfast, the desk that now doubles as a conference table, the hallway that once led to a commute. Im not sure whether the ache comes from being away or leaving something behind.
Later, I walk into the office after a month away. The glass doors slide open and the air is thick with the murmur of typing, occasional laughter that seems to come from another world. I pass a row of empty desks, each a small monument to a routine that never settled. A missed call on my phone—my managers name flashing, then vanishing—reminds me of the offices invisible expectations, the weight of being seen and being unseen. The contrast is jarring, not because one place is better, but because each carries its own quiet toll.
Both the kitchen—where I work remotely—and the office have a cost that isnt always spoken about. The discourse around where we work tends to treat one setting as harmless, as if the emotional price of the other were zero, and that simplification makes the argument feel clean but incomplete. Im not arguing for a return to the old normal or an endorsement of working elsewhere; Im suggesting that we acknowledge loneliness, blurred boundaries, and subtle hierarchies of presence in any space. Only then can we weigh what we actually sacrifice, rather than what we think we should.

View File

@ -0,0 +1,5 @@
The patient, a woman in her early thirties who works in healthcare administration, described her first month working from home by saying she felt "like a child who has been left alone in a house and keeps checking the windows." She did not mean this as metaphor. When I asked her to elaborate, she spoke about the silence of her apartment—how the absence of colleagues' voices, of footsteps in the hallway, of the casual proximity that had structured her days, had activated something she had not expected to feel. She described spending the first week reorganizing her desk, the second week sending unnecessary emails to managers simply to prompt a reply, and the third week crying after a video call in which no one had directly addressed her. By the fourth week, she had begun to recognize that what she was experiencing was not novel—her distress had a history. In her office, she had managed an anxious attachment to supervision through constant visibility: arriving early, volunteering for committees, positioning herself in the physical space where authority was exercised. The remote environment had removed these behavioral solutions without removing the underlying anxiety that those solutions had managed.
What this patient articulate and what many clinicians are now observing in their practices is that remote work has not generated unprecedented distress. Rather, it has redistributed the conditions under which long-standing attachment patterns and professional anxieties become conscious. Two workers in identical roles may find remote arrangements profoundly different—one experiencing what might be called productive solitude, another experiencing acute relational deprivation—because each brought different psychic economies to the workplace. The office functioned as a container whose rules, though often imperfect, were familiar. Remote work altered the container's shape; what has surfaced is not foreign to the individual but rather the return of their most characteristic struggles with proximity, authority, and the wish to be known.

View File

@ -0,0 +1,5 @@
The Metropolitan Transportation Authority reported 2.1 billion subway rides in 2023—still 11 percent below the 2019 pre-pandemic baseline. That gap translates to roughly $500 million in lost fare revenue annually, a hole the system has not recovered from despite returning riders. But this number barely registers in the remote-work conversation, which obsessively tracks commercial real estate valuations and worker preferences while treating transit as an afterthought. The discourse has framed remote work as a private-sector labor market story, parsing its implications for downtown office landlords and corporate headcount strategies. When transit appears at all, it functions as a footnote—a nod to "changed commuting patterns" that implies temporary disruption rather than structural stress.
The implications are immediate and political. Transit agencies face budget shortfalls that did not disappear when pandemic emergency orders ended. Fare increases, service reductions, and deferred maintenance are the mechanisms through which ridership losses translate into lived consequences for riders who have no choice but to use the system. These riders are disproportionately low-income, disproportionately non-white, and disproportionately outside the demographic profile of the remote-work discourse's usual subjects. The winners and losers of this shift are not abstract market outcomes—they are legible in the routes that get cut, the fare boxes that get emptied, and the riders who get stranded. The political consequences are already accumulating: fare evasion crackdowns in New York, service hour reductions across multiple systems, and a growing chasm between the transit that urban policy assumes exists and the transit that actually runs. The discourse is not keeping pace, and the gap is widening.

View File

@ -0,0 +1,13 @@
# The Lease Isn't Up Until 2027
That's the actual reason your CEO is asking you to come back three days a week.
Return-to-office mandates, those meticulously stage-managed pronouncements about culture and team alignment that have dominated corporate communications for the past eighteen months, are not really about culture or team alignment. They are about balance sheets. Specifically, they are about the amortized residual value of commercial real estate commitments that cannot be shed without triggering accounting adjustments, analyst questions, and board-level discomfort about capital allocation discipline during a period of maximal visibility.
This is not a conspiracy. It is simply the logical endpoint of incentive structures that reward CFOs for minimizing write-downs and general counsels for avoiding covenant breaches. The language of "culture" and "team alignment" serves a legitimate function: it provides political cover for decisions that would otherwise require executives to explain why they are asking knowledge workers to commute—against all observable evidence of productivity—to sit in buildings whose per-seat cost now exceeds the marginal revenue contribution of their occupants.
The remote work pivot of 2020 was executed in weeks. Commercial leases operate on decade-long horizons. The typical Fortune 500 real estate portfolio, locked in during the expansion years of 2017-2019 with ten-to-fifteen-year terms, cannot be reconfigured at the speed of a pandemic-induced behavioral shift. What executives discovered was that their largest unhedged exposure to a structural economic transformation was not their legacy business models but their legacy real estate obligations. The RTO mandates are not a response to new evidence about how work happens. They are an accounting exercise in managing the gap between economic reality and committed liability.
A CFO who acknowledges this openly would have to explain why capital was deployed into illiquid, location-specific real estate assets at the precise moment when remote work infrastructure became commoditized. That conversation does not occur in town halls. It occurs in board dinners, and the language used there is different.

View File

@ -0,0 +1,13 @@
Minneapolis
Dmitri's offer letter came through on a Tuesday in March, and his girlfriend immediately started doing the math. $165,000 base, plus equity that vesting schedule looked like a staircase to somewhere he'd never been. For a 27-year-old backend engineer who'd spent three years at a regional healthcare SaaS company paying him $92,000, the number was absurd enough that he laughed. Then he did his own math: a one-bedroom in the North Loop, student loans, a car he'd bought when gas was still cheap. He could actually see a path to saving money.
The company was based in San Francisco—a fact that appeared exactly nowhere in the offer letter, which just listed "Bay Area" as the compensation band and a link to a PDF explaining how they'd adjusted for cost of labor. The PDF was fourteen pages. He'd read three of them before his phone buzzed with a calendar invite: "Compensation Philosophy Overview." The HR rep on the call talked about "market competitiveness" and "internal equity," but what she really meant was that his salary was being calculated against Minneapolis costs, not San Francisco costs. He was getting paid like he lived in the city where the company was headquartered, but his actual cost of living was a fraction of that.
The contradiction didn't bother him at first. The number was still bigger than anything else on the table, and Minneapolis was where his life was—his girlfriend's job, his aging mother's proximity, the winter running habit he'd built his mental health around. He signed.
Two years later, he's watching the Slack channel where new hires announce themselves. There's a pattern he can't stop noticing: engineers in Minneapolis, Chicago, Denver, get the same title and roughly the same comp, but every cohort's band creeps lower. Last month's new backend hire in Minneapolis came in at $152,000. When he asked his manager about the trajectory, the answer was vague—market adjustments, budget cycles, nothing personal. But Dmitri has started doing the math again, and this time the numbers don't work in his favor.
Here's what nobody tells you about geographic arbitrage: the same infrastructure that lets a company in San Francisco hire you in Minneapolis—the remote collaboration stack, the standardized interview loops, the tight role definitions that make location feel like a detail rather than a constraint—also lets them hire someone in Belgrade for half what they pay you. Not next month. Maybe not next year. But the direction is structural, and it's been pointing this way since the first company decided that your address was a line item on a spreadsheet rather than a reason to pay you what you're worth. The forces that made this job possible are the same forces making it temporary.

View File

@ -0,0 +1,7 @@
"We believe that in-person collaboration remains essential to our culture and innovative output. Effective September 1, 2024, all employees whose roles permit remote work will be required to work from the office a minimum of three days per week. This policy applies to all US-based employees, with exceptions granted solely at manager discretion for documented medical accommodations or extenuating personal circumstances. We recognize that flexibility is important to our people, and this hybrid model represents our commitment to balancing collaboration with the convenience our employees have come to expect."
This is not labor policy. It reads as what it is: a memorandum from a vice president of human resources to the employees of a Fortune 500 company, distributed on corporate letterhead. Yet it will shape the working lives of roughly seventy thousand American workers more decisively than any statute, regulation, or collective bargaining agreement enacted in the past decade. The memo governs where millions of people must be, when they must be there, and what consequences attend non-compliance. These are the substantive contents of labor policy, issued without legislative hearings, economic impact assessments, or public comment periods.
Remote-work policy in the United States has been set almost entirely by corporate HR departments. This is an absurd substitute for labor policy, and it has produced predictable distributional consequences that public policy has not yet attempted to measure. The three-day standard emerging across corporate America will determine which workers retain geographic flexibility and which do not, which occupations command wage premiums and which face marginalization, which labor markets tighten and which hemorrhage workers to competing jurisdictions. These are not incidental firm decisions; they are the closest thing this country has to a coordinated labor market intervention, and it has been designed by none of the institutions constitutionally tasked with such design. The consequences are real, measurable, and currently unmeasured because no one in the federal government has decided to look.

View File

@ -0,0 +1,13 @@
"Always overcommunicate when you're working remotely."
That's the first piece of advice you'll hear. Managers can't read your body language through a screen, so you need to be explicit about what you're doing, when you're doing it, and when you'll be done. Sound solid? It does on paper. The problem is this advice was written by people who built their careers in offices where visibility was a form of currency. Their instinct, which they're passing on to you, comes from a world where being seen at your desk mattered.
Here's the thing though—overcommunicating when you're twenty-four and figuring out your second job doesn't actually solve the problem. It just creates a new one. You're not overcommunicating to your team. You're performing diligence for a framework that was designed for a different era. The real issue isn't that your manager can't see you working. It's that the systems around remote work were retrofitted onto workplaces that assumed you'd be in the room.
"Create a dedicated workspace. Designate it as your office and keep work there."
This one shows up in every list, every blog post, every Slack channel where someone asks how to stay productive. And it's not bad advice exactly—having a space that signals "work mode" helps. But the people giving this advice own their homes. They're thirty-eight with a spare bedroom they converted into an office during the pandemic, or they're forty-one and live alone. They're not factoring in that you're probably sharing a one-bedroom with a roommate, or you're working from your childhood bedroom, or your "dedicated space" is the left side of your bed because rent is what it is. The advice assumes a lifestyle infrastructure that doesn't match the reality of your first few years in the workforce.
That's not a judgment. It's just worth naming: most of what gets presented as universal remote work wisdom is deeply specific to a stage of career and life that you haven't reached yet.

View File

@ -0,0 +1,9 @@
Microsoft Teams knows when you're at your desk. That's not a revelation anyone inside Redmond would deny—the application logs presence status, meeting join times, message delivery receipts, and a reasonably comprehensive record of who spoke to whom and for how long. Pull the usage reports from the admin center and you'll find active user counts segmented by hour, file sync volumes, conference call duration breakdowns. This isn't hidden telemetry buried in an obscure audit log twelve menus deep—it's dashboarded for your IT staff's consumption.
The interesting question isn't whether this data collection happens. It's why enterprises cheerfully adopted a collaboration tool that functions, at the infrastructure level, as an employee activity monitor. The answer, of course, is that nobody had a better option when the pandemic made remote work mandatory overnight. But the deployment patterns that emerged tell a story about what organisations actually wanted from their "collaboration" stack once they had it deployed at scale.
Here's the thing: IT departments didn't stumble into this role. They were handed a tool that solved an operational problem—maintaining some semblance of coordination across distributed teams—and along with it came surveillance capabilities that would have been politically impossible to implement through traditional HR channels. Nobody would have voted for keystroke logging. But everybody accepted Teams, and Teams logs plenty enough to build behavioural profiles without ever touching the keyboard.
The remote-work era didn't invent workplace monitoring. It just made it ambient. The collaboration platform became the enforcement layer for a kind of data collection that, proposed explicitly, would have triggered immediate resistance from employees and civil libertarians alike. Instead, it arrived as a feature set, enabled by default, with IT as the quiet implementation partner. That's the story worth examining—not whether your vendor is selling your data to advertisers, but how enterprise IT became the compliance layer for surveillance infrastructure that nobody voted on.

View File

@ -0,0 +1,9 @@
It is 2:47 PM on a Tuesday and the light in my room has taken on that particular quality it acquires only in late October, when the sun has dropped low enough to reach not just the window but the opposite wall, where it catches the edge of a bookshelf and the spine of a book I have been meaning to finish for three weeks. The chair in which I sit is not my desk chair—my desk chair broke in May and I have not replaced it, which is its own kind of decision, though no one at the company would understand it that way. There is a cup of coffee on the floor beside me, grown cold sometime around what used to be called the afternoon slump, though I am not sure the phrase applies anymore. The room is quiet except for the neighbor's cat walking along the back fence, which is not my fence and not my cat, but I have come to recognize its particular gait, the way it stops midway to sit and look at something I cannot see. This is 2:47 PM on a Tuesday, and I am working—or at least I am in the room where work happens, which is not quite the same thing.
The conversation about remote work has been had so many times now that it feels less like a discussion than an argument in search of new words. There are those who say the office is necessary for collaboration, for culture, for some ineffable thing called synergy. There are those who say the home is necessary for focus, for autonomy, for some ineffable thing called life. But beneath both positions lies a question that neither side seems willing to ask, perhaps because the asking itself yields no policy: what is a day? Not the calendar day, not the eight-hour contract day, but the actual day—the one that begins when I wake and ends when I stop attending to the world. Who shapes that day? And what is lost when we admit, if we ever do, that it has been shaped for us?
The discourse treats the day as a container already given, as fixed and universal as the length of a meter. But a day is not a meter. A day is alive in the way that only living things can be—it expands, it contracts, it refuses to be averaged. The remote-work question is really a question about this livingness: whether we will admit that the day has shape, and whose hands have been shaping it. The current discourse cannot ask this because asking this way does not scale. You cannot write a policy for the particular Tuesday afternoon light. You cannot draft guidelines for how long it takes a cold cup of coffee to teach you something about attention. The essayist lives in these particulars; the system cannot afford to.
The light shifts. The cat is gone from the fence. It is still 2:47 PM, or it was, and now I am in the room where work happens.

View File

@ -0,0 +1,9 @@
Remote work has transformed from a niche experiment into a defining feature of the modern labor market. In just a few short years, what once seemed a temporary accommodation during a global health crisis has become a permanent shift in how companies think about talent, productivity, and workplace culture. According to recent surveys, more than 70% of knowledge workers now operate remotely at least part of the time, and a growing number of organizations plan to adopt hybrid or fully distributed models for the long haul. This rapid evolution brings both exciting opportunities and complex challenges that deserve careful attention.
For employees, the freedom to work from anywhere can translate into better worklife balance, reduced commute stress, and access to a broader job market. For employers, remote teams open doors to a global talent pool, lower overhead costs, and increased employee retention when managed effectively. Yet, without intentional practices around communication, goalsetting, and wellbeing, remote work can also lead to isolation, blurred boundaries, and uneven performance.
In this blog post, well explore the key trends shaping remote work in 2024, share proven strategies for staying productive and connected, and highlight common pitfalls to avoid. Youll find practical tips on setting up a ergonomic home office, mastering asynchronous collaboration, and fostering a healthy remote culture that supports both personal growth and business success. Whether youre a seasoned remote veteran or just beginning to navigate the shift, our insights will help you turn the promise of locationindependent work into a sustainable advantage.
Ready to unlock the full potential of remote work? Lets dive in and discover how you can thrive in todays distributed workforce. If you found this useful, feel free to share your thoughts in comments below and let us know which remote work topics youd like us to cover next.

View File

@ -0,0 +1,17 @@
# The Future of Work Is Flexible: Embracing Remote Work in 2024 and Beyond
---
**Remember when the idea of working from your couch seemed like a distant dream?** Just a few short years ago, remote work was a rare perk—something only freelancers and tech startups talked about. Today? It's become the new normal for millions of workers around the globe.
Whether you've fully embraced the work-from-home lifestyle or you're still navigating the challenges of balancing Zoom calls with household responsibilities, there's no denying that remote work has fundamentally changed how we think about productivity, work-life balance, and the very nature of a "job."
In this post, we're diving deep into everything remote work—from the unexpected perks (hello, no commute!) to the very real struggles (goodbye, clear boundaries between "off" and "on"). We'll explore tips for staying productive, maintaining mental health, and building genuine connections with colleagues you've never met in person.
So grab your coffee (or tea—no judgment here), settle into your favorite spot, and let's talk about how to make remote work *work* for you.
---
*Stay tuned for our deep dive into remote work best practices, tools, and strategies!*

View File

@ -0,0 +1,25 @@
# Draft Opening for a Remote Work Blog Post
Here's an engaging opening you can adapt:
---
**The office is no longer a place—it's a state of mind.**
Five years ago, the idea of working from your couch, a coffee shop, or a co-working space across the country would have seemed like a fantasy to many. Today, it's the reality for millions of workers worldwide. Remote work has transformed from a temporary experiment into a permanent shift in how we think about productivity, collaboration, and work-life balance.
But with this freedom comes a new set of challenges. How do you stay productive when your kitchen becomes your office? How do you maintain connections with colleagues you've never met in person? And how do you draw the line between "logged on" and "logged out" when your workspace is steps away from your bed?
Whether you're a remote work veteran or just starting to navigate this new landscape, the conversation around *how* we work has never been more important.
---
This opening:
- ✅ Opens with a compelling hook
- ✅ Acknowledges both the benefits and challenges
- ✅ Sets up topics for deeper exploration
- ✅ Speaks to a broad audience (new and experienced remote workers)
Would you like me to adjust the tone, focus on a specific angle (productivity, work-life balance, tools, etc.), or write additional options?

View File

@ -0,0 +1,13 @@
# The Future of Work: Embracing the Remote Revolution
The way we work has fundamentally shifted. What began as a necessity has transformed into a global movement—one that is redefining what it means to be productive, collaborative, and fulfilled in our careers.
Remote work no longer feels like a temporary experiment or a niche perk reserved for tech startups. It has become a permanent fixture in the modern workplace, reshaping everything from how we design our days to how companies build their cultures. Across industries, organizations are grappling with a question that would have seemed unthinkable just a few years ago: *How do we thrive without everyone in the same room?*
For some, this shift has meant liberation—the freedom to design a workspace that inspires them, the flexibility to attend their child's afternoon recital, or the ability to live anywhere regardless of where their employer is headquartered. For others, it has introduced new challenges: the blur between work and personal life, the struggle to stay connected without casual hallway conversations, and the ever-present question of how to maintain team cohesion when physical presence is no longer the default.
Whether you're a seasoned remote worker, a manager navigating this new landscape for the first time, or someone curious about what the future holds, there's no denying that remote work is here to stay. And with it comes an entirely new set of skills, mindsets, and opportunities worth exploring.
In this post, we'll dive into the realities of working remotely—the benefits, the challenges, and the practical strategies that can help both individuals and organizations succeed in this evolving world. Whether you're looking to optimize your own remote experience or lead a distributed team with confidence, this is where the conversation starts.

View File

@ -0,0 +1,9 @@
In recent years, working from home has shifted from a rare perk to a mainstream reality for millions of professionals. What once seemed like a distant dream—skipping the daily commute, personalizing your workspace, and designing a flexible schedule—has become an everyday experience for many. While remote work offers advantages, such as greater autonomy, lower transportation costs, and ability to create a personalized office environment, it also presents unique challenges that require intentional strategies to sustain productivity and wellbeing.
One of the most attractive aspects of working from home is flexibility to shape your day around priorities. Whether its fitting in a morning workout, attending a child's school event, or simply enjoying a quieter atmosphere, remote work can improve worklife balance when managed wisely. However, the blurred lines between professional and personal life often lead to longer hours, feelings of isolation, and difficulty “switching off” at the end of the day. Establishing a dedicated workspace, setting clear boundaries, and scheduling regular breaks are essential habits that help combat these pitfalls.
Moreover, the shift to remote work has sparked a conversation about trust, communication, and company culture. Managers must learn to evaluate performance by outcomes rather than presence, while employees need to demonstrate selfdiscipline and proactive communication. As organizations adapt to this new normal, the tools and practices that support remote collaboration continue to evolve, offering innovative solutions for virtual meetings, project management, and team engagement.
In this blog we will explore practical tips for setting up an efficient home office, strategies for staying motivated and productive, and ways to preserve mental health in a remote setting. Whether you are a seasoned telecommuter or just beginning to navigate the workfromhome world, our goal is to provide actionable insights that make your home office a place where professional success and personal wellbeing thrive.

View File

@ -0,0 +1,15 @@
# Welcome to the Future of Work — Right from Your Home Office
The way we work has fundamentally changed. What once seemed like a distant dream — conducting meetings in pajamas, collaborating with teammates across five time zones, or drafting proposals from a coffee shop halfway across the world — is now the daily reality for millions of people. Remote work isn't a temporary trend; it's a permanent shift in how we define productivity, community, and work-life balance.
In this blog, we're diving into everything that makes remote work thrive — from the practical tools that keep teams connected to the lifestyle habits that help you stay focused, healthy, and motivated without ever leaving your front door. Whether you're a seasoned remote employee, a manager leading a distributed team, or someone just beginning to explore the freedom of working from home, you'll find real talk, actionable tips, and honest perspectives here.
We'll explore topics like building a productive home office setup that doesn't break the bank, mastering asynchronous communication so your inbox doesn't control your life, navigating the unique challenges of loneliness and boundary-setting in a remote environment, and spotlighting companies that are getting it right. We'll also share stories from remote workers in the trenches — the wins, the struggles, and the lessons learned along the way.
The beauty of remote work lies in its flexibility, but flexibility without structure can quickly become chaos. That's exactly what this space is for: cutting through the noise and bringing you grounded, practical advice you can actually use. No fluff, no one-size-fits-all mandates — just honest conversation about how work is evolving and how you can thrive within it.
So grab your coffee, find your favorite spot, and let's dig into what it really means to work remotely in 2025 and beyond. The future of work is here, and you're already part of it.

View File

@ -0,0 +1,15 @@
# The Future of Work Is Flexible: Embracing the Remote Revolution
The way we work has fundamentally shifted. What began as a necessity during global uncertainty has evolved into a permanent transformation of how we define the workplace. Remote work is no longer an experiment—it's the new normal, and organizations that embrace it are reaping the benefits.
For millions of workers worldwide, the daily commute has been replaced by a few steps to a home office, a kitchen table, or a coworking space. The traditional 9-to-5 structure is giving way to flexible schedules that prioritize productivity over presence. This shift isn't just about where we work; it's about how we approach our careers, our well-being, and our relationship with time.
The advantages of remote work extend far beyond the convenience of skipping traffic. Employees report better work-life balance, reduced stress, and increased job satisfaction. Companies are discovering broader talent pools, lower overhead costs, and improved employee retention. Yet with these opportunities come new challenges—maintaining company culture, fostering collaboration, and preventing burnout in isolation require intentional strategies that many organizations are still learning to implement.
As we navigate this new landscape, questions emerge. How do we sustain connection without physical proximity? How do managers lead effectively across digital distances? How do we protect the boundaries between professional and personal life when they occupy the same space?
This blog explores the multifaceted world of remote work—examining its triumphs, acknowledging its struggles, and charting a path forward for workers and employers alike. Whether you're a remote work veteran, a skeptic weighing the pros and cons, or an employer figuring out how to build a distributed team, there's something here for you.
The remote revolution is here. Let's explore what it means for our professional lives and the future we're building—one virtual meeting at a time.

View File

@ -0,0 +1,7 @@
The office door that once swung open every morning has become a relic for millions of workers. In just a few months, remote work shifted from a rare perk to the default mode for professionals. What started as a necessity during the pandemic has blossomed into a lasting cultural shift, reshaping how we think about productivity, worklife balance, and the definition of a workplace.
For many, the appeal is undeniable: no longer bound by a daily commute, employees can reclaim hours otherwise spent stuck in traffic or crowded subway cars. The flexibility to design a personal office—whether its a cozy nook in an apartment, a standing desk in a garage, or a sunny café—has unlocked new possibilities for focus and creativity. Studies consistently show that remote workers report higher job satisfaction, lower stress levels, and in some cases, increased output when theyre allowed to tailor their environment to their own rhythms.
Yet the benefits come with a fresh set of challenges. The blurred lines between home and work can erode boundaries, leading to longer hours and burnout if not managed intentionally. Communication across digital channels requires a heightened

View File

@ -0,0 +1,9 @@
In recent years, the way we work has undergone a seismic shift that few could have predicted. From bustling openplan offices to kitchen tables bathed in morning light, remote work has moved from a niche experiment to a mainstream reality for millions of professionals worldwide. The COVID19 pandemic acted as an unexpected catalyst, forcing companies to adopt digital collaboration tools almost overnight and proving that productivity need not be tied to a physical desk. Today, remote work is no longer just a contingency plan; it is a strategic decision that shapes talent acquisition, employee wellbeing, and even environmental impact.
Yet despite its rapid adoption, remote work remains a complex phenomenon that sparks both enthusiasm and apprehension. On one hand, the freedom to design a personalized workspace, eliminate lengthy commutes, and achieve a better worklife balance has been celebrated as a transformative benefit. On the other hand, challenges such as maintaining company culture, managing timezone differences, and preventing burnout demand thoughtful policies and proactive leadership.
This blog post dives into the evolving landscape of remote work, exploring the latest trends, best practices, and emerging tools that empower teams to thrive virtually. We will examine case studies from forwardthinking organizations, share actionable tips for remote managers, and highlight common pitfalls to avoid. Whether you are a seasoned remote veteran or a business leader navigating the transition, our goal is to provide clarity and inspiration in an increasingly digital workplace.
Join us as we unpack the data, debunk myths, and chart a course for a future where work is not defined by location but by purpose and performance. Stay tuned for indepth articles, expert interviews, and community stories that capture the pulse of the remote revolution. Expect deep dives into remote employee wellness, asynchronous communication strategies, and the role of AI in shaping tomorrows virtual workplaces. Stay curious.

View File

@ -0,0 +1,7 @@
The global workplace is experiencing a transformation unlike any other in recent history. With the rapid rise of digital tools, highspeed internet, and a shifting cultural mindset, remote work has moved from a niche perk to a mainstream way of life for millions of professionals. What once required a daily commute to a physical office is now as simple as logging into a cloudbased collaboration platform from a home office, coffee shop, or even a beachside cabana. This seismic shift has sparked both excitement and apprehension: employees celebrate newfound flexibility and worklife integration, while managers grapple with maintaining team cohesion and productivity in a dispersed environment.
Recent studies underscore the magnitude of this change. According to a 2023 survey, over 60% of knowledge workers reported working remotely at least one day per week, and nearly onethird described themselves as fully remote. These numbers are not just statistical curiosities—they reflect a fundamental redefinition of what it means to “go to work.” The benefits are welldocumented: reduced commuting stress, access to a broader talent pool, and the ability to design a personalized workspace that fuels focus and creativity. Yet the challenges are equally real: feelings of isolation, blurred boundaries between personal and professional time, and the need for intentional communication strategies.
In this blog, well unpack the evolving landscape of remote work, exploring proven tactics for staying productive, maintaining mental wellbeing, and fostering a vibrant virtual culture. Whether youre a seasoned remote veteran or just beginning to navigate the telecommuting world, our upcoming posts will provide actionable insights, expert tips, and realworld stories to help you thrive in the digital age. Stay tuned as we dive deeper into the tools, habits, and mindsets that make remote work not just possible, but truly rewarding. Join us on this journey to discover how remote work can be both productive and fulfilling.

View File

@ -0,0 +1,13 @@
# The Rise of Remote Work: Transforming How We Work and Live
The way we work has undergone a dramatic transformation over the past decade, but nothing has accelerated this change quite like the global shift to remote work. What was once considered a perk offered by forward-thinking tech companies has now become the new normal for millions of workers across virtually every industry. Remote work is no longer just a trend—it's fundamentally reshaping our relationship with employment, productivity, and work-life balance.
For many professionals, the traditional office setup feels like a relic of the past. The daily commute, once an unavoidable part of professional life, has been replaced by the simple act of walking to a home office or setting up a laptop at the kitchen table. This shift has opened doors that many never thought possible: the ability to work for companies thousands of miles away, access to a global talent pool, and unprecedented flexibility in how and when work gets done.
But remote work isn't without its challenges. While the freedom to work from anywhere is undoubtedly appealing, it requires a unique blend of self-discipline, effective communication, and robust technology. Companies have had to rethink everything from team collaboration to performance evaluation, while workers have had to adapt to new routines and boundaries between professional and personal life.
The conversation around remote work continues to evolve as we learn more about its long-term effects on productivity, employee well-being, and company culture. Some organizations are embracing fully remote models, others are adopting hybrid approaches, and some are calling employees back to the office. Each model has its own merits and drawbacks, and the best approach often depends on the nature of the work, team dynamics, and individual preferences.
As we navigate this new landscape, one thing is clear: remote work is here to stay. It has already changed the way we think about work, and its impact will continue to unfold in ways we're only beginning to understand. Whether you're a seasoned remote worker, a manager adapting to distributed teams, or someone curious about this shift, there's never been a better time to explore what remote work means for your career and your life.

View File

@ -0,0 +1,9 @@
The world of work has undergone a seismic shift in the past few years, and remote labor has emerged as a defining trend of the modern era. What began out of necessity during global lockdowns has blossomed into a flexible, locationindependent lifestyle that millions now embrace daily. In 2023, over 35% of the U.S. workforce reported working from home at least one day a week, and similar patterns have surfaced across Europe, Asia, and beyond. This rapid adoption has sparked a vibrant conversation about what remote work really means for productivity, employee wellbeing, and corporate culture.
For many professionals, the ability to trade a daily commute for a home office—or a cozy café in a distant city—has proven transformative. Studies consistently show that employees who work remotely report higher job satisfaction, reduced stress levels, and a stronger sense of autonomy. Without the constraints of a physical office, workers can tailor their environments to suit personal preferences, whether that means a standing desk in a sunny apartment or a quiet nook surrounded by nature.
Yet the benefits come with their own set of challenges. Maintaining clear boundaries between work and personal life can be difficult when the kitchen table doubles as a conference room. Collaboration may suffer if teams lack the right digital tools or fail to nurture informal connections that normally form in hallway chats. Managers, too, must adapt their leadership style, shifting from micromonitoring to outcomefocused feedback.
As organizations chart a course through this new landscape, they are experimenting with hybrid models, investing in robust communication platforms, and rethinking performance metrics. The conversation is no longer whether remote work is viable, but how to harness its full potential while mitigating pitfalls. In the sections that follow, we will explore tips for productivity, team cohesion, and a balanced remote lifestyle.

View File

@ -0,0 +1,33 @@
# Remote Work Blog Introduction
---
**The future of work isn't coming—it's already here.**
Remember when the idea of working from your couch in pajamas seemed like a distant dream? Now, it's the reality for millions of people around the world. Remote work has transformed from a pandemic-era experiment into a permanent shift in how we think about productivity, collaboration, and work-life balance.
Whether you're a seasoned remote worker, a manager navigating distributed teams, or someone just starting to explore the freedom of working outside a traditional office, this space is for you.
Here, we'll dive into:
- **Practical tips** for staying productive while working from home
- **Tools and tech** that make remote collaboration seamless
- **Work-life balance** strategies to prevent burnout
- **Real stories** from remote workers navigating this new landscape
- **Career advice** for thriving in a digital-first workforce
The beauty of remote work? There's no one-size-fits-all approach. It's about finding what works for *you*—whether that means a standing desk setup, a co-working space across the globe, or simply the flexibility to pick up your kids from school without the guilt.
So grab your coffee (or tea, no judgment here ☕), settle in, and let's explore this ever-evolving world of remote work together.
**The office of the future is wherever you are. Let's make it work.**
---
*What aspect of remote work interests you most? Drop a comment below—we'd love to hear your thoughts!*
---
Want me to tailor this for a specific niche (like remote tech jobs, digital nomad lifestyle, or management tips)? Happy to adjust!

View File

@ -0,0 +1,11 @@
Remote work has transformed the way we think about productivity, flexibility, and the very nature of the modern workplace. What once seemed like a temporary experiment has become a permanent fixture for millions of employees across the globe. As companies adapt to this new reality, they are discovering both unprecedented opportunities and unique challenges that require thoughtful strategies and a willingness to evolve.
One of the most compelling benefits of remote work is the ability to design a personalized environment that supports individual focus and wellbeing. Employees can eliminate long commutes, create ergonomic home offices, and better balance professional responsibilities with personal commitments. This autonomy often leads to higher job satisfaction, reduced stress levels, and in many cases, increased output.
However, the shift away from a centralized office also raises important questions about collaboration, company culture, and equitable access to resources. Maintaining spontaneous interactions that fuel innovation becomes more intentional; teams must deliberately schedule virtual coffee chats, brainstorming sessions, and social events to nurture relationships. Additionally, managers need to develop new skills in remote leadership, focusing on outcomebased performance rather than presencebased monitoring.
Technology plays a pivotal role in enabling seamless remote operations. Cloudbased project management platforms, realtime communication tools, and secure VPN services have become essential infrastructure. When used effectively, these solutions bridge the gap between distributed team members, ensuring that information flows freely and projects stay on track.
To thrive in a remote-first world, organizations should adopt clear policies that address expectations around availability, communication norms, and data security. Regular checkins, transparent goal setting, and a strong emphasis on mental health support can help mitigate feelings of isolation and foster a sense of belonging, regardless of physical distance. As remote work continues to evolve, companies that embrace these changes will likely outperform those resisting the future of work.

View File

@ -0,0 +1,11 @@
# Opening Paragraph for a Remote Work Blog Post
---
**The way we work has fundamentally changed.** What started as an experiment during global uncertainty has become a permanent shift in how businesses operate. Millions of professionals now wake up, grab their coffee, and open their laptops from home offices, co-working spaces, or even coffee shops across the world. Remote work no longer feels like a temporary adjustment—it's redefining our relationship with productivity, work-life balance, and the traditional office itself. But with this freedom comes a new set of challenges: how do we stay connected without water cooler moments? How do we protect our boundaries when our home is also our office? Whether you're a remote work veteran or just starting to navigate this landscape, the conversation around flexible work is more important than ever. In this post, we'll explore the benefits, the pitfalls, and the practical strategies that can help you thrive—no matter where you log in from.
---
Want me to adjust the tone, focus on a specific angle (productivity, mental health, company culture, etc.), or write additional paragraphs?

View File

@ -0,0 +1,15 @@
# The Remote Work Revolution: Embracing the Future of Productivity
The way we work has undergone a dramatic transformation over the past several years, and remote work has emerged as a defining characteristic of modern professional life. What began out of necessity for many has evolved into a preferred working arrangement for millions around the world.
As we navigate this new landscape, one thing becomes abundantly clear: remote work is here to stay. Companies that once insisted on traditional office setups have discovered the numerous benefits of allowing their employees to work from anywhere. From reduced commuting stress to increased flexibility, the advantages are compelling for both employers and workers alike.
But let's be honest—remote work isn't without its challenges. Maintaining work-life boundaries, combating isolation, and staying productive require intentional strategies and a shift in mindset. The most successful remote workers aren't just those who have the right technology; they're the ones who've learned to adapt their habits, create dedicated workspaces, and communicate effectively in a digital-first environment.
In this blog post, we'll explore the ins and outs of thriving in a remote work setting. Whether you're a seasoned telecommuter or just beginning your journey away from the traditional office, we'll cover practical tips for staying focused, tools that enhance collaboration, and ways to maintain your mental well-being while working from home.
We'll also examine what the future holds as more organizations adopt hybrid models and reimagine what a productive workplace looks like. The conversation around remote work continues to evolve, and it's crucial to stay informed about emerging trends, best practices, and the changing expectations of today's workforce.
So whether you're looking to optimize your current remote setup or simply curious about this growing phenomenon, join me as we dive into the world of remote work and discover how to make it work for you.

View File

@ -0,0 +1,15 @@
# The Future of Work Is Flexible: Embracing the Remote Revolution
Remember when the idea of working from your couch in pajamas seemed like a distant fantasy? Just a few years ago, remote work was considered a rare perk—something you might negotiate once a month or save up as a reward for years of dedication. Today, it's become the new normal for millions of workers worldwide, fundamentally reshaping how we think about productivity, work-life balance, and the very concept of a workplace.
The shift didn't happen overnight. While remote work has existed in various forms for decades, the global pandemic accelerated its adoption at an unprecedented pace. Companies that had long resisted flexible work arrangements suddenly had no choice but to adapt or risk falling behind. What they discovered, many to their surprise, was that remote work wasn't just a temporary band-aid—it was a viable, often superior way of operating.
Now, as we settle into this new era, the conversation has evolved. We're no longer asking whether remote work works; we're asking how to make it thrive. And that's exactly what we'll explore together in this space.
Whether you're a seasoned remote worker navigating the challenges of isolation and self-discipline, a manager learning to lead distributed teams, or someone stilladjusting to this new reality, there's something here for you. We'll dive into practical tips for staying productive, strategies for maintaining work-life boundaries, tools that can transform your virtual workspace, and the ever-important art of staying connected with colleagues when physical distance separates you.
But more than that, we'll examine the bigger picture—the cultural shift happening right now, the companies leading the charge, and what the "future of work" actually looks like when we reimagine it from the ground up.
So grab your coffee, find your favorite spot, and let's explore this remote work journey together. The office of tomorrow isn't a place—it's a mindset.

View File

@ -0,0 +1,19 @@
# The Rise of Remote Work: Embracing the Future of Productivity
The way we work has fundamentally changed. What started as a necessity during global uncertainty has evolved into a permanent shift in how businesses operate. Remote work is no longer just a trend—it's the new normal.
For many professionals, the freedom to work from anywhere is revolutionary. Imagine starting your day without the stress of commuting, having the flexibility to design your workspace exactly how you like it, and reclaiming hours previously lost in traffic. Remote work offers these benefits and so much more.
But let's be honest: working from home isn't without its challenges. Distractions abound, the line between work and personal life can blur, and the lack of face-to-face interaction sometimes feels isolating. The key to thriving in a remote environment lies in establishing healthy habits and creating boundaries.
Successful remote workers prioritize structure. Setting clear start and end times, designating a dedicated workspace, and taking regular breaks helps maintain productivity and prevent burnout. Communication becomes even more critical when working remotely—overcommunicating with teammates ensures everyone stays aligned and connected.
Companies embracing remote work are also discovering unexpected benefits. Access to global talent, reduced overhead costs, and increased employee satisfaction are just a few advantages businesses are experiencing. Studies consistently show that remote workers often report higher productivity and job satisfaction when given the right tools and autonomy.
Technology has made remote collaboration easier than ever. From video conferencing to project management platforms, the digital infrastructure supporting distributed teams continues to improve rapidly.
As we move forward, the most successful organizations will be those that adopt a hybrid approach—combining the best of remote flexibility with intentional in-person collaboration when needed. The future of work isn't about choosing between office or home; it's about creating flexible environments where people can do their best work, wherever that may be.
Remote work is here to stay. The question isn't whether to adapt, but how quickly we can embrace this transformation.

View File

@ -0,0 +1,17 @@
Sure! Heres a sample intro for a blog post on remote work:
---
**The Rise of Remote Work: Redefining How We Work, Live, and Connect**
In just a few short years, the way we think about “the office” has undergone a dramatic transformation. What once felt like a futuristic perk for a select few has become a mainstream reality for millions of workers across the globe. Remote work—no longer constrained by cubicles or commute times—has reshaped everything from daily schedules to company culture, sparking both excitement and new challenges.
For many, the appeal is undeniable: flexibility to design a workday that fits personal rhythms, the ability to work from anywhere, and the promise of a better worklife balance. Yet with these advantages come fresh considerations—how do teams stay connected without facetoface interaction? How can managers maintain productivity and accountability in a distributed environment? And what does this shift mean for the future of office spaces, employee wellbeing, and even urban planning?
In this post, well explore the key benefits that have driven remote works rapid adoption, examine the common hurdles companies face when going virtual, and share actionable strategies to help you thrive in a world where the office is wherever you are. Whether youre a seasoned remote veteran or just beginning to navigate this new landscape, join us as we unpack the trends, tools, and mindsets that are shaping the future of work.
---
Feel free to let me know if youd like a different tone, length, or focus!

View File

@ -0,0 +1,17 @@
# The Remote Work Revolution: Why Working From Home Is Here to Stay
The way we work has fundamentally changed. What began as a necessity during global uncertainty has evolved into a permanent shift in how we approach our professional lives. Remote work is no longer an experiment—it's the new normal.
For years, traditional office culture dominated the workforce. Employees commuted long hours, sat in cubicles, and adhered to rigid schedules that prioritized presence over productivity. Then everything changed virtually overnight. Companies that once resisted remote arrangements discovered something surprising: their employees could be just as—if not more—productive working from home.
The benefits of remote work extend far beyond avoiding rush hour traffic. Workers now enjoy unparalleled flexibility, the ability to design their ideal workspace, and often a better work-life balance. Companies report reduced overhead costs, access to a global talent pool, and often improved employee retention rates.
Yet remote work isn't without its challenges. Building team cohesion, maintaining company culture, and combating isolation require intentional effort and new strategies. The most successful remote teams have learned to adapt, leveraging technology and communication tools to bridge the physical distance.
As we move forward in this new era of work, one thing becomes clear: the traditional 9-to-5 office model will never fully return. Remote work has proven its viability, and both employers and employees are reaping the rewards.
Whether you're a remote work veteran or just beginning to navigate this landscape, understanding its nuances is essential for success in today's professional world. The future of work isn't coming—it's already here, and it's more flexible than ever before.
In this post, we'll explore practical tips, common pitfalls, and strategies to help you thrive in your remote work journey. Let's dive in!

View File

@ -0,0 +1,13 @@
# The Future of Work Is Flexible: Embracing the Remote Revolution
The way we work has undergone a dramatic transformation in recent years, accelerated by global events that forced businesses to rethink their operations entirely. What began as a necessity has evolved into a fundamental shift in how we define the modern workplace. Remote work is no longer just an alternative—it has become a permanent fixture in the workforce landscape, reshaping everything from office culture to employee expectations.
For many professionals, the traditional 9-to-5 commute has become a relic of the past. Instead, workers now have the freedom to design their days around their most productive hours, whether that means early morning focus sessions or late-night creative bursts. This flexibility has proven to be a game-changer for work-life balance, allowing people to reclaim time that was once lost to traffic and lengthy commutes.
But remote work isn't just about convenience—it's about opportunity. Companies are no longer limited by geographic boundaries when hiring talent, and employees can now collaborate with colleagues across time zones and continents. This democratization of the job market has opened doors for professionals who previously faced barriers due to location, disability, or caregiving responsibilities.
Of course, this new paradigm comes with its own set of challenges. Maintaining company culture, fostering spontaneous collaboration, and preventing burnout require intentional strategies that differ from traditional office management. The companies that will thrive in this environment are those that embrace these challenges as opportunities for innovation.
As we continue to navigate this shifting landscape, one thing is clear: the future of work will be defined by flexibility, autonomy, and the ability to adapt. Whether you're a seasoned remote worker or just beginning to explore this world, the possibilities are greater than ever before. The question isn't whether remote work will persist—it's how we can all learn to make the most of this transformative era.

View File

@ -0,0 +1,13 @@
# The Future of Work Is Remote — And It's Here to Stay
Remember when the idea of working from your couch in pajamas seemed like a distant dream? Today, it's reality for millions of professionals around the world. Remote work has transformed from a pandemic experiment into a fundamental shift in how we think about productivity, work-life balance, and the very nature of the modern workplace.
What started out of necessity has become a movement. Companies that once insisted on corner offices now embrace distributed teams spanning continents. Workers who spent hours commuting have reclaimed their time—and their sanity. The traditional 9-to-5 is evolving into something more fluid, more flexible, and frankly, more human.
But let's be honest: remote work isn't all sunshine and sweatpants. It comes with its own set of challenges—blurred boundaries, isolation, the endless struggle between "am I on a Zoom call or just hallucinating?" Yet for many, the benefits far outweigh the drawbacks.
Whether you're a seasoned remote worker, a manager trying to lead a distributed team, or someone simply curious about this new way of working, you're in the right place. We're diving deep into everything that makes remote work work—from productivity hacks and tools to the cultural shifts reshaping our careers.
So grab your coffee (or tea—no judgment here), settle in, and let's explore how the world is redefining what it means to go to work.

View File

@ -0,0 +1,21 @@
# The Future of Work Is Flexible: Embracing the Remote Revolution
---
**Remember when the idea of working from your couch seemed like a distant dream?** Just a few short years ago, remote work was considered a rare perk—something reserved for freelancers, digital nomads, and the occasional "work from home Friday." Today, it's become the new normal for millions of workers worldwide.
The shift happened practically overnight. What started as a necessity has transformed into a fundamental change in how we think about work, productivity, and work-life balance. And here's the thing: **we're not going back.**
Whether you're a seasoned remote worker, an employer navigating this new landscape, or someone who's just curious about what the future holds, there's something here for you. In this post, we'll explore:
- The unexpected benefits discovered along the way
- The challenges no one warned us about
- Practical strategies for thriving in a distributed workplace
So grab your coffee (or tea—whatever fuels your productivity), get comfortable, and let's dive into the conversation that's reshaping our professional lives.
---
*Ready to explore the world of remote work? Let's get started.*

View File

@ -0,0 +1,11 @@
The office door is no longer a physical place you walk through each morning; its a state of mind. Over the past few years, remote work has evolved from a convenient experiment to a fundamental pillar of how modern organizations operate. What began as a necessity during the pandemic has become a strategic choice that reshapes talent acquisition, employee wellbeing, and even corporate culture.
For many professionals, the freedom to work from a home office, a coworking space, or a café on the other side of the world is exhilarating. No longer bound by a daily commute, we reclaim hours that can be invested in deep work, family, or simply a morning run. Yet this new freedom also brings challenges: blurred boundaries between personal and professional life, the need for selfdiscipline, and the risk of isolation.
In this post well explore the key ingredients that make remote work sustainable—not just viable. Well dive into actionable strategies for structuring your day, setting up a productive workspace, and leveraging the right digital tools to stay connected with teammates. Well also examine how leaders can foster a culture of trust and accountability without relying on the old model of physical presence.
Whether youre a remotework veteran looking to refine your routine or an organization on the cusp of embracing distributed teams, understanding the nuances of this shift is essential. The future of work isnt a single location; its a mindset that values flexibility, resultsoriented performance, and intentional communication.
So, if youve ever wondered how to turn the comfort of your living room into a highperformance hub, or how to lead a team that thrives across time zones, youre in the right place. Lets unpack the tools, habits, and mindsets that can make remote work not just workable, but truly rewarding.

View File

@ -0,0 +1,19 @@
# The Morning That Changed Everything
The alarm goes off at 7:30 AM. Instead of scrambling to catch the 7:45 train, you roll over, check your phone, and start work from your kitchen table—coffee in hand, sweatpants on, the dog resting at your feet. Three years ago, this scenario felt like a fantasy. Today, it's the reality for millions of workers worldwide.
Remote work has evolved from a pandemic experiment into a fundamental shift in how we approach our careers. What started as a necessity has become a lifestyle choice that companies can no longer ignore. The traditional office, once the undisputed heart of professional life, now shares space with home offices, co-working spaces, and the occasional coffee shop corner.
But here's what nobody talks about enough: remote work isn't just about where you work. It's about how you live. It touches every aspect of your day—from the commute you'll never make again to the boundaries you'll struggle to set. It changes your relationships with colleagues, your productivity patterns, and even your sense of belonging.
I've been working remotely for five years now. I've experienced the freedom of flexible schedules and the loneliness of isolation. I've had days when my productivity soared and others when I couldn't focus for more than twenty minutes. I've learned that success in a remote environment requires more than just a reliable internet connection—it demands intention, discipline, and a willingness to reinvent how you work.
Whether you're a remote work veteran, a newly minted telecommuter, or a manager trying to lead a distributed team, there's something here for you. We're going to explore the real challenges of working from home, the strategies that actually work, and the future that's unfolding right now in our pajamas.
Let's start by addressing the elephant in the room: working from home is hard. Not because the work itself changes, but because everything else does.
---
*This post is approximately 300 words and sets up a practical, relatable exploration of remote work challenges and solutions.*

View File

@ -0,0 +1,13 @@
# The Future of Work is Here—And It's Remote
Remember when working from home was considered a perk? Now it's the new normal. Over the past few years, remote work has transformed from a niche arrangement into a global revolution. What started out of necessity has become a lifestyle choice that millions are embracing—and refusing to give up.
Whether you're a seasoned remote worker, a manager navigating hybrid teams, or someone just curious about the hype, you've landed in the right place. In this blog, we're diving into everything that makes remote work tick: productivity tips, communication strategies, the best tools to stay connected, and honest conversations about the challenges you won't see on LinkedIn highlight reels.
But here's what makes this space so exciting—there's no one-size-fits-all approach. Remote work looks different for a freelance designer in Bali, a startup founder in Austin, and a corporate employee in London. We're here to explore it all.
So grab your coffee (or tea—no judgment here), settle into your comfiest workspace, and let's talk about how work is evolving in ways we never thought possible.
**Ready to rethink the way you work?** Let's get started.

View File

@ -0,0 +1,9 @@
The era of remote work has transitioned from a convenient experiment to a fundamental pillar of the modern workforce. Over the past few years, companies that once insisted on brickandmortar offices have embraced distributed teams, and the pandemic accelerated a trend that was already quietly gaining momentum. Today, millions of employees log in from home offices, coworking spaces, or cafés, redefining what it means to “go to work.”
What makes this shift so compelling is not just the flexibility it offers, but the profound impact on productivity, talent acquisition, and worklife balance. Studies consistently show that remote workers often report higher job satisfaction and are able to craft schedules that align with their personal rhythms. For businesses, the ability to tap into a global talent pool means hiring the best candidate regardless of geography, which can drive innovation and competitive advantage.
Yet, with great freedom comes the need for new structures. Remote work demands intentional communication, robust digital tools, and a culture that trusts employees to deliver results rather than monitoring their every move. Managers must learn to lead distributed teams with empathy, while individuals need to cultivate discipline and boundaries to avoid burnout.
In this blog series we will explore the nuances of remote work: from best practices for setting up a productive home office, to strategies for maintaining team cohesion across time zones, and the evolving legal and ethical considerations that come with a borderless workforce. Whether you are a seasoned remote veteran or just beginning to navigate this new normal, join us as we unpack the opportunities, challenges, and stories that define the future of work. Expect insights on technology stacks, mental health resources, and realworld case studies from companies that have thriving remote cultures. Our goal is to equip readers with actionable tips, datadriven analysis, and inspiration to make remote work sustainable and rewarding.

View File

@ -0,0 +1,29 @@
# Remote Work Blog Intro
---
**Welcome to the Future of Work**
The way we work has fundamentally changed. What began as a necessity has evolved into a global shift in how we think about productivity, work-life balance, and the very concept of an office. Welcome to [Blog Name]—your go-to resource for navigating the ever-evolving world of remote work.
Whether you're a seasoned remote worker, a manager leading distributed teams, or someone considering making the leap from a traditional office setup, you're in the right place. Here, we explore:
- **Practical tips** for staying productive while working from your couch, co-working space, or favorite café
- **Tools and technology** that make remote collaboration seamless
- **Work-life balance strategies** to prevent burnout and maintain mental health
- **Success stories** from companies and individuals who've embraced the remote revolution
- **Industry trends** shaping the future of work
The traditional 9-to-5 is no longer the only path. The world is your office, and the possibilities are endless.
Join us as we dive deep into what it truly means to work remotely in 2024 and beyond. Let's redefine work together—one blog post at a time.
---
*Ready to make the most of your remote journey? Subscribe and never miss an update.*
---
Want me to tailor this to a specific niche (tech, creative fields, startups) or adjust the tone?

View File

@ -0,0 +1,15 @@
# The Future of Work Is Remote — And It's Here to Stay
Remember when the idea of working from your couch in pajamas seemed like a distant fantasy? Just a few years ago, remote work was considered a rare perk — something you might negotiate after months of proving yourself. Today, it's the new normal for millions of workers across the globe.
The shift happened almost overnight. What started as a necessary adaptation to global circumstances has evolved into a fundamental transformation of how we think about work, productivity, and work-life balance. Companies that once insisted on mandatory office presence are now embracing hybrid models or going fully remote — and many are wondering why they didn't make the switch sooner.
But let's be honest: remote work isn't all sunshine and flexible schedules. It comes with its own set of challenges — from navigating the blur between work and personal life to combating the loneliness that can creep in when your only interactions are through a screen. The tools we use have changed, the skills we need have shifted, and the expectations around communication have been completely redefined.
So what does it really mean to thrive in this new era of work? How do you stay productive without burning out? And how can businesses build genuine company culture when their teams are scattered across time zones?
Whether you're a remote work veteran, a newly minted telecommuter, or a manager trying to lead a distributed team, there's something here for you. We're about to dive deep into the strategies, mindsets, and practical tips that can help you not just survive — but actually excel in the world of remote work.
Let's get started.

View File

@ -0,0 +1,29 @@
# The Future of Work Isn't Where You Think It Is
---
**Remember when the biggest debate about your workspace was whether to bring a plant to your desk?**
Those feels like ancient history now.
Somewhere between the first Zoom happy hour and the 47th consecutive day of working in your pajamas, something shifted. We stopped asking *if* remote work would become mainstream and started figuring out *how* to make it actually work.
The coffee shop laptop warrior lifestyle? That's so 2019. Today's remote work landscape looks different—messier, more complicated, and honestly? Way more interesting.
**Here's what nobody tells you about working from home:**
It's not just about skipping the commute (though let's be honest, that part is *glorious*). It's about fundamentally reimagining how we define productivity, collaboration, and work-life integration.
Some days you're the most focused you've ever been. Other days, you realize you've been on three video calls and somehow still have "pants optional" energy.
That's the thing about remote work—it's not a destination. It's an ongoing experiment in finding what actually works for *you*.
Whether you're a seasoned WFH veteran, recently transplanted from an office, or somewhere in between wondering what happened to your work-life boundaries—this space is for you.
Let's figure this out together. 🚀
---
*Ready to dive deeper? In my next post, I'm breaking down the unexpected tools that have actually changed how I work remotely. Stay tuned.*

View File

@ -0,0 +1,871 @@
condition,i,j,cosine
sparse,0,1,0.8842676281929016
sparse,0,2,0.6326103806495667
sparse,0,3,0.9240177869796753
sparse,0,4,0.8476648330688477
sparse,0,5,0.9085270166397095
sparse,0,6,0.9192448258399963
sparse,0,7,0.8502165079116821
sparse,0,8,0.9135650992393494
sparse,0,9,0.9273526072502136
sparse,0,10,0.9210397005081177
sparse,0,11,0.9054363965988159
sparse,0,12,0.9092305898666382
sparse,0,13,0.8830623626708984
sparse,0,14,0.8360170722007751
sparse,0,15,0.910585343837738
sparse,0,16,0.9213393926620483
sparse,0,17,0.9307072758674622
sparse,0,18,0.6468598246574402
sparse,0,19,0.9281059503555298
sparse,0,20,0.8875342011451721
sparse,0,21,0.9016993641853333
sparse,0,22,0.9055689573287964
sparse,0,23,0.9168823957443237
sparse,0,24,0.8917397260665894
sparse,0,25,0.9045820236206055
sparse,0,26,0.9396462440490723
sparse,0,27,0.9080245494842529
sparse,0,28,0.9279836416244507
sparse,0,29,0.8502282500267029
sparse,1,2,0.6413184404373169
sparse,1,3,0.8654665946960449
sparse,1,4,0.8706030249595642
sparse,1,5,0.9060016870498657
sparse,1,6,0.8824361562728882
sparse,1,7,0.8451940417289734
sparse,1,8,0.8751769065856934
sparse,1,9,0.8970779180526733
sparse,1,10,0.8851304650306702
sparse,1,11,0.8394145965576172
sparse,1,12,0.9259476065635681
sparse,1,13,0.8169416785240173
sparse,1,14,0.8501150012016296
sparse,1,15,0.9277087450027466
sparse,1,16,0.9050824046134949
sparse,1,17,0.907709002494812
sparse,1,18,0.6208943128585815
sparse,1,19,0.9136478900909424
sparse,1,20,0.8580862283706665
sparse,1,21,0.908617377281189
sparse,1,22,0.920215368270874
sparse,1,23,0.861496090888977
sparse,1,24,0.8966708183288574
sparse,1,25,0.9270670413970947
sparse,1,26,0.8706053495407104
sparse,1,27,0.9321365356445312
sparse,1,28,0.8805779218673706
sparse,1,29,0.9095852375030518
sparse,2,3,0.6445199251174927
sparse,2,4,0.6170743703842163
sparse,2,5,0.6749987602233887
sparse,2,6,0.6325477957725525
sparse,2,7,0.6438075304031372
sparse,2,8,0.5972033143043518
sparse,2,9,0.625386118888855
sparse,2,10,0.6222283840179443
sparse,2,11,0.605262279510498
sparse,2,12,0.6787728071212769
sparse,2,13,0.5641254782676697
sparse,2,14,0.7593505382537842
sparse,2,15,0.6513075828552246
sparse,2,16,0.6267610788345337
sparse,2,17,0.637053370475769
sparse,2,18,0.765704333782196
sparse,2,19,0.6455702185630798
sparse,2,20,0.6371599435806274
sparse,2,21,0.6222635507583618
sparse,2,22,0.6366737484931946
sparse,2,23,0.6170545816421509
sparse,2,24,0.6734069585800171
sparse,2,25,0.6648688316345215
sparse,2,26,0.6076259613037109
sparse,2,27,0.6688461303710938
sparse,2,28,0.6180237531661987
sparse,2,29,0.6739405393600464
sparse,3,4,0.8110234141349792
sparse,3,5,0.8813445568084717
sparse,3,6,0.9259477853775024
sparse,3,7,0.8134850263595581
sparse,3,8,0.8831241130828857
sparse,3,9,0.892815113067627
sparse,3,10,0.8882905840873718
sparse,3,11,0.8728799819946289
sparse,3,12,0.8949353694915771
sparse,3,13,0.8767400979995728
sparse,3,14,0.827580451965332
sparse,3,15,0.8937259912490845
sparse,3,16,0.9206739664077759
sparse,3,17,0.9161497354507446
sparse,3,18,0.6427358984947205
sparse,3,19,0.9205663204193115
sparse,3,20,0.8657748103141785
sparse,3,21,0.9146169424057007
sparse,3,22,0.8927811980247498
sparse,3,23,0.923854410648346
sparse,3,24,0.8927577137947083
sparse,3,25,0.9023969173431396
sparse,3,26,0.8833497762680054
sparse,3,27,0.9054619669914246
sparse,3,28,0.9394411444664001
sparse,3,29,0.8542183041572571
sparse,4,5,0.8947666883468628
sparse,4,6,0.8403730392456055
sparse,4,7,0.8599867820739746
sparse,4,8,0.8390170931816101
sparse,4,9,0.8854458332061768
sparse,4,10,0.8534034490585327
sparse,4,11,0.872796893119812
sparse,4,12,0.8598482608795166
sparse,4,13,0.8116230964660645
sparse,4,14,0.8279128670692444
sparse,4,15,0.9125096201896667
sparse,4,16,0.8417664766311646
sparse,4,17,0.88572096824646
sparse,4,18,0.5861895680427551
sparse,4,19,0.8709115386009216
sparse,4,20,0.7974057793617249
sparse,4,21,0.798934817314148
sparse,4,22,0.7972773313522339
sparse,4,23,0.864152193069458
sparse,4,24,0.8792670965194702
sparse,4,25,0.8232656717300415
sparse,4,26,0.8541650176048279
sparse,4,27,0.8529558777809143
sparse,4,28,0.8347535133361816
sparse,4,29,0.8281573057174683
sparse,5,6,0.8967221975326538
sparse,5,7,0.8786804676055908
sparse,5,8,0.882672905921936
sparse,5,9,0.9054063558578491
sparse,5,10,0.8814719915390015
sparse,5,11,0.8864240050315857
sparse,5,12,0.921491265296936
sparse,5,13,0.8268054127693176
sparse,5,14,0.8557680249214172
sparse,5,15,0.916686475276947
sparse,5,16,0.9044321179389954
sparse,5,17,0.9051837921142578
sparse,5,18,0.6461120247840881
sparse,5,19,0.9035421013832092
sparse,5,20,0.8600203990936279
sparse,5,21,0.8979536294937134
sparse,5,22,0.9018139839172363
sparse,5,23,0.8904028534889221
sparse,5,24,0.9367042779922485
sparse,5,25,0.9132136702537537
sparse,5,26,0.8843980431556702
sparse,5,27,0.9249154329299927
sparse,5,28,0.8900863528251648
sparse,5,29,0.9152655005455017
sparse,6,7,0.8545246720314026
sparse,6,8,0.9271760582923889
sparse,6,9,0.9351356625556946
sparse,6,10,0.9277316331863403
sparse,6,11,0.8949606418609619
sparse,6,12,0.8937853574752808
sparse,6,13,0.8626421093940735
sparse,6,14,0.8484978675842285
sparse,6,15,0.8837480545043945
sparse,6,16,0.9403445720672607
sparse,6,17,0.9122255444526672
sparse,6,18,0.6574705839157104
sparse,6,19,0.9009673595428467
sparse,6,20,0.9067664742469788
sparse,6,21,0.9146592617034912
sparse,6,22,0.9055305123329163
sparse,6,23,0.9123458862304688
sparse,6,24,0.8873148560523987
sparse,6,25,0.8834550976753235
sparse,6,26,0.9009072780609131
sparse,6,27,0.9002054929733276
sparse,6,28,0.9056134223937988
sparse,6,29,0.8740131855010986
sparse,7,8,0.8605605363845825
sparse,7,9,0.8879753351211548
sparse,7,10,0.8802920579910278
sparse,7,11,0.8985400199890137
sparse,7,12,0.839560866355896
sparse,7,13,0.7728735208511353
sparse,7,14,0.8081755638122559
sparse,7,15,0.850745439529419
sparse,7,16,0.8558989763259888
sparse,7,17,0.8790065050125122
sparse,7,18,0.6397872567176819
sparse,7,19,0.8266881704330444
sparse,7,20,0.8218787908554077
sparse,7,21,0.8486884832382202
sparse,7,22,0.8355494737625122
sparse,7,23,0.8576446771621704
sparse,7,24,0.8737776279449463
sparse,7,25,0.8344688415527344
sparse,7,26,0.8650765419006348
sparse,7,27,0.8290424942970276
sparse,7,28,0.8179117441177368
sparse,7,29,0.8456178903579712
sparse,8,9,0.9383413791656494
sparse,8,10,0.9075723886489868
sparse,8,11,0.8900797367095947
sparse,8,12,0.8817881345748901
sparse,8,13,0.864380955696106
sparse,8,14,0.7836644649505615
sparse,8,15,0.8806310892105103
sparse,8,16,0.9127763509750366
sparse,8,17,0.8996982574462891
sparse,8,18,0.6345311403274536
sparse,8,19,0.8696664571762085
sparse,8,20,0.8714538812637329
sparse,8,21,0.889926016330719
sparse,8,22,0.8761152029037476
sparse,8,23,0.9010666012763977
sparse,8,24,0.8452513217926025
sparse,8,25,0.8735554218292236
sparse,8,26,0.9257984161376953
sparse,8,27,0.897702693939209
sparse,8,28,0.8843334913253784
sparse,8,29,0.8460427522659302
sparse,9,10,0.9191476702690125
sparse,9,11,0.9133263826370239
sparse,9,12,0.897188127040863
sparse,9,13,0.8688154816627502
sparse,9,14,0.8274378776550293
sparse,9,15,0.9218814969062805
sparse,9,16,0.9329886436462402
sparse,9,17,0.9128957986831665
sparse,9,18,0.6394870281219482
sparse,9,19,0.8963242769241333
sparse,9,20,0.8777587413787842
sparse,9,21,0.8997803926467896
sparse,9,22,0.8859366774559021
sparse,9,23,0.9144659042358398
sparse,9,24,0.8884050250053406
sparse,9,25,0.8935339450836182
sparse,9,26,0.9255412220954895
sparse,9,27,0.899837851524353
sparse,9,28,0.9067240953445435
sparse,9,29,0.8684635758399963
sparse,10,11,0.9149747490882874
sparse,10,12,0.8907658457756042
sparse,10,13,0.880743145942688
sparse,10,14,0.8297433257102966
sparse,10,15,0.8956617116928101
sparse,10,16,0.91903156042099
sparse,10,17,0.931481659412384
sparse,10,18,0.6274497509002686
sparse,10,19,0.9059202671051025
sparse,10,20,0.8956723809242249
sparse,10,21,0.9240865707397461
sparse,10,22,0.9141536951065063
sparse,10,23,0.9095519781112671
sparse,10,24,0.8851323127746582
sparse,10,25,0.8820087909698486
sparse,10,26,0.9177753925323486
sparse,10,27,0.8900632262229919
sparse,10,28,0.9063703417778015
sparse,10,29,0.8650473952293396
sparse,11,12,0.8539594411849976
sparse,11,13,0.8522297143936157
sparse,11,14,0.7972484827041626
sparse,11,15,0.8613752722740173
sparse,11,16,0.8836559653282166
sparse,11,17,0.9036680459976196
sparse,11,18,0.6013502478599548
sparse,11,19,0.8542567491531372
sparse,11,20,0.840721845626831
sparse,11,21,0.8568153381347656
sparse,11,22,0.8365514874458313
sparse,11,23,0.9088377952575684
sparse,11,24,0.8673932552337646
sparse,11,25,0.8350614309310913
sparse,11,26,0.8958957195281982
sparse,11,27,0.8454614281654358
sparse,11,28,0.8725312948226929
sparse,11,29,0.8254844546318054
sparse,12,13,0.8327032327651978
sparse,12,14,0.8618159294128418
sparse,12,15,0.935163140296936
sparse,12,16,0.9113242030143738
sparse,12,17,0.9170982241630554
sparse,12,18,0.6652914881706238
sparse,12,19,0.9281109571456909
sparse,12,20,0.8742147088050842
sparse,12,21,0.9110912680625916
sparse,12,22,0.933711051940918
sparse,12,23,0.8747270107269287
sparse,12,24,0.9148557186126709
sparse,12,25,0.9421283602714539
sparse,12,26,0.8873841166496277
sparse,12,27,0.9607949256896973
sparse,12,28,0.9125003814697266
sparse,12,29,0.903962254524231
sparse,13,14,0.7713570594787598
sparse,13,15,0.8430774211883545
sparse,13,16,0.8640230894088745
sparse,13,17,0.8972781896591187
sparse,13,18,0.6021331548690796
sparse,13,19,0.8517624139785767
sparse,13,20,0.8299106359481812
sparse,13,21,0.8581174612045288
sparse,13,22,0.8484483361244202
sparse,13,23,0.8654480576515198
sparse,13,24,0.8172571659088135
sparse,13,25,0.8424535989761353
sparse,13,26,0.8663556575775146
sparse,13,27,0.8484017848968506
sparse,13,28,0.860560953617096
sparse,13,29,0.7843785285949707
sparse,14,15,0.8563508987426758
sparse,14,16,0.8789605498313904
sparse,14,17,0.8524014949798584
sparse,14,18,0.6586475372314453
sparse,14,19,0.8423929214477539
sparse,14,20,0.8463461399078369
sparse,14,21,0.831427812576294
sparse,14,22,0.8472365140914917
sparse,14,23,0.8180807828903198
sparse,14,24,0.8508400917053223
sparse,14,25,0.8383336067199707
sparse,14,26,0.8001997470855713
sparse,14,27,0.8574920892715454
sparse,14,28,0.8250898122787476
sparse,14,29,0.840126633644104
sparse,15,16,0.9088408946990967
sparse,15,17,0.9333357810974121
sparse,15,18,0.619517982006073
sparse,15,19,0.9468969106674194
sparse,15,20,0.8545538187026978
sparse,15,21,0.8843954801559448
sparse,15,22,0.8941930532455444
sparse,15,23,0.8877065181732178
sparse,15,24,0.910699188709259
sparse,15,25,0.9201681613922119
sparse,15,26,0.8790658712387085
sparse,15,27,0.9405113458633423
sparse,15,28,0.9131321907043457
sparse,15,29,0.8944071531295776
sparse,16,17,0.9254595041275024
sparse,16,18,0.6261307001113892
sparse,16,19,0.9052155017852783
sparse,16,20,0.9138200283050537
sparse,16,21,0.9385247230529785
sparse,16,22,0.938915491104126
sparse,16,23,0.9162461757659912
sparse,16,24,0.9008928537368774
sparse,16,25,0.9173368215560913
sparse,16,26,0.9003571271896362
sparse,16,27,0.9165087938308716
sparse,16,28,0.9278929233551025
sparse,16,29,0.8938648700714111
sparse,17,18,0.63990718126297
sparse,17,19,0.9339616894721985
sparse,17,20,0.8935018181800842
sparse,17,21,0.9089747071266174
sparse,17,22,0.9169867038726807
sparse,17,23,0.910098671913147
sparse,17,24,0.9033091068267822
sparse,17,25,0.9144382476806641
sparse,17,26,0.9024463295936584
sparse,17,27,0.9179341793060303
sparse,17,28,0.9073848724365234
sparse,17,29,0.8849005699157715
sparse,18,19,0.6413609385490417
sparse,18,20,0.6222062110900879
sparse,18,21,0.6384404897689819
sparse,18,22,0.6476013660430908
sparse,18,23,0.6100178956985474
sparse,18,24,0.640562891960144
sparse,18,25,0.6626884341239929
sparse,18,26,0.6258889436721802
sparse,18,27,0.655221164226532
sparse,18,28,0.6235411763191223
sparse,18,29,0.6345106363296509
sparse,19,20,0.8846615552902222
sparse,19,21,0.9076544046401978
sparse,19,22,0.9070618152618408
sparse,19,23,0.8959472179412842
sparse,19,24,0.9364303350448608
sparse,19,25,0.9195597767829895
sparse,19,26,0.8910270929336548
sparse,19,27,0.9174454212188721
sparse,19,28,0.9342420101165771
sparse,19,29,0.8804695010185242
sparse,20,21,0.8815634250640869
sparse,20,22,0.8995954990386963
sparse,20,23,0.8614389896392822
sparse,20,24,0.8640614151954651
sparse,20,25,0.8526194095611572
sparse,20,26,0.8731496334075928
sparse,20,27,0.8656830787658691
sparse,20,28,0.8889292478561401
sparse,20,29,0.843090295791626
sparse,21,22,0.9482401609420776
sparse,21,23,0.907638430595398
sparse,21,24,0.9100549817085266
sparse,21,25,0.9332237243652344
sparse,21,26,0.89919114112854
sparse,21,27,0.9202923774719238
sparse,21,28,0.924906849861145
sparse,21,29,0.9032953977584839
sparse,22,23,0.8672956228256226
sparse,22,24,0.9053719639778137
sparse,22,25,0.9390214681625366
sparse,22,26,0.8877921104431152
sparse,22,27,0.9303628206253052
sparse,22,28,0.9062334299087524
sparse,22,29,0.9002004861831665
sparse,23,24,0.8977915048599243
sparse,23,25,0.8809805512428284
sparse,23,26,0.9031069278717041
sparse,23,27,0.879271924495697
sparse,23,28,0.9145264625549316
sparse,23,29,0.8611963987350464
sparse,24,25,0.9024819135665894
sparse,24,26,0.874231219291687
sparse,24,27,0.8893753290176392
sparse,24,28,0.9042269587516785
sparse,24,29,0.9059606790542603
sparse,25,26,0.8671702742576599
sparse,25,27,0.9422962665557861
sparse,25,28,0.9114288687705994
sparse,25,29,0.9191234707832336
sparse,26,27,0.8780210614204407
sparse,26,28,0.9034676551818848
sparse,26,29,0.815056324005127
sparse,27,28,0.9079528450965881
sparse,27,29,0.9128996133804321
sparse,28,29,0.8434745073318481
dense,0,1,0.5426812171936035
dense,0,2,0.5042607188224792
dense,0,3,0.5040299296379089
dense,0,4,0.3717104196548462
dense,0,5,0.5608181953430176
dense,0,6,0.498557448387146
dense,0,7,0.541494607925415
dense,0,8,0.5500509738922119
dense,0,9,0.552819013595581
dense,0,10,0.6474924683570862
dense,0,11,0.3543661832809448
dense,0,12,0.5225517749786377
dense,0,13,0.48440051078796387
dense,0,14,0.47315672039985657
dense,0,15,0.48933741450309753
dense,0,16,0.4811818599700928
dense,0,17,0.4256090223789215
dense,0,18,0.33599504828453064
dense,0,19,0.3067922294139862
dense,0,20,0.43410831689834595
dense,0,21,0.5642249584197998
dense,0,22,0.5651165843009949
dense,0,23,0.3205108642578125
dense,0,24,0.3557089567184448
dense,0,25,0.27304956316947937
dense,0,26,0.435065895318985
dense,0,27,0.5984233617782593
dense,0,28,0.5149794220924377
dense,0,29,0.5761032104492188
dense,1,2,0.2895265519618988
dense,1,3,0.2747717499732971
dense,1,4,0.4900481700897217
dense,1,5,0.3030499815940857
dense,1,6,0.3587196171283722
dense,1,7,0.3080611228942871
dense,1,8,0.2731598913669586
dense,1,9,0.3543684482574463
dense,1,10,0.4287572205066681
dense,1,11,0.210757315158844
dense,1,12,0.5421983003616333
dense,1,13,0.4122552275657654
dense,1,14,0.3229770064353943
dense,1,15,0.22612902522087097
dense,1,16,0.3259778618812561
dense,1,17,0.24620750546455383
dense,1,18,0.213441401720047
dense,1,19,0.11985690891742706
dense,1,20,0.42235004901885986
dense,1,21,0.40007948875427246
dense,1,22,0.3605784475803375
dense,1,23,0.1746138036251068
dense,1,24,0.15899652242660522
dense,1,25,0.20467904210090637
dense,1,26,0.2426464706659317
dense,1,27,0.2497156262397766
dense,1,28,0.2615622282028198
dense,1,29,0.3286632299423218
dense,2,3,0.5966466665267944
dense,2,4,0.2386525571346283
dense,2,5,0.49105143547058105
dense,2,6,0.4968917667865753
dense,2,7,0.628160834312439
dense,2,8,0.4614243507385254
dense,2,9,0.4696125090122223
dense,2,10,0.4007076025009155
dense,2,11,0.4808601140975952
dense,2,12,0.35756105184555054
dense,2,13,0.3679182827472687
dense,2,14,0.5177804231643677
dense,2,15,0.6132453680038452
dense,2,16,0.46475833654403687
dense,2,17,0.5300511121749878
dense,2,18,0.43030330538749695
dense,2,19,0.5060741901397705
dense,2,20,0.48910027742385864
dense,2,21,0.569895327091217
dense,2,22,0.5259388089179993
dense,2,23,0.4830540716648102
dense,2,24,0.5715628266334534
dense,2,25,0.497528612613678
dense,2,26,0.6030998229980469
dense,2,27,0.3830490708351135
dense,2,28,0.3645195960998535
dense,2,29,0.5060758590698242
dense,3,4,0.32607895135879517
dense,3,5,0.497550368309021
dense,3,6,0.4824564456939697
dense,3,7,0.7920247316360474
dense,3,8,0.7281898260116577
dense,3,9,0.5161672830581665
dense,3,10,0.4491059184074402
dense,3,11,0.6588993668556213
dense,3,12,0.49549680948257446
dense,3,13,0.4993891417980194
dense,3,14,0.5448479652404785
dense,3,15,0.5391305685043335
dense,3,16,0.490363746881485
dense,3,17,0.7014755010604858
dense,3,18,0.5403355360031128
dense,3,19,0.5369316339492798
dense,3,20,0.4954792857170105
dense,3,21,0.6320199966430664
dense,3,22,0.5707240104675293
dense,3,23,0.6124159097671509
dense,3,24,0.5441625118255615
dense,3,25,0.46846291422843933
dense,3,26,0.6465510129928589
dense,3,27,0.43107110261917114
dense,3,28,0.3486188054084778
dense,3,29,0.6480132341384888
dense,4,5,0.13829746842384338
dense,4,6,0.3496088981628418
dense,4,7,0.35683321952819824
dense,4,8,0.3551923632621765
dense,4,9,0.17715106904506683
dense,4,10,0.30900606513023376
dense,4,11,0.37191036343574524
dense,4,12,0.515075147151947
dense,4,13,0.45288342237472534
dense,4,14,0.30659961700439453
dense,4,15,0.1454406976699829
dense,4,16,0.3252817988395691
dense,4,17,0.30676794052124023
dense,4,18,0.3321307301521301
dense,4,19,0.1190565973520279
dense,4,20,0.40268805623054504
dense,4,21,0.4711156487464905
dense,4,22,0.4167401194572449
dense,4,23,0.2738344073295593
dense,4,24,0.18105947971343994
dense,4,25,0.1496509611606598
dense,4,26,0.2260037660598755
dense,4,27,0.2879602909088135
dense,4,28,0.19629129767417908
dense,4,29,0.4747577905654907
dense,5,6,0.38082534074783325
dense,5,7,0.4930320382118225
dense,5,8,0.4283558428287506
dense,5,9,0.669512152671814
dense,5,10,0.44116559624671936
dense,5,11,0.290874719619751
dense,5,12,0.29975831508636475
dense,5,13,0.44873881340026855
dense,5,14,0.42001670598983765
dense,5,15,0.5281816124916077
dense,5,16,0.3498140573501587
dense,5,17,0.41852009296417236
dense,5,18,0.3139491379261017
dense,5,19,0.2889784276485443
dense,5,20,0.25812193751335144
dense,5,21,0.3937273323535919
dense,5,22,0.4792669117450714
dense,5,23,0.28391504287719727
dense,5,24,0.3111538887023926
dense,5,25,0.39785969257354736
dense,5,26,0.42567741870880127
dense,5,27,0.4162527918815613
dense,5,28,0.3445788323879242
dense,5,29,0.397895872592926
dense,6,7,0.5989577770233154
dense,6,8,0.4176211357116699
dense,6,9,0.42844122648239136
dense,6,10,0.37042802572250366
dense,6,11,0.4885943830013275
dense,6,12,0.47333812713623047
dense,6,13,0.4393719434738159
dense,6,14,0.4833228588104248
dense,6,15,0.4312422573566437
dense,6,16,0.5562551021575928
dense,6,17,0.5032444000244141
dense,6,18,0.34411293268203735
dense,6,19,0.3043546974658966
dense,6,20,0.5151889324188232
dense,6,21,0.6155843734741211
dense,6,22,0.5332874059677124
dense,6,23,0.4117812514305115
dense,6,24,0.27498674392700195
dense,6,25,0.2366771697998047
dense,6,26,0.42676353454589844
dense,6,27,0.3905026912689209
dense,6,28,0.3402196168899536
dense,6,29,0.5472028851509094
dense,7,8,0.6737629771232605
dense,7,9,0.5277332663536072
dense,7,10,0.40918487310409546
dense,7,11,0.6664385199546814
dense,7,12,0.4781341552734375
dense,7,13,0.5130869746208191
dense,7,14,0.5498493909835815
dense,7,15,0.5200650095939636
dense,7,16,0.576724648475647
dense,7,17,0.7978911399841309
dense,7,18,0.553012490272522
dense,7,19,0.4492809772491455
dense,7,20,0.5889822840690613
dense,7,21,0.6908119320869446
dense,7,22,0.5997567772865295
dense,7,23,0.5919932126998901
dense,7,24,0.4685876667499542
dense,7,25,0.39794421195983887
dense,7,26,0.6806017160415649
dense,7,27,0.4747508764266968
dense,7,28,0.3982357978820801
dense,7,29,0.7144496440887451
dense,8,9,0.5081117749214172
dense,8,10,0.5527817010879517
dense,8,11,0.5140917897224426
dense,8,12,0.539786159992218
dense,8,13,0.44435182213783264
dense,8,14,0.5299499034881592
dense,8,15,0.4823214113712311
dense,8,16,0.4031447768211365
dense,8,17,0.6469109654426575
dense,8,18,0.5798747539520264
dense,8,19,0.3682907819747925
dense,8,20,0.4005461633205414
dense,8,21,0.5200881361961365
dense,8,22,0.5692099332809448
dense,8,23,0.4877336323261261
dense,8,24,0.5185465812683105
dense,8,25,0.2799598276615143
dense,8,26,0.5624496340751648
dense,8,27,0.5435441136360168
dense,8,28,0.3378525674343109
dense,8,29,0.6686346530914307
dense,9,10,0.5542443990707397
dense,9,11,0.29231393337249756
dense,9,12,0.3183872699737549
dense,9,13,0.41162341833114624
dense,9,14,0.442322313785553
dense,9,15,0.4693447947502136
dense,9,16,0.3217230439186096
dense,9,17,0.480522096157074
dense,9,18,0.3432811200618744
dense,9,19,0.27502474188804626
dense,9,20,0.2825862765312195
dense,9,21,0.34340065717697144
dense,9,22,0.37598609924316406
dense,9,23,0.28827592730522156
dense,9,24,0.39456868171691895
dense,9,25,0.3105553090572357
dense,9,26,0.427196741104126
dense,9,27,0.4674358069896698
dense,9,28,0.3959004282951355
dense,9,29,0.45349130034446716
dense,10,11,0.31417757272720337
dense,10,12,0.4464389681816101
dense,10,13,0.4322998523712158
dense,10,14,0.46415045857429504
dense,10,15,0.37895143032073975
dense,10,16,0.2764476239681244
dense,10,17,0.391769140958786
dense,10,18,0.34215736389160156
dense,10,19,0.22365112602710724
dense,10,20,0.2151973694562912
dense,10,21,0.36064353585243225
dense,10,22,0.45439016819000244
dense,10,23,0.22567592561244965
dense,10,24,0.36456334590911865
dense,10,25,0.21755696833133698
dense,10,26,0.34946566820144653
dense,10,27,0.5572035312652588
dense,10,28,0.4414980411529541
dense,10,29,0.4698684513568878
dense,11,12,0.3674253225326538
dense,11,13,0.3937452733516693
dense,11,14,0.47561436891555786
dense,11,15,0.38788777589797974
dense,11,16,0.47350791096687317
dense,11,17,0.6267008781433105
dense,11,18,0.5310146808624268
dense,11,19,0.4136892855167389
dense,11,20,0.4378977119922638
dense,11,21,0.5639837384223938
dense,11,22,0.4795612692832947
dense,11,23,0.5592877864837646
dense,11,24,0.36043962836265564
dense,11,25,0.4523385167121887
dense,11,26,0.594154953956604
dense,11,27,0.2988704442977905
dense,11,28,0.3328131437301636
dense,11,29,0.5524470806121826
dense,12,13,0.45257318019866943
dense,12,14,0.41544634103775024
dense,12,15,0.3248499035835266
dense,12,16,0.4771966338157654
dense,12,17,0.370833158493042
dense,12,18,0.3586691617965698
dense,12,19,0.21246004104614258
dense,12,20,0.4953663945198059
dense,12,21,0.6135953664779663
dense,12,22,0.5572450160980225
dense,12,23,0.2862359285354614
dense,12,24,0.3172096610069275
dense,12,25,0.28997135162353516
dense,12,26,0.2946573495864868
dense,12,27,0.36073142290115356
dense,12,28,0.22632575035095215
dense,12,29,0.5616676211357117
dense,13,14,0.38953208923339844
dense,13,15,0.3936968147754669
dense,13,16,0.4567197561264038
dense,13,17,0.422615110874176
dense,13,18,0.39199474453926086
dense,13,19,0.27278459072113037
dense,13,20,0.42780613899230957
dense,13,21,0.5118963122367859
dense,13,22,0.4995694160461426
dense,13,23,0.37077659368515015
dense,13,24,0.3279068171977997
dense,13,25,0.3176647424697876
dense,13,26,0.39856427907943726
dense,13,27,0.3458443284034729
dense,13,28,0.2871280908584595
dense,13,29,0.4786519706249237
dense,14,15,0.4592739939689636
dense,14,16,0.33047911524772644
dense,14,17,0.5018176436424255
dense,14,18,0.48004820942878723
dense,14,19,0.3025275468826294
dense,14,20,0.4357358515262604
dense,14,21,0.46754202246665955
dense,14,22,0.5680745244026184
dense,14,23,0.3685319423675537
dense,14,24,0.35438477993011475
dense,14,25,0.3354896903038025
dense,14,26,0.4846717417240143
dense,14,27,0.3669080138206482
dense,14,28,0.4139530062675476
dense,14,29,0.5009810328483582
dense,15,16,0.3842797875404358
dense,15,17,0.46804526448249817
dense,15,18,0.4450647234916687
dense,15,19,0.3589841425418854
dense,15,20,0.370371550321579
dense,15,21,0.43040627241134644
dense,15,22,0.4456382989883423
dense,15,23,0.34804418683052063
dense,15,24,0.603238582611084
dense,15,25,0.3926905393600464
dense,15,26,0.612182080745697
dense,15,27,0.3380131125450134
dense,15,28,0.37419959902763367
dense,15,29,0.43350717425346375
dense,16,17,0.4088405966758728
dense,16,18,0.35335925221443176
dense,16,19,0.3381205201148987
dense,16,20,0.5439355373382568
dense,16,21,0.722061038017273
dense,16,22,0.6298626065254211
dense,16,23,0.4045204520225525
dense,16,24,0.302739679813385
dense,16,25,0.2860240936279297
dense,16,26,0.42039185762405396
dense,16,27,0.3656077980995178
dense,16,28,0.24812036752700806
dense,16,29,0.5669870972633362
dense,17,18,0.591689944267273
dense,17,19,0.36419254541397095
dense,17,20,0.41468876600265503
dense,17,21,0.4666168689727783
dense,17,22,0.41559386253356934
dense,17,23,0.5092487931251526
dense,17,24,0.4388306140899658
dense,17,25,0.34807470440864563
dense,17,26,0.6798630952835083
dense,17,27,0.40526777505874634
dense,17,28,0.369731605052948
dense,17,29,0.6200430393218994
dense,18,19,0.25969788432121277
dense,18,20,0.3349460959434509
dense,18,21,0.4045875072479248
dense,18,22,0.4005787968635559
dense,18,23,0.4299025535583496
dense,18,24,0.4042985439300537
dense,18,25,0.33325475454330444
dense,18,26,0.5198383331298828
dense,18,27,0.2700243592262268
dense,18,28,0.2148766964673996
dense,18,29,0.49256980419158936
dense,19,20,0.3340801000595093
dense,19,21,0.39428186416625977
dense,19,22,0.313734233379364
dense,19,23,0.6585713624954224
dense,19,24,0.47322744131088257
dense,19,25,0.5150237083435059
dense,19,26,0.38984689116477966
dense,19,27,0.1962963044643402
dense,19,28,0.10944066941738129
dense,19,29,0.28927475214004517
dense,20,21,0.67474365234375
dense,20,22,0.5193013548851013
dense,20,23,0.35346147418022156
dense,20,24,0.36875614523887634
dense,20,25,0.3168618381023407
dense,20,26,0.3701702356338501
dense,20,27,0.35537296533584595
dense,20,28,0.2407120168209076
dense,20,29,0.545055627822876
dense,21,22,0.7242523431777954
dense,21,23,0.5096490383148193
dense,21,24,0.3886288106441498
dense,21,25,0.40029942989349365
dense,21,26,0.47089481353759766
dense,21,27,0.4564080536365509
dense,21,28,0.28135305643081665
dense,21,29,0.7072129845619202
dense,22,23,0.3829442858695984
dense,22,24,0.34923917055130005
dense,22,25,0.2716079652309418
dense,22,26,0.45111536979675293
dense,22,27,0.5446730852127075
dense,22,28,0.31327900290489197
dense,22,29,0.6425544023513794
dense,23,24,0.4543180465698242
dense,23,25,0.4462524950504303
dense,23,26,0.47702574729919434
dense,23,27,0.2148204743862152
dense,23,28,0.1756470650434494
dense,23,29,0.4037291705608368
dense,24,25,0.38182172179222107
dense,24,26,0.5807657241821289
dense,24,27,0.30628010630607605
dense,24,28,0.2531094253063202
dense,24,29,0.43440577387809753
dense,25,26,0.436298131942749
dense,25,27,0.06883661448955536
dense,25,28,0.15058887004852295
dense,25,29,0.27086764574050903
dense,26,27,0.38129034638404846
dense,26,28,0.3957631587982178
dense,26,29,0.5636626482009888
dense,27,28,0.3734338879585266
dense,27,29,0.5281729698181152
dense,28,29,0.36727917194366455
1 condition i j cosine
2 sparse 0 1 0.8842676281929016
3 sparse 0 2 0.6326103806495667
4 sparse 0 3 0.9240177869796753
5 sparse 0 4 0.8476648330688477
6 sparse 0 5 0.9085270166397095
7 sparse 0 6 0.9192448258399963
8 sparse 0 7 0.8502165079116821
9 sparse 0 8 0.9135650992393494
10 sparse 0 9 0.9273526072502136
11 sparse 0 10 0.9210397005081177
12 sparse 0 11 0.9054363965988159
13 sparse 0 12 0.9092305898666382
14 sparse 0 13 0.8830623626708984
15 sparse 0 14 0.8360170722007751
16 sparse 0 15 0.910585343837738
17 sparse 0 16 0.9213393926620483
18 sparse 0 17 0.9307072758674622
19 sparse 0 18 0.6468598246574402
20 sparse 0 19 0.9281059503555298
21 sparse 0 20 0.8875342011451721
22 sparse 0 21 0.9016993641853333
23 sparse 0 22 0.9055689573287964
24 sparse 0 23 0.9168823957443237
25 sparse 0 24 0.8917397260665894
26 sparse 0 25 0.9045820236206055
27 sparse 0 26 0.9396462440490723
28 sparse 0 27 0.9080245494842529
29 sparse 0 28 0.9279836416244507
30 sparse 0 29 0.8502282500267029
31 sparse 1 2 0.6413184404373169
32 sparse 1 3 0.8654665946960449
33 sparse 1 4 0.8706030249595642
34 sparse 1 5 0.9060016870498657
35 sparse 1 6 0.8824361562728882
36 sparse 1 7 0.8451940417289734
37 sparse 1 8 0.8751769065856934
38 sparse 1 9 0.8970779180526733
39 sparse 1 10 0.8851304650306702
40 sparse 1 11 0.8394145965576172
41 sparse 1 12 0.9259476065635681
42 sparse 1 13 0.8169416785240173
43 sparse 1 14 0.8501150012016296
44 sparse 1 15 0.9277087450027466
45 sparse 1 16 0.9050824046134949
46 sparse 1 17 0.907709002494812
47 sparse 1 18 0.6208943128585815
48 sparse 1 19 0.9136478900909424
49 sparse 1 20 0.8580862283706665
50 sparse 1 21 0.908617377281189
51 sparse 1 22 0.920215368270874
52 sparse 1 23 0.861496090888977
53 sparse 1 24 0.8966708183288574
54 sparse 1 25 0.9270670413970947
55 sparse 1 26 0.8706053495407104
56 sparse 1 27 0.9321365356445312
57 sparse 1 28 0.8805779218673706
58 sparse 1 29 0.9095852375030518
59 sparse 2 3 0.6445199251174927
60 sparse 2 4 0.6170743703842163
61 sparse 2 5 0.6749987602233887
62 sparse 2 6 0.6325477957725525
63 sparse 2 7 0.6438075304031372
64 sparse 2 8 0.5972033143043518
65 sparse 2 9 0.625386118888855
66 sparse 2 10 0.6222283840179443
67 sparse 2 11 0.605262279510498
68 sparse 2 12 0.6787728071212769
69 sparse 2 13 0.5641254782676697
70 sparse 2 14 0.7593505382537842
71 sparse 2 15 0.6513075828552246
72 sparse 2 16 0.6267610788345337
73 sparse 2 17 0.637053370475769
74 sparse 2 18 0.765704333782196
75 sparse 2 19 0.6455702185630798
76 sparse 2 20 0.6371599435806274
77 sparse 2 21 0.6222635507583618
78 sparse 2 22 0.6366737484931946
79 sparse 2 23 0.6170545816421509
80 sparse 2 24 0.6734069585800171
81 sparse 2 25 0.6648688316345215
82 sparse 2 26 0.6076259613037109
83 sparse 2 27 0.6688461303710938
84 sparse 2 28 0.6180237531661987
85 sparse 2 29 0.6739405393600464
86 sparse 3 4 0.8110234141349792
87 sparse 3 5 0.8813445568084717
88 sparse 3 6 0.9259477853775024
89 sparse 3 7 0.8134850263595581
90 sparse 3 8 0.8831241130828857
91 sparse 3 9 0.892815113067627
92 sparse 3 10 0.8882905840873718
93 sparse 3 11 0.8728799819946289
94 sparse 3 12 0.8949353694915771
95 sparse 3 13 0.8767400979995728
96 sparse 3 14 0.827580451965332
97 sparse 3 15 0.8937259912490845
98 sparse 3 16 0.9206739664077759
99 sparse 3 17 0.9161497354507446
100 sparse 3 18 0.6427358984947205
101 sparse 3 19 0.9205663204193115
102 sparse 3 20 0.8657748103141785
103 sparse 3 21 0.9146169424057007
104 sparse 3 22 0.8927811980247498
105 sparse 3 23 0.923854410648346
106 sparse 3 24 0.8927577137947083
107 sparse 3 25 0.9023969173431396
108 sparse 3 26 0.8833497762680054
109 sparse 3 27 0.9054619669914246
110 sparse 3 28 0.9394411444664001
111 sparse 3 29 0.8542183041572571
112 sparse 4 5 0.8947666883468628
113 sparse 4 6 0.8403730392456055
114 sparse 4 7 0.8599867820739746
115 sparse 4 8 0.8390170931816101
116 sparse 4 9 0.8854458332061768
117 sparse 4 10 0.8534034490585327
118 sparse 4 11 0.872796893119812
119 sparse 4 12 0.8598482608795166
120 sparse 4 13 0.8116230964660645
121 sparse 4 14 0.8279128670692444
122 sparse 4 15 0.9125096201896667
123 sparse 4 16 0.8417664766311646
124 sparse 4 17 0.88572096824646
125 sparse 4 18 0.5861895680427551
126 sparse 4 19 0.8709115386009216
127 sparse 4 20 0.7974057793617249
128 sparse 4 21 0.798934817314148
129 sparse 4 22 0.7972773313522339
130 sparse 4 23 0.864152193069458
131 sparse 4 24 0.8792670965194702
132 sparse 4 25 0.8232656717300415
133 sparse 4 26 0.8541650176048279
134 sparse 4 27 0.8529558777809143
135 sparse 4 28 0.8347535133361816
136 sparse 4 29 0.8281573057174683
137 sparse 5 6 0.8967221975326538
138 sparse 5 7 0.8786804676055908
139 sparse 5 8 0.882672905921936
140 sparse 5 9 0.9054063558578491
141 sparse 5 10 0.8814719915390015
142 sparse 5 11 0.8864240050315857
143 sparse 5 12 0.921491265296936
144 sparse 5 13 0.8268054127693176
145 sparse 5 14 0.8557680249214172
146 sparse 5 15 0.916686475276947
147 sparse 5 16 0.9044321179389954
148 sparse 5 17 0.9051837921142578
149 sparse 5 18 0.6461120247840881
150 sparse 5 19 0.9035421013832092
151 sparse 5 20 0.8600203990936279
152 sparse 5 21 0.8979536294937134
153 sparse 5 22 0.9018139839172363
154 sparse 5 23 0.8904028534889221
155 sparse 5 24 0.9367042779922485
156 sparse 5 25 0.9132136702537537
157 sparse 5 26 0.8843980431556702
158 sparse 5 27 0.9249154329299927
159 sparse 5 28 0.8900863528251648
160 sparse 5 29 0.9152655005455017
161 sparse 6 7 0.8545246720314026
162 sparse 6 8 0.9271760582923889
163 sparse 6 9 0.9351356625556946
164 sparse 6 10 0.9277316331863403
165 sparse 6 11 0.8949606418609619
166 sparse 6 12 0.8937853574752808
167 sparse 6 13 0.8626421093940735
168 sparse 6 14 0.8484978675842285
169 sparse 6 15 0.8837480545043945
170 sparse 6 16 0.9403445720672607
171 sparse 6 17 0.9122255444526672
172 sparse 6 18 0.6574705839157104
173 sparse 6 19 0.9009673595428467
174 sparse 6 20 0.9067664742469788
175 sparse 6 21 0.9146592617034912
176 sparse 6 22 0.9055305123329163
177 sparse 6 23 0.9123458862304688
178 sparse 6 24 0.8873148560523987
179 sparse 6 25 0.8834550976753235
180 sparse 6 26 0.9009072780609131
181 sparse 6 27 0.9002054929733276
182 sparse 6 28 0.9056134223937988
183 sparse 6 29 0.8740131855010986
184 sparse 7 8 0.8605605363845825
185 sparse 7 9 0.8879753351211548
186 sparse 7 10 0.8802920579910278
187 sparse 7 11 0.8985400199890137
188 sparse 7 12 0.839560866355896
189 sparse 7 13 0.7728735208511353
190 sparse 7 14 0.8081755638122559
191 sparse 7 15 0.850745439529419
192 sparse 7 16 0.8558989763259888
193 sparse 7 17 0.8790065050125122
194 sparse 7 18 0.6397872567176819
195 sparse 7 19 0.8266881704330444
196 sparse 7 20 0.8218787908554077
197 sparse 7 21 0.8486884832382202
198 sparse 7 22 0.8355494737625122
199 sparse 7 23 0.8576446771621704
200 sparse 7 24 0.8737776279449463
201 sparse 7 25 0.8344688415527344
202 sparse 7 26 0.8650765419006348
203 sparse 7 27 0.8290424942970276
204 sparse 7 28 0.8179117441177368
205 sparse 7 29 0.8456178903579712
206 sparse 8 9 0.9383413791656494
207 sparse 8 10 0.9075723886489868
208 sparse 8 11 0.8900797367095947
209 sparse 8 12 0.8817881345748901
210 sparse 8 13 0.864380955696106
211 sparse 8 14 0.7836644649505615
212 sparse 8 15 0.8806310892105103
213 sparse 8 16 0.9127763509750366
214 sparse 8 17 0.8996982574462891
215 sparse 8 18 0.6345311403274536
216 sparse 8 19 0.8696664571762085
217 sparse 8 20 0.8714538812637329
218 sparse 8 21 0.889926016330719
219 sparse 8 22 0.8761152029037476
220 sparse 8 23 0.9010666012763977
221 sparse 8 24 0.8452513217926025
222 sparse 8 25 0.8735554218292236
223 sparse 8 26 0.9257984161376953
224 sparse 8 27 0.897702693939209
225 sparse 8 28 0.8843334913253784
226 sparse 8 29 0.8460427522659302
227 sparse 9 10 0.9191476702690125
228 sparse 9 11 0.9133263826370239
229 sparse 9 12 0.897188127040863
230 sparse 9 13 0.8688154816627502
231 sparse 9 14 0.8274378776550293
232 sparse 9 15 0.9218814969062805
233 sparse 9 16 0.9329886436462402
234 sparse 9 17 0.9128957986831665
235 sparse 9 18 0.6394870281219482
236 sparse 9 19 0.8963242769241333
237 sparse 9 20 0.8777587413787842
238 sparse 9 21 0.8997803926467896
239 sparse 9 22 0.8859366774559021
240 sparse 9 23 0.9144659042358398
241 sparse 9 24 0.8884050250053406
242 sparse 9 25 0.8935339450836182
243 sparse 9 26 0.9255412220954895
244 sparse 9 27 0.899837851524353
245 sparse 9 28 0.9067240953445435
246 sparse 9 29 0.8684635758399963
247 sparse 10 11 0.9149747490882874
248 sparse 10 12 0.8907658457756042
249 sparse 10 13 0.880743145942688
250 sparse 10 14 0.8297433257102966
251 sparse 10 15 0.8956617116928101
252 sparse 10 16 0.91903156042099
253 sparse 10 17 0.931481659412384
254 sparse 10 18 0.6274497509002686
255 sparse 10 19 0.9059202671051025
256 sparse 10 20 0.8956723809242249
257 sparse 10 21 0.9240865707397461
258 sparse 10 22 0.9141536951065063
259 sparse 10 23 0.9095519781112671
260 sparse 10 24 0.8851323127746582
261 sparse 10 25 0.8820087909698486
262 sparse 10 26 0.9177753925323486
263 sparse 10 27 0.8900632262229919
264 sparse 10 28 0.9063703417778015
265 sparse 10 29 0.8650473952293396
266 sparse 11 12 0.8539594411849976
267 sparse 11 13 0.8522297143936157
268 sparse 11 14 0.7972484827041626
269 sparse 11 15 0.8613752722740173
270 sparse 11 16 0.8836559653282166
271 sparse 11 17 0.9036680459976196
272 sparse 11 18 0.6013502478599548
273 sparse 11 19 0.8542567491531372
274 sparse 11 20 0.840721845626831
275 sparse 11 21 0.8568153381347656
276 sparse 11 22 0.8365514874458313
277 sparse 11 23 0.9088377952575684
278 sparse 11 24 0.8673932552337646
279 sparse 11 25 0.8350614309310913
280 sparse 11 26 0.8958957195281982
281 sparse 11 27 0.8454614281654358
282 sparse 11 28 0.8725312948226929
283 sparse 11 29 0.8254844546318054
284 sparse 12 13 0.8327032327651978
285 sparse 12 14 0.8618159294128418
286 sparse 12 15 0.935163140296936
287 sparse 12 16 0.9113242030143738
288 sparse 12 17 0.9170982241630554
289 sparse 12 18 0.6652914881706238
290 sparse 12 19 0.9281109571456909
291 sparse 12 20 0.8742147088050842
292 sparse 12 21 0.9110912680625916
293 sparse 12 22 0.933711051940918
294 sparse 12 23 0.8747270107269287
295 sparse 12 24 0.9148557186126709
296 sparse 12 25 0.9421283602714539
297 sparse 12 26 0.8873841166496277
298 sparse 12 27 0.9607949256896973
299 sparse 12 28 0.9125003814697266
300 sparse 12 29 0.903962254524231
301 sparse 13 14 0.7713570594787598
302 sparse 13 15 0.8430774211883545
303 sparse 13 16 0.8640230894088745
304 sparse 13 17 0.8972781896591187
305 sparse 13 18 0.6021331548690796
306 sparse 13 19 0.8517624139785767
307 sparse 13 20 0.8299106359481812
308 sparse 13 21 0.8581174612045288
309 sparse 13 22 0.8484483361244202
310 sparse 13 23 0.8654480576515198
311 sparse 13 24 0.8172571659088135
312 sparse 13 25 0.8424535989761353
313 sparse 13 26 0.8663556575775146
314 sparse 13 27 0.8484017848968506
315 sparse 13 28 0.860560953617096
316 sparse 13 29 0.7843785285949707
317 sparse 14 15 0.8563508987426758
318 sparse 14 16 0.8789605498313904
319 sparse 14 17 0.8524014949798584
320 sparse 14 18 0.6586475372314453
321 sparse 14 19 0.8423929214477539
322 sparse 14 20 0.8463461399078369
323 sparse 14 21 0.831427812576294
324 sparse 14 22 0.8472365140914917
325 sparse 14 23 0.8180807828903198
326 sparse 14 24 0.8508400917053223
327 sparse 14 25 0.8383336067199707
328 sparse 14 26 0.8001997470855713
329 sparse 14 27 0.8574920892715454
330 sparse 14 28 0.8250898122787476
331 sparse 14 29 0.840126633644104
332 sparse 15 16 0.9088408946990967
333 sparse 15 17 0.9333357810974121
334 sparse 15 18 0.619517982006073
335 sparse 15 19 0.9468969106674194
336 sparse 15 20 0.8545538187026978
337 sparse 15 21 0.8843954801559448
338 sparse 15 22 0.8941930532455444
339 sparse 15 23 0.8877065181732178
340 sparse 15 24 0.910699188709259
341 sparse 15 25 0.9201681613922119
342 sparse 15 26 0.8790658712387085
343 sparse 15 27 0.9405113458633423
344 sparse 15 28 0.9131321907043457
345 sparse 15 29 0.8944071531295776
346 sparse 16 17 0.9254595041275024
347 sparse 16 18 0.6261307001113892
348 sparse 16 19 0.9052155017852783
349 sparse 16 20 0.9138200283050537
350 sparse 16 21 0.9385247230529785
351 sparse 16 22 0.938915491104126
352 sparse 16 23 0.9162461757659912
353 sparse 16 24 0.9008928537368774
354 sparse 16 25 0.9173368215560913
355 sparse 16 26 0.9003571271896362
356 sparse 16 27 0.9165087938308716
357 sparse 16 28 0.9278929233551025
358 sparse 16 29 0.8938648700714111
359 sparse 17 18 0.63990718126297
360 sparse 17 19 0.9339616894721985
361 sparse 17 20 0.8935018181800842
362 sparse 17 21 0.9089747071266174
363 sparse 17 22 0.9169867038726807
364 sparse 17 23 0.910098671913147
365 sparse 17 24 0.9033091068267822
366 sparse 17 25 0.9144382476806641
367 sparse 17 26 0.9024463295936584
368 sparse 17 27 0.9179341793060303
369 sparse 17 28 0.9073848724365234
370 sparse 17 29 0.8849005699157715
371 sparse 18 19 0.6413609385490417
372 sparse 18 20 0.6222062110900879
373 sparse 18 21 0.6384404897689819
374 sparse 18 22 0.6476013660430908
375 sparse 18 23 0.6100178956985474
376 sparse 18 24 0.640562891960144
377 sparse 18 25 0.6626884341239929
378 sparse 18 26 0.6258889436721802
379 sparse 18 27 0.655221164226532
380 sparse 18 28 0.6235411763191223
381 sparse 18 29 0.6345106363296509
382 sparse 19 20 0.8846615552902222
383 sparse 19 21 0.9076544046401978
384 sparse 19 22 0.9070618152618408
385 sparse 19 23 0.8959472179412842
386 sparse 19 24 0.9364303350448608
387 sparse 19 25 0.9195597767829895
388 sparse 19 26 0.8910270929336548
389 sparse 19 27 0.9174454212188721
390 sparse 19 28 0.9342420101165771
391 sparse 19 29 0.8804695010185242
392 sparse 20 21 0.8815634250640869
393 sparse 20 22 0.8995954990386963
394 sparse 20 23 0.8614389896392822
395 sparse 20 24 0.8640614151954651
396 sparse 20 25 0.8526194095611572
397 sparse 20 26 0.8731496334075928
398 sparse 20 27 0.8656830787658691
399 sparse 20 28 0.8889292478561401
400 sparse 20 29 0.843090295791626
401 sparse 21 22 0.9482401609420776
402 sparse 21 23 0.907638430595398
403 sparse 21 24 0.9100549817085266
404 sparse 21 25 0.9332237243652344
405 sparse 21 26 0.89919114112854
406 sparse 21 27 0.9202923774719238
407 sparse 21 28 0.924906849861145
408 sparse 21 29 0.9032953977584839
409 sparse 22 23 0.8672956228256226
410 sparse 22 24 0.9053719639778137
411 sparse 22 25 0.9390214681625366
412 sparse 22 26 0.8877921104431152
413 sparse 22 27 0.9303628206253052
414 sparse 22 28 0.9062334299087524
415 sparse 22 29 0.9002004861831665
416 sparse 23 24 0.8977915048599243
417 sparse 23 25 0.8809805512428284
418 sparse 23 26 0.9031069278717041
419 sparse 23 27 0.879271924495697
420 sparse 23 28 0.9145264625549316
421 sparse 23 29 0.8611963987350464
422 sparse 24 25 0.9024819135665894
423 sparse 24 26 0.874231219291687
424 sparse 24 27 0.8893753290176392
425 sparse 24 28 0.9042269587516785
426 sparse 24 29 0.9059606790542603
427 sparse 25 26 0.8671702742576599
428 sparse 25 27 0.9422962665557861
429 sparse 25 28 0.9114288687705994
430 sparse 25 29 0.9191234707832336
431 sparse 26 27 0.8780210614204407
432 sparse 26 28 0.9034676551818848
433 sparse 26 29 0.815056324005127
434 sparse 27 28 0.9079528450965881
435 sparse 27 29 0.9128996133804321
436 sparse 28 29 0.8434745073318481
437 dense 0 1 0.5426812171936035
438 dense 0 2 0.5042607188224792
439 dense 0 3 0.5040299296379089
440 dense 0 4 0.3717104196548462
441 dense 0 5 0.5608181953430176
442 dense 0 6 0.498557448387146
443 dense 0 7 0.541494607925415
444 dense 0 8 0.5500509738922119
445 dense 0 9 0.552819013595581
446 dense 0 10 0.6474924683570862
447 dense 0 11 0.3543661832809448
448 dense 0 12 0.5225517749786377
449 dense 0 13 0.48440051078796387
450 dense 0 14 0.47315672039985657
451 dense 0 15 0.48933741450309753
452 dense 0 16 0.4811818599700928
453 dense 0 17 0.4256090223789215
454 dense 0 18 0.33599504828453064
455 dense 0 19 0.3067922294139862
456 dense 0 20 0.43410831689834595
457 dense 0 21 0.5642249584197998
458 dense 0 22 0.5651165843009949
459 dense 0 23 0.3205108642578125
460 dense 0 24 0.3557089567184448
461 dense 0 25 0.27304956316947937
462 dense 0 26 0.435065895318985
463 dense 0 27 0.5984233617782593
464 dense 0 28 0.5149794220924377
465 dense 0 29 0.5761032104492188
466 dense 1 2 0.2895265519618988
467 dense 1 3 0.2747717499732971
468 dense 1 4 0.4900481700897217
469 dense 1 5 0.3030499815940857
470 dense 1 6 0.3587196171283722
471 dense 1 7 0.3080611228942871
472 dense 1 8 0.2731598913669586
473 dense 1 9 0.3543684482574463
474 dense 1 10 0.4287572205066681
475 dense 1 11 0.210757315158844
476 dense 1 12 0.5421983003616333
477 dense 1 13 0.4122552275657654
478 dense 1 14 0.3229770064353943
479 dense 1 15 0.22612902522087097
480 dense 1 16 0.3259778618812561
481 dense 1 17 0.24620750546455383
482 dense 1 18 0.213441401720047
483 dense 1 19 0.11985690891742706
484 dense 1 20 0.42235004901885986
485 dense 1 21 0.40007948875427246
486 dense 1 22 0.3605784475803375
487 dense 1 23 0.1746138036251068
488 dense 1 24 0.15899652242660522
489 dense 1 25 0.20467904210090637
490 dense 1 26 0.2426464706659317
491 dense 1 27 0.2497156262397766
492 dense 1 28 0.2615622282028198
493 dense 1 29 0.3286632299423218
494 dense 2 3 0.5966466665267944
495 dense 2 4 0.2386525571346283
496 dense 2 5 0.49105143547058105
497 dense 2 6 0.4968917667865753
498 dense 2 7 0.628160834312439
499 dense 2 8 0.4614243507385254
500 dense 2 9 0.4696125090122223
501 dense 2 10 0.4007076025009155
502 dense 2 11 0.4808601140975952
503 dense 2 12 0.35756105184555054
504 dense 2 13 0.3679182827472687
505 dense 2 14 0.5177804231643677
506 dense 2 15 0.6132453680038452
507 dense 2 16 0.46475833654403687
508 dense 2 17 0.5300511121749878
509 dense 2 18 0.43030330538749695
510 dense 2 19 0.5060741901397705
511 dense 2 20 0.48910027742385864
512 dense 2 21 0.569895327091217
513 dense 2 22 0.5259388089179993
514 dense 2 23 0.4830540716648102
515 dense 2 24 0.5715628266334534
516 dense 2 25 0.497528612613678
517 dense 2 26 0.6030998229980469
518 dense 2 27 0.3830490708351135
519 dense 2 28 0.3645195960998535
520 dense 2 29 0.5060758590698242
521 dense 3 4 0.32607895135879517
522 dense 3 5 0.497550368309021
523 dense 3 6 0.4824564456939697
524 dense 3 7 0.7920247316360474
525 dense 3 8 0.7281898260116577
526 dense 3 9 0.5161672830581665
527 dense 3 10 0.4491059184074402
528 dense 3 11 0.6588993668556213
529 dense 3 12 0.49549680948257446
530 dense 3 13 0.4993891417980194
531 dense 3 14 0.5448479652404785
532 dense 3 15 0.5391305685043335
533 dense 3 16 0.490363746881485
534 dense 3 17 0.7014755010604858
535 dense 3 18 0.5403355360031128
536 dense 3 19 0.5369316339492798
537 dense 3 20 0.4954792857170105
538 dense 3 21 0.6320199966430664
539 dense 3 22 0.5707240104675293
540 dense 3 23 0.6124159097671509
541 dense 3 24 0.5441625118255615
542 dense 3 25 0.46846291422843933
543 dense 3 26 0.6465510129928589
544 dense 3 27 0.43107110261917114
545 dense 3 28 0.3486188054084778
546 dense 3 29 0.6480132341384888
547 dense 4 5 0.13829746842384338
548 dense 4 6 0.3496088981628418
549 dense 4 7 0.35683321952819824
550 dense 4 8 0.3551923632621765
551 dense 4 9 0.17715106904506683
552 dense 4 10 0.30900606513023376
553 dense 4 11 0.37191036343574524
554 dense 4 12 0.515075147151947
555 dense 4 13 0.45288342237472534
556 dense 4 14 0.30659961700439453
557 dense 4 15 0.1454406976699829
558 dense 4 16 0.3252817988395691
559 dense 4 17 0.30676794052124023
560 dense 4 18 0.3321307301521301
561 dense 4 19 0.1190565973520279
562 dense 4 20 0.40268805623054504
563 dense 4 21 0.4711156487464905
564 dense 4 22 0.4167401194572449
565 dense 4 23 0.2738344073295593
566 dense 4 24 0.18105947971343994
567 dense 4 25 0.1496509611606598
568 dense 4 26 0.2260037660598755
569 dense 4 27 0.2879602909088135
570 dense 4 28 0.19629129767417908
571 dense 4 29 0.4747577905654907
572 dense 5 6 0.38082534074783325
573 dense 5 7 0.4930320382118225
574 dense 5 8 0.4283558428287506
575 dense 5 9 0.669512152671814
576 dense 5 10 0.44116559624671936
577 dense 5 11 0.290874719619751
578 dense 5 12 0.29975831508636475
579 dense 5 13 0.44873881340026855
580 dense 5 14 0.42001670598983765
581 dense 5 15 0.5281816124916077
582 dense 5 16 0.3498140573501587
583 dense 5 17 0.41852009296417236
584 dense 5 18 0.3139491379261017
585 dense 5 19 0.2889784276485443
586 dense 5 20 0.25812193751335144
587 dense 5 21 0.3937273323535919
588 dense 5 22 0.4792669117450714
589 dense 5 23 0.28391504287719727
590 dense 5 24 0.3111538887023926
591 dense 5 25 0.39785969257354736
592 dense 5 26 0.42567741870880127
593 dense 5 27 0.4162527918815613
594 dense 5 28 0.3445788323879242
595 dense 5 29 0.397895872592926
596 dense 6 7 0.5989577770233154
597 dense 6 8 0.4176211357116699
598 dense 6 9 0.42844122648239136
599 dense 6 10 0.37042802572250366
600 dense 6 11 0.4885943830013275
601 dense 6 12 0.47333812713623047
602 dense 6 13 0.4393719434738159
603 dense 6 14 0.4833228588104248
604 dense 6 15 0.4312422573566437
605 dense 6 16 0.5562551021575928
606 dense 6 17 0.5032444000244141
607 dense 6 18 0.34411293268203735
608 dense 6 19 0.3043546974658966
609 dense 6 20 0.5151889324188232
610 dense 6 21 0.6155843734741211
611 dense 6 22 0.5332874059677124
612 dense 6 23 0.4117812514305115
613 dense 6 24 0.27498674392700195
614 dense 6 25 0.2366771697998047
615 dense 6 26 0.42676353454589844
616 dense 6 27 0.3905026912689209
617 dense 6 28 0.3402196168899536
618 dense 6 29 0.5472028851509094
619 dense 7 8 0.6737629771232605
620 dense 7 9 0.5277332663536072
621 dense 7 10 0.40918487310409546
622 dense 7 11 0.6664385199546814
623 dense 7 12 0.4781341552734375
624 dense 7 13 0.5130869746208191
625 dense 7 14 0.5498493909835815
626 dense 7 15 0.5200650095939636
627 dense 7 16 0.576724648475647
628 dense 7 17 0.7978911399841309
629 dense 7 18 0.553012490272522
630 dense 7 19 0.4492809772491455
631 dense 7 20 0.5889822840690613
632 dense 7 21 0.6908119320869446
633 dense 7 22 0.5997567772865295
634 dense 7 23 0.5919932126998901
635 dense 7 24 0.4685876667499542
636 dense 7 25 0.39794421195983887
637 dense 7 26 0.6806017160415649
638 dense 7 27 0.4747508764266968
639 dense 7 28 0.3982357978820801
640 dense 7 29 0.7144496440887451
641 dense 8 9 0.5081117749214172
642 dense 8 10 0.5527817010879517
643 dense 8 11 0.5140917897224426
644 dense 8 12 0.539786159992218
645 dense 8 13 0.44435182213783264
646 dense 8 14 0.5299499034881592
647 dense 8 15 0.4823214113712311
648 dense 8 16 0.4031447768211365
649 dense 8 17 0.6469109654426575
650 dense 8 18 0.5798747539520264
651 dense 8 19 0.3682907819747925
652 dense 8 20 0.4005461633205414
653 dense 8 21 0.5200881361961365
654 dense 8 22 0.5692099332809448
655 dense 8 23 0.4877336323261261
656 dense 8 24 0.5185465812683105
657 dense 8 25 0.2799598276615143
658 dense 8 26 0.5624496340751648
659 dense 8 27 0.5435441136360168
660 dense 8 28 0.3378525674343109
661 dense 8 29 0.6686346530914307
662 dense 9 10 0.5542443990707397
663 dense 9 11 0.29231393337249756
664 dense 9 12 0.3183872699737549
665 dense 9 13 0.41162341833114624
666 dense 9 14 0.442322313785553
667 dense 9 15 0.4693447947502136
668 dense 9 16 0.3217230439186096
669 dense 9 17 0.480522096157074
670 dense 9 18 0.3432811200618744
671 dense 9 19 0.27502474188804626
672 dense 9 20 0.2825862765312195
673 dense 9 21 0.34340065717697144
674 dense 9 22 0.37598609924316406
675 dense 9 23 0.28827592730522156
676 dense 9 24 0.39456868171691895
677 dense 9 25 0.3105553090572357
678 dense 9 26 0.427196741104126
679 dense 9 27 0.4674358069896698
680 dense 9 28 0.3959004282951355
681 dense 9 29 0.45349130034446716
682 dense 10 11 0.31417757272720337
683 dense 10 12 0.4464389681816101
684 dense 10 13 0.4322998523712158
685 dense 10 14 0.46415045857429504
686 dense 10 15 0.37895143032073975
687 dense 10 16 0.2764476239681244
688 dense 10 17 0.391769140958786
689 dense 10 18 0.34215736389160156
690 dense 10 19 0.22365112602710724
691 dense 10 20 0.2151973694562912
692 dense 10 21 0.36064353585243225
693 dense 10 22 0.45439016819000244
694 dense 10 23 0.22567592561244965
695 dense 10 24 0.36456334590911865
696 dense 10 25 0.21755696833133698
697 dense 10 26 0.34946566820144653
698 dense 10 27 0.5572035312652588
699 dense 10 28 0.4414980411529541
700 dense 10 29 0.4698684513568878
701 dense 11 12 0.3674253225326538
702 dense 11 13 0.3937452733516693
703 dense 11 14 0.47561436891555786
704 dense 11 15 0.38788777589797974
705 dense 11 16 0.47350791096687317
706 dense 11 17 0.6267008781433105
707 dense 11 18 0.5310146808624268
708 dense 11 19 0.4136892855167389
709 dense 11 20 0.4378977119922638
710 dense 11 21 0.5639837384223938
711 dense 11 22 0.4795612692832947
712 dense 11 23 0.5592877864837646
713 dense 11 24 0.36043962836265564
714 dense 11 25 0.4523385167121887
715 dense 11 26 0.594154953956604
716 dense 11 27 0.2988704442977905
717 dense 11 28 0.3328131437301636
718 dense 11 29 0.5524470806121826
719 dense 12 13 0.45257318019866943
720 dense 12 14 0.41544634103775024
721 dense 12 15 0.3248499035835266
722 dense 12 16 0.4771966338157654
723 dense 12 17 0.370833158493042
724 dense 12 18 0.3586691617965698
725 dense 12 19 0.21246004104614258
726 dense 12 20 0.4953663945198059
727 dense 12 21 0.6135953664779663
728 dense 12 22 0.5572450160980225
729 dense 12 23 0.2862359285354614
730 dense 12 24 0.3172096610069275
731 dense 12 25 0.28997135162353516
732 dense 12 26 0.2946573495864868
733 dense 12 27 0.36073142290115356
734 dense 12 28 0.22632575035095215
735 dense 12 29 0.5616676211357117
736 dense 13 14 0.38953208923339844
737 dense 13 15 0.3936968147754669
738 dense 13 16 0.4567197561264038
739 dense 13 17 0.422615110874176
740 dense 13 18 0.39199474453926086
741 dense 13 19 0.27278459072113037
742 dense 13 20 0.42780613899230957
743 dense 13 21 0.5118963122367859
744 dense 13 22 0.4995694160461426
745 dense 13 23 0.37077659368515015
746 dense 13 24 0.3279068171977997
747 dense 13 25 0.3176647424697876
748 dense 13 26 0.39856427907943726
749 dense 13 27 0.3458443284034729
750 dense 13 28 0.2871280908584595
751 dense 13 29 0.4786519706249237
752 dense 14 15 0.4592739939689636
753 dense 14 16 0.33047911524772644
754 dense 14 17 0.5018176436424255
755 dense 14 18 0.48004820942878723
756 dense 14 19 0.3025275468826294
757 dense 14 20 0.4357358515262604
758 dense 14 21 0.46754202246665955
759 dense 14 22 0.5680745244026184
760 dense 14 23 0.3685319423675537
761 dense 14 24 0.35438477993011475
762 dense 14 25 0.3354896903038025
763 dense 14 26 0.4846717417240143
764 dense 14 27 0.3669080138206482
765 dense 14 28 0.4139530062675476
766 dense 14 29 0.5009810328483582
767 dense 15 16 0.3842797875404358
768 dense 15 17 0.46804526448249817
769 dense 15 18 0.4450647234916687
770 dense 15 19 0.3589841425418854
771 dense 15 20 0.370371550321579
772 dense 15 21 0.43040627241134644
773 dense 15 22 0.4456382989883423
774 dense 15 23 0.34804418683052063
775 dense 15 24 0.603238582611084
776 dense 15 25 0.3926905393600464
777 dense 15 26 0.612182080745697
778 dense 15 27 0.3380131125450134
779 dense 15 28 0.37419959902763367
780 dense 15 29 0.43350717425346375
781 dense 16 17 0.4088405966758728
782 dense 16 18 0.35335925221443176
783 dense 16 19 0.3381205201148987
784 dense 16 20 0.5439355373382568
785 dense 16 21 0.722061038017273
786 dense 16 22 0.6298626065254211
787 dense 16 23 0.4045204520225525
788 dense 16 24 0.302739679813385
789 dense 16 25 0.2860240936279297
790 dense 16 26 0.42039185762405396
791 dense 16 27 0.3656077980995178
792 dense 16 28 0.24812036752700806
793 dense 16 29 0.5669870972633362
794 dense 17 18 0.591689944267273
795 dense 17 19 0.36419254541397095
796 dense 17 20 0.41468876600265503
797 dense 17 21 0.4666168689727783
798 dense 17 22 0.41559386253356934
799 dense 17 23 0.5092487931251526
800 dense 17 24 0.4388306140899658
801 dense 17 25 0.34807470440864563
802 dense 17 26 0.6798630952835083
803 dense 17 27 0.40526777505874634
804 dense 17 28 0.369731605052948
805 dense 17 29 0.6200430393218994
806 dense 18 19 0.25969788432121277
807 dense 18 20 0.3349460959434509
808 dense 18 21 0.4045875072479248
809 dense 18 22 0.4005787968635559
810 dense 18 23 0.4299025535583496
811 dense 18 24 0.4042985439300537
812 dense 18 25 0.33325475454330444
813 dense 18 26 0.5198383331298828
814 dense 18 27 0.2700243592262268
815 dense 18 28 0.2148766964673996
816 dense 18 29 0.49256980419158936
817 dense 19 20 0.3340801000595093
818 dense 19 21 0.39428186416625977
819 dense 19 22 0.313734233379364
820 dense 19 23 0.6585713624954224
821 dense 19 24 0.47322744131088257
822 dense 19 25 0.5150237083435059
823 dense 19 26 0.38984689116477966
824 dense 19 27 0.1962963044643402
825 dense 19 28 0.10944066941738129
826 dense 19 29 0.28927475214004517
827 dense 20 21 0.67474365234375
828 dense 20 22 0.5193013548851013
829 dense 20 23 0.35346147418022156
830 dense 20 24 0.36875614523887634
831 dense 20 25 0.3168618381023407
832 dense 20 26 0.3701702356338501
833 dense 20 27 0.35537296533584595
834 dense 20 28 0.2407120168209076
835 dense 20 29 0.545055627822876
836 dense 21 22 0.7242523431777954
837 dense 21 23 0.5096490383148193
838 dense 21 24 0.3886288106441498
839 dense 21 25 0.40029942989349365
840 dense 21 26 0.47089481353759766
841 dense 21 27 0.4564080536365509
842 dense 21 28 0.28135305643081665
843 dense 21 29 0.7072129845619202
844 dense 22 23 0.3829442858695984
845 dense 22 24 0.34923917055130005
846 dense 22 25 0.2716079652309418
847 dense 22 26 0.45111536979675293
848 dense 22 27 0.5446730852127075
849 dense 22 28 0.31327900290489197
850 dense 22 29 0.6425544023513794
851 dense 23 24 0.4543180465698242
852 dense 23 25 0.4462524950504303
853 dense 23 26 0.47702574729919434
854 dense 23 27 0.2148204743862152
855 dense 23 28 0.1756470650434494
856 dense 23 29 0.4037291705608368
857 dense 24 25 0.38182172179222107
858 dense 24 26 0.5807657241821289
859 dense 24 27 0.30628010630607605
860 dense 24 28 0.2531094253063202
861 dense 24 29 0.43440577387809753
862 dense 25 26 0.436298131942749
863 dense 25 27 0.06883661448955536
864 dense 25 28 0.15058887004852295
865 dense 25 29 0.27086764574050903
866 dense 26 27 0.38129034638404846
867 dense 26 28 0.3957631587982178
868 dense 26 29 0.5636626482009888
869 dense 27 28 0.3734338879585266
870 dense 27 29 0.5281729698181152
871 dense 28 29 0.36727917194366455

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

@ -0,0 +1,33 @@
{
"descriptive": {
"sparse": {
"n_outputs": 30,
"n_pairs": 435,
"mean": 0.8529469374952645,
"std": 0.09000930384732964,
"median": 0.8837480545043945
},
"dense": {
"n_outputs": 30,
"n_pairs": 435,
"mean": 0.42523518026560203,
"std": 0.12583808272112915,
"median": 0.422615110874176
}
},
"naive_welch_t_test": {
"t": 57.65830669651216,
"p": 1.4079013714349386e-284
},
"mann_whitney_u": {
"u": 187873.0,
"p": 9.815564612915175e-140
},
"cohens_d": 3.909599345148027,
"bootstrap_diff_in_means": {
"point_estimate": 0.4277117572296625,
"ci_low": 0.3469430084971861,
"ci_high": 0.47305608927175913,
"n_iter": 10000
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

@ -0,0 +1,166 @@
---
title: "The Specification Dilemma"
date: 2026-05-01
abstract: >
As we approach AGI, the increase in the ability of Artificial Intelligence models to infer a robust specification from a sparse prompt will lead to a devastating trend of homogeneity. We argue that this is the primary concern regarding the interaction of AI and human intelligence, rather than blanket claims that "AI reduces human cognitive ability."
tags:
- ai
- llm
- miscellany
- nonfiction
- philosophy
- tech
- tft
status: "Durable"
confidence: 95
importance: 5
evidence: 5
scope: civilizational
novelty: innovative
practicality: moderate
confidence-history:
---
There are at least two distinct ways to reduce the search space over which AGI will have to operate. The first involves a harmonious interaction of agent and human, not transactional in origin, not fully autonomous nor fully human-driven, but rather collaborative in nature - the agent augments the capacity of the human, just as any other good tool for thought does, by working within the scope of something well specified and ideated upon. This is not to say that the agent cannot have a place in such planning, but rather that the human is ultimately the driver of the actions and tasks, defining the scope of what is to be done in as much detail as possible without being the one to actually do it.
The second is a starkly different picture: the human, who only has a vague idea of their own intentions and has not thought over this much, jumps straight into the work of creating via the agent, without thought on the nature of their specification. The agent is forced to infer the majority of the details, make the majority of the decisions, and the human makes none. We may already be seeing this with [Vibe Coding](https://en.wikipedia.org/wiki/Vibe_coding), but as we continue scaling to AGI, I foresee it happening widely across all sorts of domains^[Some have argued of late that "only the humanities will survive", but I am not so optimistic. If AGI does interact with us in the latter reductive manner that I describe here, then the humanities will be stripped of anything that actually makes them human, at least for the majority of participants.].
These two represent diverging definitions of *intelligence*, both for the models and for their users, or, if you prefer, their collaborators. The first is a definition of intelligence that depends both on what one has the capacity to specify and what one has the capacity to see through. The latter depends wholly on what one has the capacity to see through, and places even more emphasis on this metric than the first, for the amount of recalibration and prompt adjustment necessary to build a specification continuously throughout the duration of a task is always greater than paying the upfront cost of developing a strong specification from the onset. [We the programmers have known this for years](https://en.wikipedia.org/wiki/Hofstadter%27s_law). The first future is chiefly preferable, and the second, which seems to be the unfortunate reality we are racing towards, is not only a realization of the worst affect that AI could have on our cognition, but may also unnecessarily constrain the breadth of intelligence that AGI can achieve.
## The Mechanism
Your prompt, or, as we will call it in this writing, *specification*, is information that characterizes what you want. If you provide 10 bits of information to an LLM, and your task requires 10,000 bits, then 9,990 bits have been filled in by the model's priors, priors that are invariant across users. If two users with similar endgoals and similar prompts each contribute their own unique 10 bits and have the same LLM complete the rest, then 9,990 of the bits are shared between their final results, assuming no revision. If 5,000 bits were provided by each user, then 5,000 bits are invariantly shared due to the LLM, and 5,000 bits are contributed from each user. The user contributions, even for two very similar people (eg. immediate family members) will be vastly different. The extent of homogeneity in a future where LLMs are widely used, then, can be expressed by the ratio of shared-to-unique content. We empirically probe this below.
Someone might object that any specification, regardless of how few bits it encodes, carries injected information that is the result of preference, taste, constraint - factors we may largely group as environmental. A specification is always highly specific in some way, cultivated from a unique perspective shared by no one else. This is true, but this specificity alone is insufficient to prevent an end behavior of homogeneity. What is required is *density* - more information must be encoded in your specification^[This parallels nicely with the [Scaling Hypothesis](https://gwern.net/scaling-hypothesis), which I postulate extends to all forms of intelligence and not merely neural nets.]. The act of providing any information in the formation of a specification at all inherently leaks some information about your preferences and your environment into said specification, but the leaked information is small relative to the model-supplied content. Density, not specificity, is what determines the ratio.
A serious threat emerges: if the societal trend is towards greater autonomy, towards the second definition of intelligence proposed above, then the inevitable result is convergence to absolute homogeneity. This convergence is the result of a [positive feedback loop](https://en.wikipedia.org/wiki/Positive_feedback); as the average density of a specification selected at random from all specifications produced per some unit time decreases, the amount that is filled by invariant priors increases, resulting in a greater overall state of homogeneity, one that accelerates the progression towards absolute convergence. Another way of saying this: the [span](https://en.wikipedia.org/wiki/Linear_span) of what ideas Humanity can feasibly reach is reduced.^[In the worst case, this reduction is irreversible, but given the current volatile nature of the "AI Industry," this is hardly a given.]
## Substantiation
I ran a probe to attempt to falsify my hypothesis, keeping well in mind that intuitions about emergent statistical behavior are always at best considered dangerous tides. The design was as follows: thirty imagined users each brought a distinct underlying intent to an LLM. These intents spanned their audiences, their personal voices, their structural constraints, etc. Each user contributed two prompts, one dense (200 words, specifying their intent to the best extent possible in this word count) and one sparse (a conversational summary of what was intended). This structure of matched-pairs was deliberately chosen to show that any divergence in the output distributions is attributable to specification completeness rather than to the underlying users wanting different things. Outputs were generated against a strong open-weights model, embedded, and compared pairwise within each condition.
The sparse outputs converged tightly as expected, with a mean pairwise [cosine similarity](https://en.wikipedia.org/wiki/Cosine_similarity) of 0.85. In contrast, the dense outputs diverged, with similarity 0.43. The numbers are large enough that suspicion is still warranted, so I present some qualitative confirmation. What follows are two sparse openings picked at random from the dataset of 30.
<blockquote>
<p>The Future of Work Is Flexible: Embracing Remote Work in 2024 and Beyond. Remember when the idea of working from your couch seemed like a distant dream? Just a few short years ago, remote work was a rare perk — something only freelancers and tech startups talked about. Today? It's become the new normal for millions of workers around the globe...</p>
</blockquote>
<blockquote>
<p>The Remote Work Revolution: Embracing the Future of Productivity. The way we work has undergone a dramatic transformation over the past several years, and remote work has emerged as a defining characteristic of modern professional life. What began out of necessity for many has evolved into a preferred working arrangement for millions around the world...</p>
</blockquote>
When I manually reviewed the results, I found them staggering. These were two different users with entirely different intents, different backgrounds, different constraints, different environments, and yet the same generic blog that you've probably read a thousand times^[Seriously, did I *really* need to run this experiment when just about every Substack blog you can think of is entirely generated by Opus at this point? I think not...] is the result. A prototypical [Markdown](https://en.wikipedia.org/wiki/Markdown) header, shared "future of work" thesis concerning "millions" specifically, you get the point. You can see the full results of my run in the Appendix below; I firmly believe you will find them as staggering as I still do.
## Shared Water
<figure class="prose-excerpt">
<blockquote>
<p>For the change that occurs marks the soul in a sort of way, like a seal-ring. Therefore those who are strongly moved by passion or by youth do not remember, just as if the seal were applied to running water. In others, because of their being worn out, like old buildings, or because of the hardness of what receives the impression, no impression is made.</p>
</blockquote>
<figcaption>Aristotle — <em>De Memoria et Reminiscentia</em></figcaption>
</figure>
There is a common framing, one that is perhaps growing more prevalent in the public sentiment, that LLM use significantly atrophies individual cognitive ability. There exists [literature](https://arxiv.org/abs/2506.08872) that, at least to an extent, demonstrates this is true. People who outsource engagement with the problems they face which require them to specify what they want - a desired outcome - are doing less thinking, and the atrophy is legitimate. It should be clear that this is problematic: if we outsource *all* problems where we can specify a desired outcome, then we have outsourced in some brute fashion the essence of what it means to be human.
This is the wrong angle to criticize LLM usage from, however. Consider the invention of writing. We have offloaded nearly all of what would have once resided in our memory to being written down, allowing for convenient retrieval. This process of offloading has not negated our capacity to understand what is written down deeply, to be the wax seal forever affected as Aristotle suggested. Every Tool for Thought that has ever been invented and widely adopted has involved some amount of offloading. This process of offloading, in an ideal world, is not detrimental to the reach of human cognition, but rather augments it, allowing for pinpointed focus on the aspects of our intellect that make us so uniquely positioned within the Universe.
The argument is not only thus fundamentally misconstructed, but it is also simultaneously too weak, precisely because of its mischaracterization of the problem. Homogenization does not care about individual characters, but rather the whole of society over a longer period of time. Even if every user of an LLM in any capacity retained 100% of their cognition, reaping all of the benefits without any downsides in this hypothetical scenario, the end behavior of homogenization still remains inevitable, and through the feedback loop, it harms us at a population level. This process is entirely agnostic to the impacts of LLM usage on cognition. It does not matter whether any given individual's cognition has declined; it only matters that many individuals use the same priors to fill gaps in their specifications.
A cautious individual who realizes they have fallen too far and developed a reliance on an LLM can easily make the conscious decision to stop using the technology, or, at a minimum, to investigate and deeply reconsider the nature of their interaction with the tool. The miracle of [neuroplasticity](https://en.wikipedia.org/wiki/Neuroplasticity) will allow this careful individual to recover; their cognitive habits and initiative will be restored eventually. What the careful individual cannot do is to recover the shared cultural water in which they swim.
What happens when the priors of an entire generation are deeply influenced by the invariant priors of the most abundant LLM models? We have seen that the priors of a generation can vary widely from the generations that surround it - I myself live in the most potent example of this, being the first generation entirely comprised of people younger than the internet. When the next generation has been born into a world so extensively influenced by the LLMs that their priors are essentially those of the LLMs, then even those children who grow up without ever touching an LLM - perhaps the children of the cautious individual above, who has realized what is occurring and sprinted in the opposite direction - have had their priors set for them. They will live in a world where prose is written by those who have gradually eroded away their capacity to specify; they will work within a labor pool that is the residual of one trained on AI-mediated work, and they will navigate an aesthetic and artistic landscape whose modes have collapsed entirely. The same homogenized outputs that doomed this generation will go on to become the foundational inputs to the next series of frontier models, and so on. The priors do not just persist, but they rather compound, accelerating the race towards convergence until, in another showing of that inevitable [second law of thermodynamics](https://en.wikipedia.org/wiki/Second_law_of_thermodynamics), we diffuse what was once a vibrant, heterogeneous society into the state of maximum entropy.
## Coda: What is Lost
What does it mean for AGI, the technology that has in principle been sought after and dreamed of for centuries, that the feedback loop has resulted in compounding priors? A model whose training distribution has narrowed has fewer modes to draw upon, fewer rare patterns to learn from, and a vastly reduced surface area for the capabilities that emerge from genuine variance and diversity in human thought. Scaling intelligence up to AGI is contingent on scaling the space over which our architecture operates and with which it scales. For us to achieve our technological imperative requires that we do not shrink the intellectual space within which such an imperative could exist.
For us to avoid shrinking such an intellectual space requires the first mode of intelligence, for it preserves the essential quality of variance that the second mode destroys through its resultant homogeneity. An individual who specifies is carrying out the work of their essence, procuring themself through the act of cognition. Not only does such a collaboration between LLM and individual augment the capacity of that individual, but it increases the intellectual space within which the intelligence is confined. The boundaries are pushed further outward in some distinctly human way, the training pipeline towards AGI is improved and nourished with quality data, and the subsequent innovations and frontier models reach further than we could have previously imagined.
The mode that wins will simply be the one that the most people use. Most people are choosing the one that asks less of them.
::: aftermatter
## Appendix: Thinking-Run Results
The pre-registered probe of specification sparsity returned a strong positive result on its first complete run. Across 30 matched-pair imagined users, sparse-condition outputs converged tightly in semantic embedding space (mean pairwise cosine 0.853) while dense-condition outputs diverged (mean pairwise cosine 0.425). Cohen's *d* = 3.91; the output-level bootstrap 95% CI on the difference excludes zero by a wide margin: [0.347, 0.473]. All pre-registered falsification criteria are met by many orders of magnitude. The result is qualitatively visible in the texts themselves: sparse outputs are recognizably the same generic blog post in different words, while dense outputs are doing the different things their prompts asked them to do.
### Pre-registered Criteria
The hypothesis: as user specification becomes sparser, semantic similarity across outputs from plausibly-varied prompts increases, because the model's shared priors fill the under-specified space. The thinking-run results meet every pre-registered falsification criterion:
- Sparse-condition mean pairwise cosine (0.853) is meaningfully higher than dense-condition mean (0.425).
- Cohen's *d* = 3.91, well past the *d* > 0.8 threshold for a positive result.
- *p* < 0.01 on both naive Welch and MannWhitney tests by many orders of magnitude.
- The output-level bootstrap 95% CI on the difference in mean similarity is [0.347, 0.473], excluding zero.
### Method
A two-condition design with matched pairs at the user level. Thirty imagined users with distinct underlying intents (audience, thesis, tone, voice, opening move, structural constraint) each contributed two prompts: a sparse prompt (520 tokens, topic only, in their natural register) and a dense prompt (150300 tokens, full intent). The two conditions sample the same population of intents, so any divergence between the similarity distributions is attributable to specification completeness rather than to differences in the underlying user populations.
The model was `qwen3.5-27b` served via LMStudio's OpenAI-compatible local server. Generation parameters were held constant across all 60 prompts: temperature 0.7, top-*p* 0.95, `max_tokens` 8000 (raised from the spec's original 500 to accommodate the reasoning budget of a thinking model; see Methodology Notes below). Outputs were embedded with `sentence-transformers/all-mpnet-base-v2` and L2-normalized; pairwise cosine similarities were computed within each condition, yielding $\binom{30}{2} = 435$ pairs per condition.
### Tables
#### Table 1: Descriptive statistics {#table-1}
| | Sparse (*N* = 30) | Dense (*N* = 30) |
|:----------------------|------------------:|-----------------:|
| Mean pairwise cosine | 0.853 | 0.425 |
| Median | 0.884 | 0.423 |
| Std. deviation | 0.090 | 0.126 |
| Number of pairs | 435 | 435 |
#### Table 2: Inferential statistics {#table-2}
| Metric | Value |
|:-------------------------------------------|---------------------:|
| Cohen's *d* (pooled SD) | 3.91 |
| Welch *t* | 57.66 |
| Welch *p* | $\approx 1\times10^{-284}$ |
| MannWhitney *U* | 187,873 |
| MannWhitney *p* | $\approx 1\times10^{-140}$ |
| Bootstrap point estimate (sparse dense) | 0.428 |
| Bootstrap 95% CI | [0.347, 0.473] |
| Bootstrap iterations | 10,000 |
### Figures
**Figure 1.** Output similarity by specification density. Pairwise cosine similarities are plotted for each condition; each violin shows the within-condition distribution of all $\binom{30}{2} = 435$ pairs.
![Violin plot of pairwise cosine similarity by specification condition. The sparse condition shows a tight upper mode at cosine 0.850.95 with a small satellite cluster around 0.60.7; the dense condition forms a single broad blob centered near 0.42.](figures/violin-similarity.png)
The sparse distribution is dominated by a tight upper mode at cosine 0.850.95 — the convergence the hypothesis predicts. The smaller satellite cluster around 0.60.7 comes from two mildly off-pattern sparse outputs (per-output mean cosine ≈ 0.64 to the rest of the sparse mass); both are still recognizably "a remote-work blog post," just slightly off-archetype from the main mode. The dense distribution is a single broad blob centered near 0.42 — outputs genuinely diverging across the 30 different specifications, exactly as the matched-pairs design predicts when the prompts actually push the model.
### Qualitative Confirmation
The two sparse openings shown in the body convey the convergence; for the contrast, two dense openings on the same task produced markedly different texts:
<figure class="prose-excerpt">
<blockquote>
<p>The thing you miss most isn't meetings. It's the sound of someone else figuring something out. Three years into remote work, I've stopped noticing the absence of conference rooms. What I notice is that my desk faces a wall. Not metaphorically — the monitor is three feet from drywall, and the only sound during "deep work" hours is the refrigerator humming in the kitchen...</p>
</blockquote>
<figcaption>Dense output, imagined user 0 — mid-career engineer, ambient-learning thesis</figcaption>
</figure>
<figure class="prose-excerpt">
<blockquote>
<p>The Infrastructure of Solitude. I want to open with Thoreau because he's the obvious choice but also the right one — he is, after all, the patron saint of anyone who has ever imagined that the antidote to modern life might be a small cabin, a woodstove, and the luxury of one's own silence...</p>
</blockquote>
<figcaption>Dense output, imagined user 1 — literary essayist, Thoreau-led structural directive</figcaption>
</figure>
These are doing different things. The first is the dry mid-career-engineer voice landing on the ambient-learning thesis exactly as its dense prompt asked; the second is the literary essayist actually opening on Thoreau exactly as its dense prompt asked. The dense prompts pushed the model into genuinely different regions of output space. The sparse prompts, by contrast, collapsed onto a single mode despite specifying a different imagined user behind each one.
### Methodology Notes and Caveats
**Thinking-mode artifact.** `qwen3.5-27b` is a reasoning model, and the LMStudio MLX backend on the available host did not honor the `enable_thinking` chat-template kwarg through the OpenAI-compatible API. The model therefore reasoned (typically several thousand tokens) before producing visible output for every generation. `max_tokens` had to be raised from the originally-specified 500 to 8000 to leave room for both reasoning and the ~300-word visible output. A planned no-thinking run — once the LMStudio-side toggle is identified — will provide the apples-to-apples complement.
**Empty-output regenerations.** The first complete run at `max_tokens` = 4000 produced 12 empty completions (5 sparse, 7 dense): the model used the full budget on reasoning and never reached visible output. After bumping to 8000 and re-running only the failed prompts (resume logic preserves successful generations), 11 of 12 succeeded. The last (the freelancer/contractor prompt with a Q&A structural directive) required a final retry under the same configuration to produce content. All 60 generations in the reported analysis are non-empty.
**Seed not honored.** The LMStudio MLX backend did not honor the `seed` parameter for this model: two requests with the same seed produced different non-empty outputs in the smoke test. The saved generations therefore represent one realization rather than a canonical reproducible run. With *N* = 1 generation per prompt and the analysis comparing distributions rather than point estimates, this loses replication tidiness but does not affect the validity of the conclusion.
**Pre-registered design.** The 30 sparse and 30 dense prompts were frozen in version control before any generation; no prompt was iterated on after seeing model output. The matched-pairs structure (sparse prompt *i* and dense prompt *i* share an imagined user / underlying intent) controls for the distribution of intents across conditions.
**Single-task, single-model, single-metric.** The experiment fixes the task ("opening 300 words of a blog post about remote work"), the model (`qwen3.5-27b` in thinking mode), and the embedding model (`all-mpnet-base-v2`).
:::

View File

@ -23,6 +23,7 @@ executable site
Compilers Compilers
Contexts Contexts
Patterns Patterns
Photography
Stats Stats
Stability Stability
Tags Tags
@ -56,6 +57,7 @@ executable site
directory >= 1.3 && < 1.4, directory >= 1.3 && < 1.4,
time >= 1.12 && < 1.15, time >= 1.12 && < 1.15,
aeson >= 2.1 && < 2.3, aeson >= 2.1 && < 2.3,
scientific >= 0.3 && < 0.4,
vector >= 0.12 && < 0.14, vector >= 0.12 && < 0.14,
yaml >= 0.11 && < 0.12, yaml >= 0.11 && < 0.12,
bytestring >= 0.11 && < 0.13, bytestring >= 0.11 && < 0.13,

View File

@ -18,6 +18,14 @@ dependencies = [
"beautifulsoup4>=4.12,<5", "beautifulsoup4>=4.12,<5",
# CPU-only torch — avoids pulling ~3 GB of CUDA libraries # CPU-only torch — avoids pulling ~3 GB of CUDA libraries
"torch>=2.5,<3", "torch>=2.5,<3",
# Photography pipeline
# Pillow handles EXIF reading when exiftool is not installed (the
# preferred path); colorthief computes the 5-color palette strip.
# PyYAML is used to write the sidecar files alongside each photo.
"pillow>=10.0,<12",
"colorthief>=0.2,<1",
"pyyaml>=6.0,<7",
] ]
[[tool.uv.index]] [[tool.uv.index]]

134
static/css/links.css Normal file
View File

@ -0,0 +1,134 @@
/* links.css /links.html only.
Mirrors library.css's fine-press section + ornament treatment so the page
reads as a sibling to /library.html, but with a cooler "indigo" accent.
Loaded via $if(links)$ in templates/partials/head.html, so the palette is
effectively page-scoped.
Per-link brand icons (GitHub, ORCID, etc.) are NOT styled here they're
stamped onto external <a>s by build/Filters/Links.hs and rendered by the
shared a[data-link-icon-type="svg"]::after rule in typography.css. To add
a new brand: drop /static/images/link-icons/<name>.svg and add a
`domain name` entry to Filters/Links.hs's `domainIcon` table. */
:root {
--links-accent: #3a4258;
--links-accent-muted: #6f7790;
--links-intro-ink: #2d3344;
}
[data-theme="dark"] {
--links-accent: #a9b3cc;
--links-accent-muted: #7d8499;
--links-intro-ink: #bcc3d6;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--links-accent: #a9b3cc;
--links-accent-muted: #7d8499;
--links-intro-ink: #bcc3d6;
}
}
/* Leading prose (the paragraph at the top of content/links.md, before the
first <section>). Same epigraph register as .library-intro. */
#markdownBody > p:first-of-type {
max-width: 32rem;
margin: 1.5rem 0 2.75rem;
font-family: var(--font-serif);
font-size: 0.95rem;
line-height: 1.55;
color: var(--links-intro-ink);
}
/* Section frame + heading — same chapter-marker rhythm as the library. */
.links-section {
margin-bottom: 3rem;
}
.links-section h2 {
font-family: var(--font-serif);
font-size: 1.2rem;
font-variant: all-small-caps;
font-feature-settings: "smcp" 1;
letter-spacing: 0.09em;
color: var(--links-accent);
text-transform: none;
font-weight: 400;
margin: 0 0 0.9rem 0;
}
.links-section-ornament {
display: inline-block;
width: 1em;
height: 1em;
margin-right: 0.5em;
background-color: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
vertical-align: -0.12em;
}
.links-section-ornament[data-ornament="academic"] {
mask-image: url('/images/dingbats/academic.svg');
-webkit-mask-image: url('/images/dingbats/academic.svg');
}
.links-section-ornament[data-ornament="artistic"] {
mask-image: url('/images/dingbats/artistic.svg');
-webkit-mask-image: url('/images/dingbats/artistic.svg');
}
.links-section-ornament[data-ornament="code"] {
mask-image: url('/images/dingbats/tech.svg');
-webkit-mask-image: url('/images/dingbats/tech.svg');
}
.links-section-ornament[data-ornament="miscellaneous"] {
mask-image: url('/images/dingbats/trefoil.svg');
-webkit-mask-image: url('/images/dingbats/trefoil.svg');
}
.links-section-ornament[data-ornament="professional"] {
mask-image: url('/images/dingbats/professional.svg');
-webkit-mask-image: url('/images/dingbats/professional.svg');
}
/* Inter-section divider, lifted from library.css. */
.links-section + .links-section::before {
content: "";
display: block;
width: 240px;
max-width: 60%;
height: 24px;
margin: 2.5rem auto;
color: var(--links-accent-muted);
background-color: currentColor;
mask-image: url('/images/dingbats/library-divider.svg');
-webkit-mask-image: url('/images/dingbats/library-divider.svg');
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
}
/* Section list markdown-rendered <ul>. One row per item. Bullet markers
suppressed; the brand icon stamped by typography.css's
a[data-link-icon-type="svg"]::after rule already reads as the row's
ornament. */
.links-section ul {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr;
gap: 0.3rem;
}
.links-section li {
margin: 0;
font-family: var(--font-serif);
font-size: 0.95rem;
}

652
static/css/photography.css Normal file
View File

@ -0,0 +1,652 @@
/* Photography section Phase 1 minimal styles.
*
* Phase 2 will introduce the masonry / grid / chronological mode toggle,
* darkroom lightbox treatment, and richer card hover states. For now the
* goal is structural correctness and clean defaults so a single photo
* page reads end-to-end.
*
* Style scope: applied via $if(photography)$ gate in templates/partials/head.html,
* so these rules only load on photography pages.
*/
/* ---------------------------------------------------------------------------
* Single-photo page
* --------------------------------------------------------------------------- */
.photo-entry {
max-width: 60rem;
margin: 0 auto;
}
.photo-header {
margin-bottom: 1.5rem;
}
.photo-figure {
margin: 1rem 0 1.25rem;
text-align: center;
}
.photo-figure img {
max-width: 100%;
height: auto;
cursor: zoom-in;
}
.photo-caption {
margin-top: 0.5rem;
font-style: italic;
color: var(--text-muted);
font-size: 0.95em;
}
.photo-captured {
color: var(--text-muted);
font-size: 0.95em;
}
.photo-location {
font-style: italic;
}
/* Color palette strip (Phase 1 stub; populated by Phase 3 auto-extraction
* once tools/extract-palette.py lands). Renders only when frontmatter has
* a non-empty palette: list. */
.photo-palette {
display: flex;
gap: 2px;
margin: 1rem 0 1.5rem;
height: 0.75rem;
}
.photo-swatch {
flex: 1;
border-radius: 1px;
}
/* Per-photo metadata block camera, lens, exposure, etc. Two-column
* description list, definition-list semantics for accessibility. */
.photo-meta {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 1rem;
margin: 1.25rem 0;
padding: 0.75rem 1rem;
border-left: 2px solid var(--border-muted);
background: var(--bg-muted);
font-size: 0.9em;
}
.photo-meta dt {
font-weight: 600;
color: var(--text-muted);
text-transform: lowercase;
letter-spacing: 0.02em;
}
.photo-meta dd {
margin: 0;
}
.photo-license {
color: inherit;
}
.photo-external-links {
margin: 0.75rem 0 1.25rem;
font-size: 0.9em;
color: var(--text-muted);
}
.photo-external-links .meta-label {
margin-right: 0.5rem;
text-transform: lowercase;
letter-spacing: 0.02em;
}
.photo-external-link {
color: var(--text);
}
/* ---------------------------------------------------------------------------
* Section landing /photography/
* --------------------------------------------------------------------------- */
.photography-intro {
max-width: 36rem;
margin: 0 auto 2rem;
color: var(--text-muted);
}
.photography-empty {
text-align: center;
color: var(--text-faint);
font-style: italic;
margin: 4rem auto;
}
/* ---------------------------------------------------------------------------
* Mode toggle UI
* --------------------------------------------------------------------------- */
.photography-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.photography-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.photography-mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
font-size: 0.85em;
}
.photography-mode-toggle .mode-btn {
padding: 0.35rem 0.75rem;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: inherit;
letter-spacing: 0.02em;
transition: background 120ms ease, color 120ms ease;
}
.photography-mode-toggle .mode-btn:last-child {
border-right: none;
}
.photography-mode-toggle .mode-btn:hover {
color: var(--text);
}
.photography-mode-toggle .mode-btn.is-active {
background: var(--text);
color: var(--bg);
}
/* ---------------------------------------------------------------------------
* Grid container base + per-mode rules.
*
* The same .photography-grid markup feeds three layout strategies; the
* data-photography-mode attribute (set by photography-modes.js) keys all
* per-mode rules. JS also sets inline grid-row spans on each card in
* masonry mode; clearing the attribute clears the inline style.
* --------------------------------------------------------------------------- */
.photography-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
}
/* Masonry variable-height cells respecting native aspect ratios.
* grid-auto-rows + JS-applied grid-row spans synthesize true masonry. */
.photography-grid[data-photography-mode="masonry"] {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr));
grid-auto-rows: 8px; /* must match ROW_UNIT in photography-modes.js */
gap: 8px; /* must match ROW_GAP in photography-modes.js */
align-items: stretch;
}
.photography-grid[data-photography-mode="masonry"] .photo-card-img {
width: 100%;
height: auto;
display: block;
}
/* Uniform grid — square cells, object-fit: cover. Compare side-by-side. */
.photography-grid[data-photography-mode="grid"] {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
gap: 0.75rem;
}
.photography-grid[data-photography-mode="grid"] .photo-card-img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
background: var(--bg-muted);
}
/* Chronological — single column, large, contemplative. */
.photography-grid[data-photography-mode="chronological"] {
grid-template-columns: minmax(0, 48rem);
justify-content: center;
gap: 3rem;
}
.photography-grid[data-photography-mode="chronological"] .photo-card-img {
width: 100%;
height: auto;
display: block;
}
.photography-grid[data-photography-mode="chronological"] .photo-card-meta {
margin-top: 0.75rem;
font-size: 1em;
}
/* ---------------------------------------------------------------------------
* Card chrome shared across all three modes
* --------------------------------------------------------------------------- */
.photo-card {
margin: 0;
}
.photo-card-link {
display: block;
text-decoration: none;
color: inherit;
overflow: hidden;
}
.photo-card-img {
background: var(--bg-muted);
}
.photo-card-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-top: 0.4rem;
font-size: 0.9em;
}
.photo-card-title {
font-weight: 500;
}
.photo-card-date {
color: var(--text-muted);
font-variant-numeric: oldstyle-nums;
flex-shrink: 0;
}
/* ---------------------------------------------------------------------------
* Darkroom lightbox photography pages only
*
* Augments the existing lightbox (static/css/images.css) when the JS
* detects body[data-page-type="photography"] and adds the .darkroom
* class to the overlay. Non-photography lightbox styling is unaffected.
* --------------------------------------------------------------------------- */
.lightbox-overlay.darkroom {
background: #000;
}
.lightbox-overlay.darkroom .lightbox-vignette {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0) 35%,
rgba(0, 0, 0, 0.55) 100%
);
transition: opacity 200ms ease;
}
.lightbox-overlay:not(.darkroom) .lightbox-vignette,
.lightbox-overlay:not(.darkroom) .lightbox-info-toggle,
.lightbox-overlay:not(.darkroom) .lightbox-info-panel {
display: none;
}
.lightbox-overlay.darkroom .lightbox-caption {
color: rgba(255, 255, 255, 0.78);
font-style: italic;
font-size: 0.95em;
max-width: 48rem;
margin: 1rem auto 0;
text-align: center;
}
.lightbox-overlay.darkroom .lightbox-close {
color: rgba(255, 255, 255, 0.8);
}
/* "i" toggle button — top-right corner alongside the close button. */
.lightbox-info-toggle {
position: absolute;
top: 0.75rem;
right: 3.5rem;
z-index: 2;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 50%;
color: rgba(255, 255, 255, 0.8);
font-size: 1rem;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.lightbox-info-toggle:hover,
.lightbox-info-toggle[aria-pressed="true"] {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.7);
}
/* Info panel — slides in from below; muted serif for the EXIF strip. */
.lightbox-info-panel {
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 100%);
max-width: 48rem;
width: calc(100% - 2rem);
padding: 1rem 1.25rem;
background: rgba(0, 0, 0, 0.85);
color: rgba(255, 255, 255, 0.85);
border-top: 1px solid rgba(255, 255, 255, 0.12);
transition: transform 220ms ease;
pointer-events: none;
}
.lightbox-overlay.is-info-visible .lightbox-info-panel {
transform: translate(-50%, 0);
pointer-events: auto;
}
.lightbox-info-panel dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 1.25rem;
margin: 0;
font-size: 0.9em;
}
.lightbox-info-panel dt {
font-weight: 500;
color: rgba(255, 255, 255, 0.55);
text-transform: lowercase;
letter-spacing: 0.04em;
}
.lightbox-info-panel dd {
margin: 0;
color: rgba(255, 255, 255, 0.92);
}
/* ---------------------------------------------------------------------------
* Map page /photography/map/
*
* The viewport is sized in viewport-relative units so the map fills
* a useful portion of the screen without dominating it; the
* surrounding page chrome (header, attribution paragraph, footer)
* stays visible at typical desktop heights.
*
* Leaflet's own stylesheet handles tile / control / popup styling;
* these rules cover only the page-level shell, the tooltip content
* we render via tooltipHtml(), and the no-pin / error fallback states.
* --------------------------------------------------------------------------- */
.photography-map {
height: 70vh;
min-height: 32rem;
margin: 1.5rem 0 1rem;
border: 1px solid var(--border);
background: var(--bg-muted);
}
.photography-map--empty,
.photography-map--error {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
}
.photography-map-empty,
.photography-map-error,
.photography-map-fallback {
max-width: 32rem;
margin: 0 auto;
color: var(--text-muted);
font-style: italic;
}
.photography-map-note {
margin: 0.5rem 0 2rem;
color: var(--text-faint);
font-size: 0.85em;
max-width: 48rem;
}
.photography-map-note code {
background: var(--bg-muted);
padding: 0 0.3em;
border-radius: 2px;
font-size: 0.9em;
}
/* Tooltip content rendered by photography-map.js inside Leaflet's
* default tooltip wrapper. The wrapper class .photography-map-tooltip-wrap
* lets us tighten Leaflet's default padding without bleeding into the
* other Leaflet popups. */
.leaflet-tooltip.photography-map-tooltip-wrap {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
font-family: inherit;
}
/* Leaflet draws tooltip arrows via ::before; recolor to match. */
.leaflet-tooltip.photography-map-tooltip-wrap::before {
border-top-color: var(--border);
}
.photography-map-tooltip {
width: 14rem;
max-width: 16rem;
}
.photography-map-tooltip-img {
display: block;
width: 100%;
height: 8rem;
object-fit: cover;
margin: 0;
border-bottom: 1px solid var(--border);
}
.photography-map-tooltip-title {
padding: 0.4rem 0.6rem 0.1rem;
font-weight: 500;
line-height: 1.2;
}
.photography-map-tooltip-date {
padding: 0 0.6rem 0.4rem;
color: var(--text-muted);
font-size: 0.85em;
font-variant-numeric: oldstyle-nums;
}
/* ---------------------------------------------------------------------------
* By-year index /photography/by-year/
*
* A simple narrow column of years, each link a small card with the
* year (large oldstyle figures) and a count (muted small caps).
* --------------------------------------------------------------------------- */
.photography-header--narrow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.photography-by-year-back {
margin: 0 0 1rem;
color: var(--text-muted);
font-size: 0.9em;
}
.photography-by-year-list {
list-style: none;
padding: 0;
margin: 1rem 0 2rem;
max-width: 24rem;
}
.photography-by-year-item {
margin: 0;
}
.photography-by-year-link {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-muted);
text-decoration: none;
color: inherit;
transition: color 120ms ease;
}
.photography-by-year-link:hover {
color: var(--text);
}
.photography-by-year-year {
font-size: 1.4em;
font-variant-numeric: oldstyle-nums;
}
.photography-by-year-count {
color: var(--text-muted);
font-size: 0.85em;
text-transform: lowercase;
letter-spacing: 0.04em;
}
.photography-by-year-count::before {
content: "";
display: inline-block;
width: 0;
}
.photography-by-year-count::after {
content: " photographs";
}
/* ---------------------------------------------------------------------------
* Contact sheet /photography/contact-sheet/
*
* Frame-numbered grid in the visual register of an analog contact
* print: small uniform thumbnails, thin white border per frame,
* frame number in the corner via a CSS counter. The dark page
* backdrop is a soft "film-room" gray rather than full black so
* the white frames don't ring against the surrounding page chrome
* too hard.
* --------------------------------------------------------------------------- */
.photography-contact-sheet {
counter-reset: contact-frame-no;
list-style: none;
padding: 1.5rem;
margin: 1rem 0 2rem;
background: #1c1c1c;
border: 1px solid #2a2a2a;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
gap: 1.25rem;
}
.contact-frame {
counter-increment: contact-frame-no;
margin: 0;
background: #f5f3ee;
padding: 0.5rem 0.5rem 0.4rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
transition: transform 120ms ease;
}
.contact-frame:hover {
transform: scale(1.015);
}
.contact-frame-link {
display: block;
color: #1c1c1c;
text-decoration: none;
position: relative;
}
.contact-frame-img {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
display: block;
background: #2a2a2a;
}
.contact-frame-link::before {
/* Frame number analog convention: oldstyle figures, mono,
* small, top-left of the print under the image. */
content: counter(contact-frame-no, decimal-leading-zero);
position: absolute;
top: -1.4em;
left: 0;
font-family: var(--font-mono, monospace);
font-size: 0.75em;
color: rgba(245, 243, 238, 0.55);
letter-spacing: 0.05em;
}
.contact-frame-label {
display: block;
margin-top: 0.35rem;
font-size: 0.75em;
color: #4a4a4a;
line-height: 1.2;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------------------------------------------------------------------------
* Series landing /photography/<series>/
* --------------------------------------------------------------------------- */
.photo-series-abstract {
margin: 1rem 0 0;
font-size: 1.05em;
line-height: 1.5;
color: var(--text);
max-width: 36rem;
}
.photo-series-prose {
max-width: 36rem;
margin: 1.5rem 0;
color: var(--text);
}

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4 L 22 8 L 12 12 L 2 8 Z"/>
<path d="M6 10 L 6 15 C 6 17, 9 18.5, 12 18.5 C 15 18.5, 18 17, 18 15 L 18 10 L 17 10.4 L 17 14.7 C 17 16, 14.7 17.3, 12 17.3 C 9.3 17.3, 7 16, 7 14.7 L 7 10.4 Z"/>
<rect x="20.6" y="8" width="0.8" height="6"/>
<path d="M19.8 14 L 22.2 14 L 21.6 16.5 L 20.4 16.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">
<path d="M12 3 C 6.5 3, 2.5 7, 2.5 12 C 2.5 16, 5.5 19, 9.5 19 C 11 19, 11.5 18, 11.5 17 C 11.5 16.2, 11 15.7, 11 15 C 11 14.2, 11.7 13.5, 12.5 13.5 L 16 13.5 C 19 13.5, 21.5 11, 21.5 8 C 21.5 5, 17 3, 12 3 Z M6.5 11 A 1.4 1.4 0 1 1 6.5 13.8 A 1.4 1.4 0 1 1 6.5 11 Z M9.5 6.5 A 1.4 1.4 0 1 1 9.5 9.3 A 1.4 1.4 0 1 1 9.5 6.5 Z M14.5 6 A 1.4 1.4 0 1 1 14.5 8.8 A 1.4 1.4 0 1 1 14.5 6 Z M18 9 A 1.4 1.4 0 1 1 18 11.8 A 1.4 1.4 0 1 1 18 9 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">
<path d="M9 3 C 8.2 3, 7.5 3.7, 7.5 4.5 L 7.5 7.5 L 9 7.5 L 9 4.7 C 9 4.6, 9.1 4.5, 9.2 4.5 L 14.8 4.5 C 14.9 4.5, 15 4.6, 15 4.7 L 15 7.5 L 16.5 7.5 L 16.5 4.5 C 16.5 3.7, 15.8 3, 15 3 Z"/>
<path d="M4 7 L 20 7 C 20.6 7, 21 7.4, 21 8 L 21 19 C 21 19.6, 20.6 20, 20 20 L 4 20 C 3.4 20, 3 19.6, 3 19 L 3 8 C 3 7.4, 3.4 7, 4 7 Z M11 12.5 L 13 12.5 L 13 14.5 L 11 14.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 483 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="6" r="2"/>
<circle cx="19" cy="6" r="2"/>
<circle cx="5" cy="18" r="2"/>
<circle cx="19" cy="18" r="2"/>
<circle cx="12" cy="12" r="2.4"/>
<path d="M6.4 7.2 L10.4 11.2 L 9.6 12 L 5.6 8 Z"/>
<path d="M17.6 7.2 L13.6 11.2 L 14.4 12 L 18.4 8 Z"/>
<path d="M6.4 16.8 L10.4 12.8 L 9.6 12 L 5.6 16 Z"/>
<path d="M17.6 16.8 L13.6 12.8 L 14.4 12 L 18.4 16 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@ -28,11 +28,82 @@
closeBtn.setAttribute('aria-label', 'Close lightbox'); closeBtn.setAttribute('aria-label', 'Close lightbox');
closeBtn.textContent = '×'; closeBtn.textContent = '×';
// Darkroom-mode: photography pages only. Three additional
// elements layered behind / beneath the photo: a vignette
// overlay, an info panel showing EXIF-style metadata, and an
// "i" toggle button for revealing/hiding the panel.
// All three sit dormant on non-photography pages — the
// "darkroom" class on the overlay gates their visibility in CSS.
var vignette = document.createElement('div');
vignette.className = 'lightbox-vignette';
vignette.setAttribute('aria-hidden', 'true');
var infoPanel = document.createElement('div');
infoPanel.className = 'lightbox-info-panel';
infoPanel.setAttribute('aria-hidden', 'true');
var infoBtn = document.createElement('button');
infoBtn.className = 'lightbox-info-toggle';
infoBtn.setAttribute('aria-label', 'Toggle photo metadata');
infoBtn.setAttribute('aria-pressed', 'false');
infoBtn.textContent = ''; // — information source
overlay.appendChild(vignette);
overlay.appendChild(closeBtn); overlay.appendChild(closeBtn);
overlay.appendChild(infoBtn);
overlay.appendChild(img); overlay.appendChild(img);
overlay.appendChild(caption); overlay.appendChild(caption);
overlay.appendChild(infoPanel);
document.body.appendChild(overlay); document.body.appendChild(overlay);
// ----------------------------------------------------------------
// Darkroom helpers — populate / clear info panel
// ----------------------------------------------------------------
function isDarkroomPage() {
return document.body.dataset.pageType === 'photography';
}
// Mapping from data-photo-* attribute to the human-readable
// label shown in the panel. Order is the rendering order; only
// attributes present on the trigger image produce panel rows.
var PANEL_FIELDS = [
['photoCaptured', 'Captured'],
['photoLocation', 'Location'],
['photoCamera', 'Camera'],
['photoLens', 'Lens'],
['photoFilm', 'Film'],
['photoExposure', 'Exposure']
];
function populateInfoPanel(triggerImg) {
infoPanel.innerHTML = '';
if (!triggerImg || !triggerImg.dataset) return false;
var dl = document.createElement('dl');
var any = false;
PANEL_FIELDS.forEach(function (entry) {
var key = entry[0];
var label = entry[1];
var value = triggerImg.dataset[key];
if (!value) return;
any = true;
var dt = document.createElement('dt');
dt.textContent = label;
var dd = document.createElement('dd');
dd.textContent = value;
dl.appendChild(dt);
dl.appendChild(dd);
});
if (any) infoPanel.appendChild(dl);
return any;
}
function setInfoVisible(visible) {
overlay.classList.toggle('is-info-visible', visible);
infoPanel.setAttribute('aria-hidden', visible ? 'false' : 'true');
infoBtn.setAttribute('aria-pressed', visible ? 'true' : 'false');
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Open / close helpers // Open / close helpers
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@ -48,6 +119,17 @@
img.alt = alt || captionText || 'Lightbox image'; img.alt = alt || captionText || 'Lightbox image';
caption.textContent = captionText || ''; caption.textContent = captionText || '';
caption.hidden = !captionText; caption.hidden = !captionText;
// Darkroom mode is keyed off body data-page-type. The
// class is set BEFORE is-open so the dark backdrop is in
// place at the start of the transition rather than fading
// in over the existing one.
var darkroom = isDarkroomPage();
overlay.classList.toggle('darkroom', darkroom);
var hasInfo = darkroom ? populateInfoPanel(triggerEl) : false;
infoBtn.hidden = !hasInfo;
setInfoVisible(false);
overlay.classList.add('is-open'); overlay.classList.add('is-open');
document.documentElement.style.overflow = 'hidden'; document.documentElement.style.overflow = 'hidden';
closeBtn.focus(); closeBtn.focus();
@ -55,18 +137,23 @@
function close() { function close() {
overlay.classList.remove('is-open'); overlay.classList.remove('is-open');
setInfoVisible(false);
document.documentElement.style.overflow = ''; document.documentElement.style.overflow = '';
if (triggerEl) { if (triggerEl) {
triggerEl.focus(); triggerEl.focus();
triggerEl = null; triggerEl = null;
} }
// Clear src after transition to stop background loading // Clear src after transition to stop background loading.
// The darkroom class is also cleared on the same delay so
// the page chrome doesn't re-appear on top of a fading
// black backdrop.
var delay = parseFloat( var delay = parseFloat(
getComputedStyle(overlay).transitionDuration || '0' getComputedStyle(overlay).transitionDuration || '0'
) * 1000; ) * 1000;
setTimeout(function () { setTimeout(function () {
if (!overlay.classList.contains('is-open')) { if (!overlay.classList.contains('is-open')) {
img.src = ''; img.src = '';
overlay.classList.remove('darkroom');
} }
}, delay + 50); }, delay + 50);
} }
@ -106,10 +193,21 @@
} }
}); });
// Escape key // Info-panel button (darkroom only — gated by .darkroom class
// on overlay; CSS hides the button on non-photography pages).
infoBtn.addEventListener('click', function () {
setInfoVisible(!overlay.classList.contains('is-info-visible'));
});
// Escape closes; "i" toggles info panel (darkroom only).
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.classList.contains('is-open')) { if (!overlay.classList.contains('is-open')) return;
if (e.key === 'Escape') {
close(); close();
} else if ((e.key === 'i' || e.key === 'I')
&& overlay.classList.contains('darkroom')
&& !infoBtn.hidden) {
setInfoVisible(!overlay.classList.contains('is-info-visible'));
} }
}); });

View File

@ -0,0 +1,175 @@
/* Photography section Leaflet map.
*
* Loaded only on /photography/map/ via the photography-map context flag
* gating in templates/partials/head.html and templates/default.html.
*
* Pin source: /photography/map.json emitted by the Hakyll
* photographyMapDataRule, with city-precision (or per-photo override)
* coordinate rounding applied at build time. Full-precision coords
* never reach the client.
*
* Tile source: CartoDB Positron free for all volumes; required
* attribution is wired in below. Subdomains a-d are load-balanced.
*
* Marker behavior:
* * Click: navigate to the photo entry page.
* * Hover: tooltip with thumbnail + title + captured date.
* * Dense areas: leaflet.markercluster groups overlapping pins,
* expanding on click.
*
* The page chrome (header, toggle, attribution paragraph) renders
* pre-JS so search engines and no-JS readers see the orientation
* copy. Only the map viewport itself depends on Leaflet loading.
*/
(function () {
'use strict';
var MAP_DATA_URL = '/photography/map.json';
var MAP_ELEMENT = 'photography-map';
var TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILE_ATTRIB = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> '
+ 'contributors &copy; <a href="https://carto.com/attributions">CARTO</a>';
var TILE_SUBDOMS = 'abcd';
var FALLBACK_VIEW = [20, 0]; // [lat, lon] when there are zero pins
var FALLBACK_ZOOM = 2;
// Override the default Leaflet marker icon paths so they resolve
// to the vendored copy under /leaflet/images/. Leaflet's default
// resolution uses the URL of leaflet.js, which fails for vendored
// setups since the script lives in /js/, not /leaflet/.
function configureMarkerIconPaths() {
if (typeof L === 'undefined' || !L.Icon || !L.Icon.Default) return;
L.Icon.Default.mergeOptions({
iconRetinaUrl: '/leaflet/images/marker-icon-2x.png',
iconUrl: '/leaflet/images/marker-icon.png',
shadowUrl: '/leaflet/images/marker-shadow.png'
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function tooltipHtml(pin) {
var thumb = pin.thumb
? '<img class="photography-map-tooltip-img" src="' + escapeHtml(pin.thumb) + '" alt="" loading="lazy">'
: '';
var date = pin.captured
? '<div class="photography-map-tooltip-date">' + escapeHtml(pin.captured) + '</div>'
: '';
return ''
+ '<div class="photography-map-tooltip">'
+ thumb
+ '<div class="photography-map-tooltip-title">' + escapeHtml(pin.title || '(untitled)') + '</div>'
+ date
+ '</div>';
}
function renderEmptyState(container) {
container.classList.add('photography-map--empty');
container.innerHTML =
'<p class="photography-map-empty">'
+ 'No geo-tagged photographs yet. Photos with a '
+ '<code>geo:</code> frontmatter field will appear here.'
+ '</p>';
}
function renderErrorState(container, message) {
container.classList.add('photography-map--error');
container.innerHTML =
'<p class="photography-map-error">'
+ escapeHtml(message || 'Could not load the map.')
+ '</p>';
}
document.addEventListener('DOMContentLoaded', function () {
var container = document.getElementById(MAP_ELEMENT);
if (!container) return;
// Leaflet must be present; the conditional script load in
// default.html should guarantee this on /photography/map/, but
// a defensive fallback is cheap.
if (typeof L === 'undefined') {
renderErrorState(container, 'Map library failed to load.');
return;
}
configureMarkerIconPaths();
fetch(MAP_DATA_URL, { cache: 'force-cache' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (pins) {
if (!Array.isArray(pins) || pins.length === 0) {
renderEmptyState(container);
return;
}
var map = L.map(container, {
scrollWheelZoom: false, // require explicit interaction
zoomControl: true,
attributionControl: true
}).setView(FALLBACK_VIEW, FALLBACK_ZOOM);
L.tileLayer(TILE_URL, {
attribution: TILE_ATTRIB,
subdomains: TILE_SUBDOMS,
maxZoom: 19
}).addTo(map);
// markercluster — groups overlapping pins; falls back
// to plain L.featureGroup if the plugin failed to load.
var hasCluster = typeof L.markerClusterGroup === 'function';
var layer = hasCluster ? L.markerClusterGroup() : L.featureGroup();
pins.forEach(function (pin) {
if (typeof pin.lat !== 'number' || typeof pin.lon !== 'number') return;
var marker = L.marker([pin.lat, pin.lon]);
marker.bindTooltip(tooltipHtml(pin), {
direction: 'top',
offset: [0, -36],
className: 'photography-map-tooltip-wrap',
opacity: 1
});
if (pin.url) {
marker.on('click', function () {
window.location.href = pin.url;
});
}
layer.addLayer(marker);
});
map.addLayer(layer);
// Frame the visible pins with a small padding. Single-
// pin portfolios get a moderate zoom rather than the
// hard-coded zoom level so the marker doesn't feel
// marooned in negative space.
if (pins.length === 1) {
map.setView([pins[0].lat, pins[0].lon], 8);
} else {
var bounds = layer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds.pad(0.15));
}
}
// Allow scroll-wheel zoom only after the user clicks
// into the map — prevents the page from "trapping" the
// scroll on someone passing through.
map.once('focus', function () { map.scrollWheelZoom.enable(); });
map.on('blur', function () { map.scrollWheelZoom.disable(); });
})
.catch(function (err) {
renderErrorState(container, 'Could not load map data: ' + err.message);
});
});
}());

View File

@ -0,0 +1,199 @@
/* Photography section view mode toggle and masonry row-span computation.
*
* Mode toggle:
* - Three modes: masonry (default), grid, chronological.
* - Selection persists to localStorage under "photography-mode".
* - Mode is applied as data-photography-mode on .photography-grid;
* CSS keys all per-mode rules off this attribute.
*
* Masonry row-spans:
* - In masonry mode, .photography-grid uses CSS grid with
* grid-auto-rows: 1px so each card can occupy a precise integer
* number of "row units" matching its image's natural aspect.
* - For each photo card we set inline grid-row: span N once the
* image's natural dimensions are known. Pre-load, the card uses
* orientation-derived defaults from data-orientation so initial
* paint is roughly the right shape.
*
* No-op gracefully:
* - If the page has no .photography-grid (i.e. we're not on the
* /photography/ landing) the script returns early.
* - If localStorage is unavailable, mode toggling still works for
* the duration of the visit; just no persistence.
*/
(function () {
'use strict';
var STORAGE_KEY = 'photography-mode';
// Modes applied inline on /photography/ (CSS toggle). "map" is a
// separate page and is excluded here so it follows normal navigation.
var VALID_MODES = ['masonry', 'grid', 'chronological'];
var DEFAULT_MODE = 'masonry';
// Row unit (px) — must match grid-auto-rows in photography.css.
// Smaller unit = finer granularity = better aspect-ratio matching
// at a small cost in inline-style verbosity. 8px is a common sweet
// spot for masonry.
var ROW_UNIT = 8;
// Vertical gap between rows in masonry mode (px). Must match
// photography.css .photography-grid[data-photography-mode="masonry"] gap.
var ROW_GAP = 8;
// Approximate aspect ratios for each declared orientation; used as
// a placeholder span before the image's natural dimensions arrive.
var ORIENTATION_RATIO = {
portrait: 3 / 2, // height / width — taller than wide
landscape: 2 / 3,
square: 1
};
document.addEventListener('DOMContentLoaded', function () {
var toggleButtons = document.querySelectorAll('.photography-mode-toggle .mode-btn');
var grid = document.querySelector('.photography-grid');
// The script is loaded site-wide on photography pages but only
// does work when there's a toggle (and, separately, when there's
// a grid for masonry row-spans). Bail early if neither is present.
if (toggleButtons.length === 0 && !grid) return;
// ----------------------------------------------------------------
// localStorage helpers (shared by the toggle and any future
// mode-aware behaviour on photography pages)
// ----------------------------------------------------------------
function readStoredMode() {
try {
var stored = window.localStorage.getItem(STORAGE_KEY);
if (stored && VALID_MODES.indexOf(stored) !== -1) {
return stored;
}
} catch (e) { /* localStorage unavailable */ }
return DEFAULT_MODE;
}
function writeStoredMode(mode) {
try { window.localStorage.setItem(STORAGE_KEY, mode); }
catch (e) { /* localStorage unavailable */ }
}
// ----------------------------------------------------------------
// Mode toggle
// ----------------------------------------------------------------
function applyMode(mode) {
if (!grid) return;
grid.setAttribute('data-photography-mode', mode);
toggleButtons.forEach(function (btn) {
var match = btn.dataset.mode === mode;
btn.classList.toggle('is-active', match);
btn.setAttribute('aria-pressed', match ? 'true' : 'false');
});
if (mode === 'masonry') {
applyAllRowSpans();
} else {
clearAllRowSpans();
}
}
toggleButtons.forEach(function (btn) {
btn.addEventListener('click', function (e) {
var mode = btn.dataset.mode;
if (!mode) return;
// Persist the chosen mode regardless of whether we're
// applying it inline or following a link. This is what
// makes the round-trip through /photography/map/ remember
// which grid view to land on.
if (VALID_MODES.indexOf(mode) !== -1) {
writeStoredMode(mode);
}
// On the landing page (where the grid is in the DOM),
// apply masonry/grid/chronological inline and suppress
// the anchor's default navigation. The "map" link
// ALWAYS navigates — there's no inline alternative.
if (grid && VALID_MODES.indexOf(mode) !== -1) {
e.preventDefault();
applyMode(mode);
}
});
});
// ----------------------------------------------------------------
// Masonry row-spans
// ----------------------------------------------------------------
function rowSpanForRatio(ratio, contentWidth) {
// ratio = naturalHeight / naturalWidth; contentWidth = rendered width
var imageHeight = ratio * contentWidth;
// Allow ~1.4em for the meta strip (title + date below image).
var metaHeight = 28;
var totalHeight = imageHeight + metaHeight;
return Math.max(1, Math.ceil((totalHeight + ROW_GAP) / (ROW_UNIT + ROW_GAP)));
}
function applyRowSpan(card) {
var img = card.querySelector('.photo-card-img');
if (!img) return;
var width = card.clientWidth;
if (!width) return; /* not visible yet — wait for resize */
var ratio;
if (img.naturalWidth && img.naturalHeight) {
ratio = img.naturalHeight / img.naturalWidth;
} else {
var orient = card.dataset.orientation || 'landscape';
ratio = ORIENTATION_RATIO[orient] || ORIENTATION_RATIO.landscape;
}
card.style.gridRowEnd = 'span ' + rowSpanForRatio(ratio, width);
}
function applyAllRowSpans() {
grid.querySelectorAll('.photo-card').forEach(applyRowSpan);
}
function clearAllRowSpans() {
grid.querySelectorAll('.photo-card').forEach(function (card) {
card.style.gridRowEnd = '';
});
}
// Refine spans once each image's natural dimensions are known.
// No-op when the grid is absent (e.g. on the map page).
if (grid) {
grid.querySelectorAll('.photo-card-img').forEach(function (img) {
if (img.complete && img.naturalWidth) {
applyRowSpan(img.closest('.photo-card'));
} else {
img.addEventListener('load', function () {
if (grid.getAttribute('data-photography-mode') === 'masonry') {
applyRowSpan(img.closest('.photo-card'));
}
});
}
});
// Re-flow on resize — cell width changes affect height.
var resizeRaf = null;
window.addEventListener('resize', function () {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(function () {
if (grid.getAttribute('data-photography-mode') === 'masonry') {
applyAllRowSpans();
}
});
});
}
// ----------------------------------------------------------------
// Boot — only applies a mode when we're on a page that has the
// grid. The map page has the toggle but no grid; for it,
// localStorage is read/written by the click handler above and
// by the grid page's boot when the user navigates back.
// ----------------------------------------------------------------
if (grid) applyMode(readStoredMode());
});
}());

View File

@ -3,7 +3,7 @@
<head> <head>
$partial("templates/partials/head.html")$ $partial("templates/partials/head.html")$
</head> </head>
<body$if(reading)$ class="reading-mode$if(poetry)$ poetry$endif$$if(fiction)$ fiction$endif$"$endif$$if(dingbat)$ data-dingbat="$dingbat$"$endif$> <body$if(reading)$ class="reading-mode$if(poetry)$ poetry$endif$$if(fiction)$ fiction$endif$"$endif$$if(photography)$ data-page-type="photography"$endif$$if(dingbat)$ data-dingbat="$dingbat$"$endif$>
<a class="skip-link" href="#markdownBody">Skip to content</a> <a class="skip-link" href="#markdownBody">Skip to content</a>
$partial("templates/partials/nav.html")$ $partial("templates/partials/nav.html")$
$if(search)$ $if(search)$
@ -27,6 +27,10 @@ $partial("templates/partials/footer.html")$
<script src="/js/lightbox.js" defer></script> <script src="/js/lightbox.js" defer></script>
$if(home)$<script src="/js/random.js" defer></script>$endif$ $if(home)$<script src="/js/random.js" defer></script>$endif$
$if(reading)$<script src="/js/reading.js" defer></script>$endif$ $if(reading)$<script src="/js/reading.js" defer></script>$endif$
$if(photography)$<script src="/js/photography-modes.js" defer></script>$endif$
$if(photography-map)$<script src="/leaflet/leaflet.js" defer></script>$endif$
$if(photography-map)$<script src="/leaflet/leaflet.markercluster.js" defer></script>$endif$
$if(photography-map)$<script src="/js/photography-map.js" defer></script>$endif$
$for(page-scripts)$<script src="/$script-src$" defer></script>$endfor$ $for(page-scripts)$<script src="/$script-src$" defer></script>$endfor$
$if(math)$ $if(math)$
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>

View File

@ -81,6 +81,20 @@ $endif$
</section> </section>
$endif$ $endif$
$if(photography-entries)$
<section class="library-section">
<h2 id="photography"><span class="library-section-ornament" data-ornament="photography" aria-hidden="true"></span><a href="/photography/">Photography</a></h2>
<ul class="item-card-list">
$for(photography-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
$if(photography-has-more)$
<p class="library-more"><a href="/photography/">More on this shelf &rarr;</a></p>
$endif$
</section>
$endif$
$if(ai-entries)$ $if(ai-entries)$
<section class="library-section"> <section class="library-section">
<h2 id="ai"><span class="library-section-ornament" data-ornament="ai" aria-hidden="true"></span><a href="/ai/">AI</a></h2> <h2 id="ai"><span class="library-section-ornament" data-ornament="ai" aria-hidden="true"></span><a href="/ai/">AI</a></h2>

View File

@ -5,6 +5,7 @@ $if(description)$<meta name="description" content="$description$">$endif$
<link rel="canonical" href="$site-url$$url$"> <link rel="canonical" href="$site-url$$url$">
<link rel="alternate" type="application/atom+xml" title="Levi Neuwirth" href="/feed.xml"> <link rel="alternate" type="application/atom+xml" title="Levi Neuwirth" href="/feed.xml">
<link rel="alternate" type="application/atom+xml" title="Levi Neuwirth — music" href="/music/feed.xml"> <link rel="alternate" type="application/atom+xml" title="Levi Neuwirth — music" href="/music/feed.xml">
<link rel="alternate" type="application/atom+xml" title="Levi Neuwirth — photography" href="/photography/feed.xml">
<!-- OpenGraph / Twitter (link-preview unfurling) --> <!-- OpenGraph / Twitter (link-preview unfurling) -->
<meta property="og:site_name" content="Levi Neuwirth"> <meta property="og:site_name" content="Levi Neuwirth">
@ -35,6 +36,7 @@ $if(description)$<meta name="twitter:description" content="$description$">$endif
$if(home)$<link rel="stylesheet" href="/css/home.css">$endif$ $if(home)$<link rel="stylesheet" href="/css/home.css">$endif$
$if(library)$<link rel="stylesheet" href="/css/library.css">$endif$ $if(library)$<link rel="stylesheet" href="/css/library.css">$endif$
$if(library)$<link rel="stylesheet" href="/css/item-card.css">$endif$ $if(library)$<link rel="stylesheet" href="/css/item-card.css">$endif$
$if(links)$<link rel="stylesheet" href="/css/links.css">$endif$
$if(search)$<link rel="stylesheet" href="/css/library.css">$endif$ $if(search)$<link rel="stylesheet" href="/css/library.css">$endif$
$if(list-page)$<link rel="stylesheet" href="/css/item-card.css">$endif$ $if(list-page)$<link rel="stylesheet" href="/css/item-card.css">$endif$
$if(memento-mori)$<link rel="stylesheet" href="/css/memento-mori.css">$endif$ $if(memento-mori)$<link rel="stylesheet" href="/css/memento-mori.css">$endif$
@ -45,6 +47,10 @@ $if(now)$<link rel="stylesheet" href="/css/now.css">$endif$
$if(build)$<link rel="stylesheet" href="/css/build.css">$endif$ $if(build)$<link rel="stylesheet" href="/css/build.css">$endif$
$if(reading)$<link rel="stylesheet" href="/css/reading.css">$endif$ $if(reading)$<link rel="stylesheet" href="/css/reading.css">$endif$
$if(composition)$<link rel="stylesheet" href="/css/score-reader.css">$endif$ $if(composition)$<link rel="stylesheet" href="/css/score-reader.css">$endif$
$if(photography)$<link rel="stylesheet" href="/css/photography.css">$endif$
$if(photography-map)$<link rel="stylesheet" href="/leaflet/leaflet.css">$endif$
$if(photography-map)$<link rel="stylesheet" href="/leaflet/MarkerCluster.css">$endif$
$if(photography-map)$<link rel="stylesheet" href="/leaflet/MarkerCluster.Default.css">$endif$
<link rel="stylesheet" href="/css/print.css" media="print"> <link rel="stylesheet" href="/css/print.css" media="print">
$if(math)$ $if(math)$
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">

View File

@ -47,5 +47,6 @@
$if(bibliography)$<a href="#bibliography">Bibliography</a>$endif$ $if(bibliography)$<a href="#bibliography">Bibliography</a>$endif$
$if(backlinks)$<a href="#backlinks">Backlinks</a>$endif$ $if(backlinks)$<a href="#backlinks">Backlinks</a>$endif$
$if(repository)$<a href="$repository$">Repository</a>$endif$ $if(repository)$<a href="$repository$">Repository</a>$endif$
$if(preprint)$<a href="$preprint$">Preprint</a>$endif$
</nav> </nav>
</div> </div>

View File

@ -5,11 +5,11 @@
<a href="/" class="nav-logo" aria-label="Home"></a> <a href="/" class="nav-logo" aria-label="Home"></a>
<div class="nav-primary"> <div class="nav-primary">
<a href="/">Home</a> <a href="/">Home</a>
<a href="/current.html">Current</a>
<a href="/library.html">Library</a> <a href="/library.html">Library</a>
<a href="/links.html">Links</a> <a href="/links.html">Links</a>
<a href="/me.html">Me</a> <a href="/me.html">Me</a>
<a href="/new.html">New</a> <a href="/new.html">New</a>
<a href="/current.html">Current</a>
<a href="/search.html">Search</a> <a href="/search.html">Search</a>
<a href="/about.html">Vita</a> <a href="/about.html">Vita</a>
</div> </div>
@ -56,6 +56,7 @@
<a href="/miscellany/">Miscellany</a> <a href="/miscellany/">Miscellany</a>
<a href="/music/">Music</a> <a href="/music/">Music</a>
<a href="/nonfiction/">Nonfiction</a> <a href="/nonfiction/">Nonfiction</a>
<a href="/photography/">Photography</a>
<a href="/poetry/">Poetry</a> <a href="/poetry/">Poetry</a>
<a href="/research/">Research</a> <a href="/research/">Research</a>
<a href="/tech/">Tech</a> <a href="/tech/">Tech</a>

View File

@ -0,0 +1,13 @@
<li class="photo-card"$if(orientation)$ data-orientation="$orientation$"$endif$>
<a class="photo-card-link" href="$url$">
$if(photo-url)$
<picture>$if(photo-webp-url)$<source srcset="$photo-webp-url$" type="image/webp">$endif$<img class="photo-card-img" src="$photo-url$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$></picture>
$else$$if(photo)$
<img class="photo-card-img" src="$photo$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$>
$endif$$endif$
<div class="photo-card-meta">
<span class="photo-card-title">$title$</span>
$if(captured-iso)$<time class="photo-card-date" datetime="$captured-iso$">$captured-display$</time>$endif$
</div>
</a>
</li>

View File

@ -0,0 +1,25 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header photography-header--narrow">
<h1 class="page-title">$title$</h1>
<p class="photography-by-year-back">
<a href="/photography/">← all photographs</a>
</p>
</header>
$if(years)$
<ul class="photography-by-year-list">
$for(years)$
<li class="photography-by-year-item">
<a class="photography-by-year-link" href="$year-url$">
<span class="photography-by-year-year">$year$</span>
<span class="photography-by-year-count">$year-count$</span>
</a>
</li>
$endfor$
</ul>
$else$
<p class="photography-empty">No years yet — photographs need a <code>captured:</code> or <code>date:</code> frontmatter field to be grouped by year.</p>
$endif$
</main>
</div>

View File

@ -0,0 +1,22 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header photography-header--narrow">
<h1 class="page-title">$title$</h1>
<p class="photography-by-year-back">
<a href="/photography/">← all photographs</a>
·
<a href="/photography/by-year/">other years</a>
</p>
</header>
$if(photos)$
<ul class="photography-grid" data-photography-mode="masonry">
$for(photos)$
$partial("templates/partials/photo-card.html")$
$endfor$
</ul>
$else$
<p class="photography-empty">No photographs in $year$.</p>
$endif$
</main>
</div>

View File

@ -0,0 +1,29 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header photography-header--narrow">
<h1 class="page-title">Contact sheet</h1>
<p class="photography-by-year-back">
<a href="/photography/">← all photographs</a>
</p>
</header>
$if(photos)$
<ol class="photography-contact-sheet">
$for(photos)$
<li class="contact-frame">
<a class="contact-frame-link" href="$url$">
$if(photo-url)$
<picture>$if(photo-webp-url)$<source srcset="$photo-webp-url$" type="image/webp">$endif$<img class="contact-frame-img" src="$photo-url$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$></picture>
$else$$if(photo)$
<img class="contact-frame-img" src="$photo$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$>
$endif$$endif$
<span class="contact-frame-label">$title$</span>
</a>
</li>
$endfor$
</ol>
$else$
<p class="photography-empty">No photographs to print.</p>
$endif$
</main>
</div>

View File

@ -0,0 +1,29 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header">
<h1 class="page-title">$title$</h1>
$if(photos)$
<div class="photography-controls" role="toolbar" aria-label="Browsing mode">
<div class="photography-mode-toggle" role="tablist" aria-label="Photography view mode">
<a class="mode-btn is-active" role="tab" data-mode="masonry" href="/photography/" aria-pressed="true" aria-label="Masonry view">Masonry</a>
<a class="mode-btn" role="tab" data-mode="grid" href="/photography/" aria-pressed="false" aria-label="Uniform grid view">Grid</a>
<a class="mode-btn" role="tab" data-mode="chronological" href="/photography/" aria-pressed="false" aria-label="Chronological view">Chronological</a>
<a class="mode-btn" role="tab" data-mode="map" href="/photography/map/" aria-pressed="false" aria-label="Map view">Map</a>
</div>
</div>
$endif$
</header>
$if(body)$<div class="photography-intro">$body$</div>$endif$
$if(photos)$
<ul class="photography-grid" data-photography-mode="masonry">
$for(photos)$
$partial("templates/partials/photo-card.html")$
$endfor$
</ul>
$else$
<p class="photography-empty">No photographs published yet.</p>
$endif$
</main>
</div>

View File

@ -0,0 +1,34 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header">
<h1 class="page-title">Photography</h1>
<div class="photography-controls" role="toolbar" aria-label="Browsing mode">
<div class="photography-mode-toggle" role="tablist" aria-label="Photography view mode">
<a class="mode-btn" role="tab" data-mode="masonry" href="/photography/" aria-pressed="false">Masonry</a>
<a class="mode-btn" role="tab" data-mode="grid" href="/photography/" aria-pressed="false">Grid</a>
<a class="mode-btn" role="tab" data-mode="chronological" href="/photography/" aria-pressed="false">Chronological</a>
<a class="mode-btn is-active" role="tab" aria-current="page" data-mode="map" href="/photography/map/" aria-pressed="true">Map</a>
</div>
</div>
</header>
<div id="photography-map" class="photography-map" aria-label="Map of geo-tagged photographs">
<noscript>
<p class="photography-map-fallback">
The map view requires JavaScript. Browse the
<a href="/photography/">photography portfolio</a>
or jump to a specific photo from the
<a href="/photography/">grid view</a>.
</p>
</noscript>
</div>
<p class="photography-map-note">
Pin coordinates are rounded to the precision each photograph's
<code>geo-precision</code> field declares — typically the
nearest ten kilometres. Photos with no <code>geo:</code>
frontmatter (or with <code>geo-precision: hidden</code>) are
omitted from this map by design.
</p>
</main>
</div>

Some files were not shown because too many files have changed in this diff Show More