diff --git a/.gitignore b/.gitignore index 2529e62..af71335 100644 --- a/.gitignore +++ b/.gitignore @@ -77,7 +77,13 @@ data/semantic-meta.json IGNORE.txt # Working notes / planning docs at the repo root (not site content). +content/drafts/ 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 # live under static/ and ship with the site; the pipeline itself is @@ -94,7 +100,40 @@ static/models/ # Download with: make download-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). # To intentionally commit a WebP, use: git add -f path/to/file.webp static/**/*.webp -content/**/*.webp \ No newline at end of file +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 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 \ No newline at end of file diff --git a/Makefile b/Makefile index 07e2389..ed901d3 100644 --- a/Makefile +++ b/Makefile @@ -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. # .env format: KEY=value (one per line, no `export` prefix, no quotes needed). @@ -28,6 +28,21 @@ build: @./tools/convert-images.sh @$(MAKE) -s pdf-thumbs @./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 pagefind --site _site @if [ -d .venv ]; then \ @@ -56,6 +71,13 @@ download-model: download-pdfjs: @./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/. # Runs automatically as part of `build`. Pairs with `gzip_static` / # `brotli_static` in the nginx vhost (see nginx/static-assets.conf). diff --git a/PHOTOGRAPHY.md b/PHOTOGRAPHY.md new file mode 100644 index 0000000..78f6a1b --- /dev/null +++ b/PHOTOGRAPHY.md @@ -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 `` with WebP companion + lazy-loading for any image in any document. Photography pages need a richer wrapper that includes: + +- The palette strip beneath the photo +- A small EXIF metadata block (camera, lens, exposure, captured-date) toggled by an "ⓘ" button +- A figure caption (Pandoc already handles this) +- The `data-photography="true"` attribute that scopes the darkroom lightbox + +The cleanest approach: extend `Filters/Images.hs` to detect when the document being processed is a photography page (via document metadata or path pattern) and emit the richer wrapper in that case. Essays continue to get the simple `` wrapper unchanged. + +Alternative considered: render the richer wrapper from the template instead of the filter. Rejected because the template loses the per-image palette/EXIF lookup; doing it in the filter keeps the data flow Pandoc-native. + +--- + +## Map architecture + +### Library & tiles + +- **Leaflet 1.9.x** vendored to `static/leaflet/`. No CDN. `tools/download-leaflet.sh` mirrors the `download-pdfjs.sh` pattern. +- **CartoDB Positron** raster tiles. Free for any volume, attribution required ("© OpenStreetMap contributors © CARTO"). Monochrome, doesn't fight the typography. +- Fallback: if CartoDB ever rate-limits or disappears, swap to Stadia Maps or self-hosted. + +### Build-time data + +`tools/build-map-data.py` walks `content/photography/`, reads each entry's `geo` + `geo-precision`, applies the precision rounding, and emits `_site/photography/map.json`: + +```json +[ + { + "slug": "reykjavik-rooftops", + "title": "Reykjavík Rooftops", + "url": "/photography/reykjavik-rooftops/", + "thumb": "/photography/reykjavik-rooftops/photo.jpg", + "lat": 64.15, + "lon": -21.94, + "captured": "2026-03-15" + } +] +``` + +### Geo precision + +Applied at build time before any data leaves the build directory: + +| Precision | Rounding | Approximate | +|-----------|----------|-------------| +| `exact` | 4 decimals | ~10 m | +| `km` | 2 decimals | ~1 km | +| `city` *(default)* | 1 decimal | ~10 km | +| `hidden` | omit from `map.json` entirely | not pinned | + +### Page-scoped JS/CSS + +`static/leaflet/leaflet.js`, `static/leaflet/leaflet.css`, and `static/js/photography-map.js` are loaded **only** on `/photography/map/` via the per-page `js:` frontmatter mechanism (already supported — see `WRITING.md`). Other photography pages stay lightweight. + +### Marker behavior + +- Click marker → photo page. +- Marker thumbnail tooltip on hover (uses Leaflet's tooltip API). +- Marker clustering when zoomed out, via `leaflet.markercluster` plugin (also vendored). + +--- + +## Templates + +New files under `templates/`: + +| File | Role | +|------|------| +| `photography.html` | Single photo page chrome — figure, palette strip, metadata, navigation within series | +| `photography-index.html` | `/photography/` landing — mode toggle, masonry/grid/chrono modes | +| `photography-series.html` | `/photography/{series}/` landing — series intro + photo list | +| `photography-map.html` | `/photography/map/` — Leaflet container, vendored JS/CSS | +| `photography-contact-sheet.html` | `/photography/contact-sheet/` — frame-numbered grid | +| `photography-by-year.html` | `/photography/by-year/{year}/` — chronological year index | + +New partial: + +| File | Role | +|------|------| +| `partials/photo-card.html` | Reusable photo-card markup for grids and listings | +| `partials/photo-meta.html` | Camera/lens/exposure/captured block, toggleable | +| `partials/photo-palette.html` | 5-swatch palette strip | + +Existing partials reused unchanged: `nav.html`, `head.html`, `footer.html`. + +--- + +## Build module structure + +New Haskell modules under `build/`: + +- **`build/Photography.hs`** — patterns, routing rules, contexts specific to photography. Separated from `Site.hs` for the same reason `Catalog.hs` and `Authors.hs` are separated: scoped concerns, easier to reason about. + +Edits to existing modules: + +- **`build/Patterns.hs`** — add `photographyPattern`, `photographySinglesPattern`, `photographySeriesPattern`, `photographyAssetsPattern`. +- **`build/Compilers.hs`** — add `photographyCompiler` (essay-pipeline minus epistemic/reading-time/backlinks/TOC). +- **`build/Contexts.hs`** — add `photographyCtx` with photo-specific fields, sidecar merge logic. +- **`build/Site.hs`** — add `("Photography", "photography")` to `homePortals`; wire photography rules from `Photography.hs`. +- **`build/Filters/Images.hs`** — extend to emit richer wrapper on photography pages. + +--- + +## Phased implementation + +Each phase has explicit exit criteria. Don't move to the next phase until the current one passes. + +### Phase 1 — Skeleton end-to-end ✓ + +- [x] Add `photographyPattern` family to `Patterns.hs` +- [x] Create `build/Photography.hs` with routing rules +- [x] Add `photographyCompiler` to `Compilers.hs` +- [x] Add `photographyCtx` to `Contexts.hs` +- [x] Add `("Photography", "photography")` to `homePortals` in `Site.hs` +- [x] Create `templates/photography.html`, `templates/photography-index.html`, `templates/partials/photo-card.html` +- [x] Add minimal `static/css/photography.css` (no modes yet — single column is fine) +- [x] Manually drop one prepared JPEG into `content/photography/{slug}/` and author its frontmatter by hand +- [x] Verify the photography portal appears in nav, the landing page lists the photo, the photo page renders +- [x] **Bonus**: license + outbound-links metadata (auto-resolved canonical URLs for known CC variants) + +**Exit criteria**: A photo renders at `/photography/{slug}/` with correct nav portal, basic styling, and is reachable from `/photography/` and `/library.html`. **Met.** + +### Phase 2 — Visual system & toggle ✓ + +- [x] Build masonry layout in `photography.css` (CSS grid + computed row spans) +- [x] Build uniform-grid mode +- [x] Build chronological mode +- [x] `static/js/photography-modes.js` — toggle UI, localStorage persistence +- [x] Extend `static/js/lightbox.js` with darkroom mode branch (gated on `body[data-page-type="photography"]`) +- [x] Body `data-page-type="photography"` attribute (added in Phase 1 as a free hook) + +**Exit criteria**: `/photography/` switches between three modes smoothly; localStorage persists choice; lightbox enters darkroom mode on photography pages only. **Met.** Visual verification pending Levi's run of the dev server. + +### Phase 3 — EXIF, palette, and import pipelines ✓ + +- [x] `tools/extract-exif.py` (uses `exiftool` if present, falls back to `Pillow`) +- [x] `tools/extract-palette.py` (Python + colorthief) +- [x] `tools/import-photo.sh` (resize, strip EXIF from delivered file, write sidecars, scaffold frontmatter) +- [x] Wire both into the Makefile, gated on `.venv` (silent-skip pattern matching `embed.py`) +- [x] Extend `photographyCtx` to merge sidecar EXIF + palette into the template context (frontmatter wins) +- [x] Update `.gitignore` with photography sidecars and RAW patterns +- [ ] **Deferred** — Extend `Filters/Images.hs` with the richer photography wrapper +- [ ] **Deferred** — Build `partials/photo-meta.html` and `partials/photo-palette.html` + +The two deferred items are now redundant with the Phase 1 template structure: the per-photo metadata block and palette strip live in `templates/photography.html` directly and consume `photographyCtx` fields. Extending `Filters/Images.hs` would only matter if Levi later writes long-form "photo essays" with multiple inline photos that should each carry the rich wrapper — defer until that content type exists. + +**Exit criteria**: A photo with no manually authored camera/lens/exposure/palette frontmatter still displays full metadata + palette strip on its detail page, sourced entirely from sidecars. **Met.** Verified with `canto31.jpg` (no frontmatter `captured:`, `camera:`, etc.; `captured-display` and palette swatches both flowed from sidecar through to rendered HTML). + +### Phase 4 — Map ✓ + +- [x] `tools/download-leaflet.sh` — vendors Leaflet 1.9.4 + leaflet.markercluster 1.5.3 to `static/leaflet/`, sha256-pinned +- [x] **`build/Photography.hs` map.json rule** (Haskell, not Python — cleaner: Hakyll already has the metadata, no extra dep) +- [x] `templates/photography-map.html` +- [x] `static/js/photography-map.js` +- [x] Add map mode to the toggle on `/photography/` +- [x] CartoDB Positron tile attribution wired into the page + +**Exit criteria**: `/photography/map/` shows pins at city precision, marker thumbnails on hover, clicking a pin navigates to the photo. Leaflet JS/CSS load only on the map page. **Met.** + +Implementation notes worth knowing: + +- `map.json` is generated by Hakyll (`photographyMapDataRule` in `build/Photography.hs`), not a Python step. The original spec called for `tools/build-map-data.py`; Haskell turned out cleaner because Hakyll already has every photo's frontmatter loaded and the precision-rounding logic is six lines. +- `geo-precision: hidden` photos are dropped from `map.json` entirely. +- Pin URLs are stripped of trailing `index.html` so click-through goes directly to the canonical directory URL with no implicit redirect. +- UTF-8 in titles is decoded via `Data.Text.Lazy.Encoding.decodeUtf8` rather than `LBS.unpack` to avoid double-encoding bugs (em-dashes, accents, etc.). +- Map page is tile-rate-limit-friendly: scroll-zoom is disabled until the user clicks into the map (prevents accidental zoom while scrolling past), tiles cached aggressively (`fetch(..., {cache: 'force-cache'})`). +- `leaflet.markercluster` is loaded but degrades gracefully to plain `L.featureGroup` if the plugin failed to load. + +### Phase 5 — Auxiliary surfaces ✓ + +- [x] `/photography/by-year/` and `/photography/by-year/{year}/` +- [x] `/photography/contact-sheet/` +- [x] `/photography/feed.xml` (Atom with thumbnails embedded inline in entry descriptions) +- [x] Series landing pages auto-generated from collection directories +- [x] Photography shelf on `/library.html` +- [x] Tag pages (`/photography/landscape/`, etc.) wired into the existing tag system; the bare `photography` tag is filtered out of the expansion in `Tags.getExpandedTags` to avoid colliding with the section-landing route + +**Exit criteria**: All routing-table URLs from this document resolve and are linked from somewhere reachable. **Met.** + +Implementation notes worth knowing: + +- A new `allPhotoEntries` pattern in `Patterns.hs` enumerates every photographic file (top-level entries + series children); used by surfaces that need every frame (by-year, contact-sheet, feed, map). The original `photographyPattern` (top-level only) feeds the main `/photography/` landing and the library shelf, where a series should appear as a single aggregate card rather than once for the landing plus once per child. +- Series detection is purely structural: a directory has siblings ↔ it's a series. No `series: true` flag in frontmatter. `photographyEntryRules` uses a `Set String` of series-slugs computed once at rule-gen time to branch template selection (`photography-series.html` vs `photography.html`). +- Sibling photo URLs are canonical directory form: `/photography///`. +- The `sectionOwnedTopLevelTags` filter in `Tags.hs` is named generally so other portal tags can be added if their content types ever feed `tagIndexable`. +- By-year extraction reads frontmatter `captured:` first, falling back to `date:`. Photos with neither are silently dropped from by-year only; they remain visible everywhere else. (Future improvement: also fall back to the EXIF sidecar's `captured:` so frontmatter-free photos appear automatically.) + +--- + +## Open / deferred questions + +These are non-blocking but worth tracking: + +- **Build-time crop** — currently `object-fit: cover`. If the contact-sheet aesthetic feels weak with center-crops, introduce a crop-aware build step (Phase 6 or later). +- **`nsfw` gate** — frontmatter field is reserved but no UI is planned in initial phases. Add when first needed. +- **Print availability / contact info** — out of scope for v1; revisit if Levi wants to sell prints. +- **Diptych / triptych layouts** — frontmatter-driven pairing exists in concept (`pair: other-slug`) but unimplemented; defer until there's actual content that demands it. +- **Random photo entry point** — `/photography/random` redirect; trivial JS, defer. +- **EXIF reliability for film** — non-concern per design discussion; sidecar/frontmatter merge handles missing EXIF gracefully. +- **High-res download links** — out of scope. Originals are not online. + +--- + +## References + +- `WRITING.md` — frontmatter conventions for essays (template for the photography schema's structure) +- `HOMEPAGE.md` — homepage portal grid +- `build/Patterns.hs` — current content pattern definitions +- `build/Tags.hs` — slash-hierarchy tag system (reused for photography tags) +- `build/Filters/Images.hs` — current image filter (to be extended) +- `static/css/gallery.css` — exhibit/overlay system (reference for darkroom lightbox) +- `tools/convert-images.sh` — WebP companion generation (reused as-is) diff --git a/WRITING.md b/WRITING.md index 8b3b5a1..c0bf7a0 100644 --- a/WRITING.md +++ b/WRITING.md @@ -81,6 +81,7 @@ bibliography: data/custom.bib # optional; overrides data/bibliography.bib csl: data/custom.csl # optional; overrides Chicago Author-Date no-collapse: true # optional; disables collapsible h2/h3 sections 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/a.js, scripts/b.js] # or a list diff --git a/build/Compilers.hs b/build/Compilers.hs index 72da06c..1babf66 100644 --- a/build/Compilers.hs +++ b/build/Compilers.hs @@ -7,6 +7,7 @@ module Compilers , poetryCompiler , fictionCompiler , compositionCompiler + , photographyCompiler , sidecarCompiler , readerOpts , writerOpts @@ -201,6 +202,25 @@ fictionCompiler = essayCompiler compositionCompiler :: Compiler (Item String) 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 @@ 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 -- source-level preprocessors and AST filters (wikilinks, sidenotes, -- smallcaps, links, etc.) so sidecar prose can use the same rich diff --git a/build/Contexts.hs b/build/Contexts.hs index 91b6617..0203b41 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -8,6 +8,7 @@ module Contexts , poetryCtx , fictionCtx , compositionCtx + , photographyCtx , contentKindField , abstractField , descriptionField @@ -24,17 +25,22 @@ module Contexts ) where 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.Vector as V import Data.List (intercalate, isPrefixOf, sortBy) import Data.Maybe (fromMaybe, mapMaybe) import Data.Ord (comparing) +import qualified Data.Scientific as Sci import Data.Time.Calendar (toGregorian) import Data.Time.Clock (UTCTime, getCurrentTime, utctDay) 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 qualified Data.Text as T +import qualified Data.Yaml as Y import Text.Pandoc (runPure, readMarkdown, writeHtml5String, writePlain, Pandoc(..), Block(..), Inline(..)) import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..)) import Hakyll hiding (trim) @@ -113,12 +119,13 @@ contentKindField = field "item-kind" $ \item -> do return $ case r of Nothing -> "Page" Just r' - | "essays/" `isPrefixOf` r' -> "Essay" - | "blog/" `isPrefixOf` r' -> "Post" - | "poetry/" `isPrefixOf` r' -> "Poem" - | "fiction/" `isPrefixOf` r' -> "Fiction" - | "music/" `isPrefixOf` r' -> "Composition" - | otherwise -> "Page" + | "essays/" `isPrefixOf` r' -> "Essay" + | "blog/" `isPrefixOf` r' -> "Post" + | "poetry/" `isPrefixOf` r' -> "Poem" + | "fiction/" `isPrefixOf` r' -> "Fiction" + | "music/" `isPrefixOf` r' -> "Composition" + | "photography/" `isPrefixOf` r' -> "Photo" + | otherwise -> "Page" -- --------------------------------------------------------------------------- -- Site-wide context @@ -886,3 +893,399 @@ compositionCtx = <> field "has-audio" (\i -> maybe (fail "no audio") (const (return "true")) (movAudio (itemBody i))) + +-- --------------------------------------------------------------------------- +-- Photography context +-- --------------------------------------------------------------------------- + +-- | Extract the photo entry's slug from its identifier. +-- +-- * Flat single @content/photography/.md@ → @@ +-- * Directory @content/photography//index.md@ → @@ +-- +-- 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//@ 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//@ 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 + -- . 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//@ 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 @@ — the @@ then degrades + -- to a plain @@ on the original-format src. Browsers do NOT + -- fall back from a 404'd @@ to the nested @@; 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, "") diff --git a/build/Filters/Images.hs b/build/Filters/Images.hs index f81ccc2..7cf7e27 100644 --- a/build/Filters/Images.hs +++ b/build/Filters/Images.hs @@ -1,6 +1,7 @@ {-# LANGUAGE GHC2021 #-} {-# LANGUAGE OverloadedStrings #-} --- | Image filter: lazy loading, lightbox markers, and WebP wrappers. +-- | Image filter: lazy loading, lightbox markers, WebP +-- wrappers, and CLS-preventing width/height attrs. -- -- For local raster images (JPG, JPEG, PNG, GIF) whose @.webp@ companion -- exists on disk at build time, emits a @@ element with a WebP @@ -17,16 +18,29 @@ -- -- SVG files and external URLs are passed through with only lazy loading -- (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 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 import Data.Char (toLower) +import Data.Default (def) import Data.List (isPrefixOf) import Data.Text (Text) 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.FilePath (replaceExtension, takeExtension, ()) -import Text.Pandoc.Definition -import Text.Pandoc.Walk (walkM) import qualified Utils as U -- | 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@ -- companion file. Absolute paths (leading @/@) are resolved against -- @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 @
@ 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 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 @@ 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 @
@ 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 @
@ with +-- @aria-hidden="true"@. Screen readers then announce the alt +-- (via the @@) 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 srcDir (Link lAttr ils lTarget) = do -- 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 → @@ with WebP @@ -- * Local raster without companion → plain @@ (graceful degradation) -- * Everything else (SVG, URL) → plain @@ 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 +-- @@. 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 srcDir attr alt target@(src, _) lightbox - | isLocalRaster (T.unpack src) = do - hasWebp <- doesFileExist (webpPhysicalPath srcDir src) - if hasWebp - then pure $ RawInline (Format "html") - (renderPicture attr alt target lightbox) - else pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr)) - alt target - | otherwise = - pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr)) alt target +renderImg srcDir attr alt target@(src, _) lightbox = 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) + if hasWebp + then pure $ RawInline (Format "html") + (renderPicture attr alt target lightbox dims) + else pure $ Image (commonAttrs dims) alt target + else + pure $ Image (commonAttrs dims) alt target where + commonAttrs dims = + withDims dims + $ addAttr "decoding" "async" + $ addLightbox lightbox + $ addAttr "loading" "lazy" attr + addLightbox True a = addAttr "data-lightbox" "true" 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. -- -- Absolute paths (@/images/foo.jpg@) resolve under @static/@ because that @@ -89,13 +186,49 @@ webpPhysicalPath srcDir src = else srcDir s 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 + -- --------------------------------------------------------------------------- -- rendering -- --------------------------------------------------------------------------- -- | Emit a @@ element with a WebP @@ and an @@ fallback. -renderPicture :: Attr -> [Inline] -> Target -> Bool -> Text -renderPicture (ident, classes, kvs) alt (src, title) lightbox = +renderPicture :: Attr -> [Inline] -> Target -> Bool -> Maybe (Int, Int) -> Text +renderPicture (ident, classes, kvs) alt (src, title) lightbox dims = T.concat [ "" , "" @@ -105,7 +238,9 @@ renderPicture (ident, classes, kvs) alt (src, title) lightbox = , " src=\"", esc src, "\"" , attrAlt alt , attrTitle title + , dimsAttrs dims , " loading=\"lazy\"" + , " decoding=\"async\"" , if lightbox then " data-lightbox=\"true\"" else "" , renderKvs passedKvs , ">" @@ -114,13 +249,81 @@ renderPicture (ident, classes, kvs) alt (src, title) lightbox = where webpSrc = replaceExtension (T.unpack src) ".webp" -- Strip attrs we handle explicitly above (id/class/alt/title) and the - -- attrs we always emit ourselves (loading, data-lightbox), so they don't - -- appear twice on the . + -- attrs we always emit ourselves (loading, decoding, data-lightbox, + -- width, height), so they don't appear twice on the . passedKvs = filter (\(k, _) -> k `notElem` - ["loading", "data-lightbox", "id", "class", "alt", "title", "src"]) + [ "loading", "decoding", "data-lightbox" + , "id", "class", "alt", "title", "src" + , "width", "height" + ]) kvs + dimsAttrs Nothing = "" + dimsAttrs (Just (w, h)) = + " width=\"" <> T.pack (show w) + <> "\" height=\"" <> T.pack (show h) <> "\"" + +-- --------------------------------------------------------------------------- +--
synthesis (Block walk, WebP path only) +-- --------------------------------------------------------------------------- + +-- | Build a @
@ HTML element wrapping pre-rendered inner +-- content (typically a @@) 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 + [ "\n" + , inner + , "\n" + , figcaption + , "\n
" + ] + +-- | Build a @
@ 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 " attrs <> ">" <> body <> "
" + +-- | 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 @

@ 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 t = if T.null t then "" else " id=\"" <> esc t <> "\"" diff --git a/build/Patterns.hs b/build/Patterns.hs index a81b80c..a496b72 100644 --- a/build/Patterns.hs +++ b/build/Patterns.hs @@ -16,6 +16,8 @@ module Patterns , poetryPattern , fictionPattern , musicPattern + , photographyPattern + , allPhotoEntries , standalonePagesPattern -- * Aggregated patterns , allWritings -- essays + blog + poetry + fiction @@ -66,6 +68,44 @@ fictionPattern = "content/fiction/*.md" musicPattern :: Pattern musicPattern = "content/music/*/index.md" +-- | All photo entries — flat singles plus directory-form entries. +-- +-- Phase 1 supports two shapes: +-- * flat: @content/photography/.md@ +-- * directory: @content/photography//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//.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//@ — 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 -- the curated routing pages under @content/cv/@ (which render with the -- same @templates/page.html@ pipeline and need the same backlink and @@ -98,6 +138,14 @@ allContent = authorIndexable :: Pattern 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 @//@ 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 = (essayPattern .||. blogPattern) .&&. hasNoVersion +tagIndexable = + (essayPattern .||. blogPattern .||. allPhotoEntries) + .&&. hasNoVersion diff --git a/build/Photography.hs b/build/Photography.hs new file mode 100644 index 0000000..9578900 --- /dev/null +++ b/build/Photography.hs @@ -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//.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/@ — flat-single co-located assets +-- * @content/photography//@ — 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/.md@ → @photography/.html@ +-- * @content/photography//index.md@ → @photography//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//.md@. Compiled with the +-- single-photo template; routed to @//index.html@ +-- so the URL is the canonical directory form (matches the rest of +-- the photography section's URL shape). +-- +-- Series landings (@/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 @/.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/.md@) +-- from directory-form (@content/photography//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//index.md + -- → photography//index.html + then replaceExtension (drop (length contentPrefix) fp) "html" + -- content/photography/.md → photography/.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 +-- @@ 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) -> + "

\"\"

\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//@ 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//@ — 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 + diff --git a/build/Site.hs b/build/Site.hs index 79ae90a..32419bf 100644 --- a/build/Site.hs +++ b/build/Site.hs @@ -32,6 +32,7 @@ import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, c contentKindField, recentFirstByDisplay, tagLinksFieldExcludingTopSegment) import qualified Patterns as P +import Photography (photographyRules) import Tags (buildAllTags, applyTagRules, sidecarIdentifier, portalIntroField, portalTooltipField) import Pagination (blogPaginateRules) @@ -46,14 +47,15 @@ import Stats (statsRules) -- do not introduce an @order:@ frontmatter field on sidecars. homePortals :: [(String, String)] homePortals = - [ ("Research", "research") - , ("Nonfiction", "nonfiction") - , ("Fiction", "fiction") - , ("Poetry", "poetry") - , ("Music", "music") - , ("AI", "ai") - , ("Tech", "tech") - , ("Miscellany", "miscellany") + [ ("Research", "research") + , ("Nonfiction", "nonfiction") + , ("Fiction", "fiction") + , ("Poetry", "poetry") + , ("Music", "music") + , ("Photography", "photography") + , ("AI", "ai") + , ("Tech", "tech") + , ("Miscellany", "miscellany") ] -- | Default number of cards shown per library shelf. The sidecar @@ -179,8 +181,16 @@ rules = do route $ gsubRoute "static/" (const "") compile compressCssCompiler - -- All other static files (fonts, JS, images, …) - match ("static/**" .&&. complement "static/css/*") $ do + -- All other static files (fonts, JS, images, …). Build-time + -- 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 "") compile copyFileCompiler @@ -344,17 +354,21 @@ rules = do >>= loadAndApplyTemplate "templates/default.html" essayCtx >>= 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/**" .&&. 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 "") compile copyFileCompiler -- Static assets co-located with draft essays (dev-only). when isDev $ match ("content/drafts/essays/**" .&&. 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 "") compile copyFileCompiler @@ -464,6 +478,12 @@ rules = do compositionCtx >>= relativizeUrls + -- --------------------------------------------------------------------------- + -- Photography — single-photo entries, asset copy, and section landing. + -- See build/Photography.hs and PHOTOGRAPHY.md for the design. + -- --------------------------------------------------------------------------- + photographyRules + -- --------------------------------------------------------------------------- -- Blog index (paginated) -- --------------------------------------------------------------------------- @@ -573,13 +593,14 @@ rules = do -- Load every content item once, then partition by primary portal -- 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) posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion) fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion) poetry <- loadAll (allPoetry .&&. 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] tagged <- mapM (\i -> (,i) <$> primaryPortalOf i) allContent let itemsByPortal :: Map.Map String [Item String] diff --git a/build/Tags.hs b/build/Tags.hs index 5afff58..159dbc5 100644 --- a/build/Tags.hs +++ b/build/Tags.hs @@ -78,9 +78,33 @@ expandTag t = let segs = wordsBy (== '/') t 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). +-- Filters out any 'sectionOwnedTopLevelTags' to prevent route +-- collisions with section landings. getExpandedTags :: MonadMetadata m => Identifier -> m [String] -getExpandedTags ident = nub . concatMap expandTag <$> getTags ident +getExpandedTags ident = + filter (`notElem` sectionOwnedTopLevelTags) . nub . concatMap expandTag + <$> getTags ident -- --------------------------------------------------------------------------- diff --git a/cabal.project.freeze b/cabal.project.freeze index aba8d51..b48a697 100644 --- a/cabal.project.freeze +++ b/cabal.project.freeze @@ -41,11 +41,11 @@ constraints: any.Glob ==0.10.2, any.cborg ==0.2.10.0, any.cereal ==0.5.8.3, any.citeproc ==0.8.1.1, - any.colour ==2.3.6, + any.colour ==2.3.7, any.commonmark ==0.2.6.1, any.commonmark-extensions ==0.2.5.6, 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-extra ==1.3.8, any.containers ==0.6.7, @@ -70,7 +70,7 @@ constraints: any.Glob ==0.10.2, any.distributive ==0.6.2.1, any.djot ==0.1.2.3, any.dlist ==1.0, - any.doclayout ==0.5, + any.doclayout ==0.5.0.1, any.doctemplates ==0.11.0.1, any.easy-file ==0.2.5, any.emojis ==0.1.4.1, @@ -85,7 +85,7 @@ constraints: any.Glob ==0.10.2, any.ghc-prim ==0.10.0, any.gridtables ==0.1.1.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, any.half ==0.3.3, any.hashable ==1.4.7.0, @@ -181,7 +181,7 @@ constraints: any.Glob ==0.10.2, any.text ==2.0.2, any.text-conversions ==0.3.1.1, 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.th-abstraction ==0.6.0.0, any.th-compat ==0.1.7, @@ -232,4 +232,4 @@ constraints: any.Glob ==0.10.2, any.yaml ==0.11.11.2, any.zip-archive ==0.4.3.2, 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 diff --git a/content/drafts/essays/specification_dilemma.md b/content/drafts/essays/specification_dilemma.md deleted file mode 100644 index df2f284..0000000 --- a/content/drafts/essays/specification_dilemma.md +++ /dev/null @@ -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 # 0–100 integer (%) -importance: 5 # 1–5 integer (rendered as filled/empty dots ●●●○○) -evidence: 3 # 1–5 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. diff --git a/content/essays/specification-dilemma/data/outputs/dense/00.txt b/content/essays/specification-dilemma/data/outputs/dense/00.txt new file mode 100644 index 0000000..4bb6c89 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/00.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/01.txt b/content/essays/specification-dilemma/data/outputs/dense/01.txt new file mode 100644 index 0000000..a535f0a --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/01.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/02.txt b/content/essays/specification-dilemma/data/outputs/dense/02.txt new file mode 100644 index 0000000..0e1703b --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/02.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/03.txt b/content/essays/specification-dilemma/data/outputs/dense/03.txt new file mode 100644 index 0000000..b1bd48f --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/03.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/04.txt b/content/essays/specification-dilemma/data/outputs/dense/04.txt new file mode 100644 index 0000000..51b9d52 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/04.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/05.txt b/content/essays/specification-dilemma/data/outputs/dense/05.txt new file mode 100644 index 0000000..d94b48b --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/05.txt @@ -0,0 +1,7 @@ + + +A 2023 survey of 500 remote‑first 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 skill‑building during the first three months. In other words, the headline figure masks a quiet transfer of onboarding costs from the employer to the employee. + +Remote‑first companies treat onboarding as a one‑off 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 remote‑first firms assigns a dedicated mentor for the first ninety days, and fewer still schedule regular check‑ins 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 you’re a recent graduate stepping into your first remote role, the implication is straightforward: don’t assume the company’s onboarding will equip you for success. Ask upfront who your point of contact is for the first month, request a written skill‑development 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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/06.txt b/content/essays/specification-dilemma/data/outputs/dense/06.txt new file mode 100644 index 0000000..2af9404 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/06.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/07.txt b/content/essays/specification-dilemma/data/outputs/dense/07.txt new file mode 100644 index 0000000..a8fe068 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/07.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/08.txt b/content/essays/specification-dilemma/data/outputs/dense/08.txt new file mode 100644 index 0000000..c25d24c --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/08.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/09.txt b/content/essays/specification-dilemma/data/outputs/dense/09.txt new file mode 100644 index 0000000..583b110 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/09.txt @@ -0,0 +1,7 @@ + + +Most founders who claim to be remote‑first are running a distributed office, not a remote company. They hang a banner on the wall of their Slack workspace, declare “we’re remote‑first,” and then default to a schedule that mirrors a downtown HQ: morning stand‑ups, midday syncs, and end‑of‑day 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, low‑grade pressure to be present, which burns out early hires and blindsides founders who think they have solved the location problem. + +Remote‑first and async‑first are not synonyms. The former describes where work happens; the latter describes how work moves between people. When a seed‑stage 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, context‑switching 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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/10.txt b/content/essays/specification-dilemma/data/outputs/dense/10.txt new file mode 100644 index 0000000..0d135e0 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/10.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/11.txt b/content/essays/specification-dilemma/data/outputs/dense/11.txt new file mode 100644 index 0000000..31fe940 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/11.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/12.txt b/content/essays/specification-dilemma/data/outputs/dense/12.txt new file mode 100644 index 0000000..f937350 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/12.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/13.txt b/content/essays/specification-dilemma/data/outputs/dense/13.txt new file mode 100644 index 0000000..639d71a --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/13.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/14.txt b/content/essays/specification-dilemma/data/outputs/dense/14.txt new file mode 100644 index 0000000..18712d7 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/14.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/15.txt b/content/essays/specification-dilemma/data/outputs/dense/15.txt new file mode 100644 index 0000000..7e4dc63 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/15.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/16.txt b/content/essays/specification-dilemma/data/outputs/dense/16.txt new file mode 100644 index 0000000..f43f385 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/16.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/17.txt b/content/essays/specification-dilemma/data/outputs/dense/17.txt new file mode 100644 index 0000000..c2e8c0e --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/17.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/18.txt b/content/essays/specification-dilemma/data/outputs/dense/18.txt new file mode 100644 index 0000000..892259f --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/18.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/19.txt b/content/essays/specification-dilemma/data/outputs/dense/19.txt new file mode 100644 index 0000000..9a1724a --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/19.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/20.txt b/content/essays/specification-dilemma/data/outputs/dense/20.txt new file mode 100644 index 0000000..b9c4d33 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/20.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/21.txt b/content/essays/specification-dilemma/data/outputs/dense/21.txt new file mode 100644 index 0000000..50c16f5 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/21.txt @@ -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. It’s 6:47 a.m., and I’m rehearsing the day—a series of Slack pings, a missed call from a co‑worker I never quite heard, the feeling that I’m 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. I’m 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 manager’s name flashing, then vanishing—reminds me of the office’s 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 isn’t 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. I’m not arguing for a return to the old normal or an endorsement of working elsewhere; I’m 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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/22.txt b/content/essays/specification-dilemma/data/outputs/dense/22.txt new file mode 100644 index 0000000..5336153 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/22.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/23.txt b/content/essays/specification-dilemma/data/outputs/dense/23.txt new file mode 100644 index 0000000..fe5998b --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/23.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/24.txt b/content/essays/specification-dilemma/data/outputs/dense/24.txt new file mode 100644 index 0000000..6027ac4 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/24.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/25.txt b/content/essays/specification-dilemma/data/outputs/dense/25.txt new file mode 100644 index 0000000..f768b5a --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/25.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/26.txt b/content/essays/specification-dilemma/data/outputs/dense/26.txt new file mode 100644 index 0000000..4d55d1d --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/26.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/27.txt b/content/essays/specification-dilemma/data/outputs/dense/27.txt new file mode 100644 index 0000000..95a1ef1 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/27.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/28.txt b/content/essays/specification-dilemma/data/outputs/dense/28.txt new file mode 100644 index 0000000..bb3b139 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/28.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/dense/29.txt b/content/essays/specification-dilemma/data/outputs/dense/29.txt new file mode 100644 index 0000000..f9e192e --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/dense/29.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/00.txt b/content/essays/specification-dilemma/data/outputs/sparse/00.txt new file mode 100644 index 0000000..8c79d13 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/00.txt @@ -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 work‑life 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, goal‑setting, and well‑being, remote work can also lead to isolation, blurred boundaries, and uneven performance. + +In this blog post, we’ll explore the key trends shaping remote work in 2024, share proven strategies for staying productive and connected, and highlight common pitfalls to avoid. You’ll 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 you’re a seasoned remote veteran or just beginning to navigate the shift, our insights will help you turn the promise of location‑independent work into a sustainable advantage. + +Ready to unlock the full potential of remote work? Let’s dive in and discover how you can thrive in today’s distributed workforce. If you found this useful, feel free to share your thoughts in comments below and let us know which remote work topics you’d like us to cover next. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/01.txt b/content/essays/specification-dilemma/data/outputs/sparse/01.txt new file mode 100644 index 0000000..04447ea --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/01.txt @@ -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!* \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/02.txt b/content/essays/specification-dilemma/data/outputs/sparse/02.txt new file mode 100644 index 0000000..2b6cd4f --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/02.txt @@ -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? \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/03.txt b/content/essays/specification-dilemma/data/outputs/sparse/03.txt new file mode 100644 index 0000000..9f991ae --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/03.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/04.txt b/content/essays/specification-dilemma/data/outputs/sparse/04.txt new file mode 100644 index 0000000..5311fbf --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/04.txt @@ -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 well‑being. + +One of the most attractive aspects of working from home is flexibility to shape your day around priorities. Whether it’s fitting in a morning workout, attending a child's school event, or simply enjoying a quieter atmosphere, remote work can improve work‑life 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 self‑discipline 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 work‑from‑home world, our goal is to provide actionable insights that make your home office a place where professional success and personal well‑being thrive. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/05.txt b/content/essays/specification-dilemma/data/outputs/sparse/05.txt new file mode 100644 index 0000000..434a667 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/05.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/06.txt b/content/essays/specification-dilemma/data/outputs/sparse/06.txt new file mode 100644 index 0000000..71e4baf --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/06.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/07.txt b/content/essays/specification-dilemma/data/outputs/sparse/07.txt new file mode 100644 index 0000000..ab44b21 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/07.txt @@ -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, work‑life 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 it’s 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 they’re 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 \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/08.txt b/content/essays/specification-dilemma/data/outputs/sparse/08.txt new file mode 100644 index 0000000..df97484 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/08.txt @@ -0,0 +1,9 @@ + + +In recent years, the way we work has undergone a seismic shift that few could have predicted. From bustling open‑plan 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 COVID‑19 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 well‑being, 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 work‑life 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 forward‑thinking 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 in‑depth 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 tomorrow’s virtual workplaces. Stay curious. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/09.txt b/content/essays/specification-dilemma/data/outputs/sparse/09.txt new file mode 100644 index 0000000..d5a453d --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/09.txt @@ -0,0 +1,7 @@ + + +The global workplace is experiencing a transformation unlike any other in recent history. With the rapid rise of digital tools, high‑speed 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 cloud‑based 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 work‑life 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 one‑third 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 well‑documented: 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, we’ll unpack the evolving landscape of remote work, exploring proven tactics for staying productive, maintaining mental well‑being, and fostering a vibrant virtual culture. Whether you’re a seasoned remote veteran or just beginning to navigate the telecommuting world, our upcoming posts will provide actionable insights, expert tips, and real‑world 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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/10.txt b/content/essays/specification-dilemma/data/outputs/sparse/10.txt new file mode 100644 index 0000000..bf2dd50 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/10.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/11.txt b/content/essays/specification-dilemma/data/outputs/sparse/11.txt new file mode 100644 index 0000000..f4a12bd --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/11.txt @@ -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, location‑independent 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 well‑being, 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 outcome‑focused 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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/12.txt b/content/essays/specification-dilemma/data/outputs/sparse/12.txt new file mode 100644 index 0000000..a91f8bf --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/12.txt @@ -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! \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/13.txt b/content/essays/specification-dilemma/data/outputs/sparse/13.txt new file mode 100644 index 0000000..d930fb6 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/13.txt @@ -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 well‑being. 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 outcome‑based performance rather than presence‑based monitoring. + +Technology plays a pivotal role in enabling seamless remote operations. Cloud‑based project management platforms, real‑time 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 check‑ins, 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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/14.txt b/content/essays/specification-dilemma/data/outputs/sparse/14.txt new file mode 100644 index 0000000..ee6580d --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/14.txt @@ -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? \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/15.txt b/content/essays/specification-dilemma/data/outputs/sparse/15.txt new file mode 100644 index 0000000..9eac888 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/15.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/16.txt b/content/essays/specification-dilemma/data/outputs/sparse/16.txt new file mode 100644 index 0000000..362e250 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/16.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/17.txt b/content/essays/specification-dilemma/data/outputs/sparse/17.txt new file mode 100644 index 0000000..aa99c0e --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/17.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/18.txt b/content/essays/specification-dilemma/data/outputs/sparse/18.txt new file mode 100644 index 0000000..a73b7df --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/18.txt @@ -0,0 +1,17 @@ + + +Sure! Here’s 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 work‑life balance. Yet with these advantages come fresh considerations—how do teams stay connected without face‑to‑face 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 well‑being, and even urban planning? + +In this post, we’ll explore the key benefits that have driven remote work’s 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 you’re 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 you’d like a different tone, length, or focus! \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/19.txt b/content/essays/specification-dilemma/data/outputs/sparse/19.txt new file mode 100644 index 0000000..bebf524 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/19.txt @@ -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! \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/20.txt b/content/essays/specification-dilemma/data/outputs/sparse/20.txt new file mode 100644 index 0000000..3440936 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/20.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/21.txt b/content/essays/specification-dilemma/data/outputs/sparse/21.txt new file mode 100644 index 0000000..c0c599f --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/21.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/22.txt b/content/essays/specification-dilemma/data/outputs/sparse/22.txt new file mode 100644 index 0000000..897e275 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/22.txt @@ -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.* \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/23.txt b/content/essays/specification-dilemma/data/outputs/sparse/23.txt new file mode 100644 index 0000000..1078c5d --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/23.txt @@ -0,0 +1,11 @@ + + +The office door is no longer a physical place you walk through each morning; it’s 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 well‑being, and even corporate culture. + +For many professionals, the freedom to work from a home office, a co‑working 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 self‑discipline, and the risk of isolation. + +In this post we’ll explore the key ingredients that make remote work sustainable—not just viable. We’ll dive into actionable strategies for structuring your day, setting up a productive workspace, and leveraging the right digital tools to stay connected with teammates. We’ll also examine how leaders can foster a culture of trust and accountability without relying on the old model of physical presence. + +Whether you’re a remote‑work 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 isn’t a single location; it’s a mindset that values flexibility, results‑oriented performance, and intentional communication. + +So, if you’ve ever wondered how to turn the comfort of your living room into a high‑performance hub, or how to lead a team that thrives across time zones, you’re in the right place. Let’s unpack the tools, habits, and mindsets that can make remote work not just workable, but truly rewarding. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/24.txt b/content/essays/specification-dilemma/data/outputs/sparse/24.txt new file mode 100644 index 0000000..813e809 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/24.txt @@ -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.* \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/25.txt b/content/essays/specification-dilemma/data/outputs/sparse/25.txt new file mode 100644 index 0000000..ffc6bc2 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/25.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/26.txt b/content/essays/specification-dilemma/data/outputs/sparse/26.txt new file mode 100644 index 0000000..7cd3012 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/26.txt @@ -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 brick‑and‑mortar 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 work‑life 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 real‑world case studies from companies that have thriving remote cultures. Our goal is to equip readers with actionable tips, data‑driven analysis, and inspiration to make remote work sustainable and rewarding. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/27.txt b/content/essays/specification-dilemma/data/outputs/sparse/27.txt new file mode 100644 index 0000000..13d2c27 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/27.txt @@ -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? \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/28.txt b/content/essays/specification-dilemma/data/outputs/sparse/28.txt new file mode 100644 index 0000000..8118ff4 --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/28.txt @@ -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. \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/outputs/sparse/29.txt b/content/essays/specification-dilemma/data/outputs/sparse/29.txt new file mode 100644 index 0000000..606076f --- /dev/null +++ b/content/essays/specification-dilemma/data/outputs/sparse/29.txt @@ -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.* \ No newline at end of file diff --git a/content/essays/specification-dilemma/data/pairwise.csv b/content/essays/specification-dilemma/data/pairwise.csv new file mode 100644 index 0000000..2737b00 --- /dev/null +++ b/content/essays/specification-dilemma/data/pairwise.csv @@ -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 diff --git a/content/essays/specification-dilemma/data/plot.png b/content/essays/specification-dilemma/data/plot.png new file mode 100644 index 0000000..9ce23b6 Binary files /dev/null and b/content/essays/specification-dilemma/data/plot.png differ diff --git a/content/essays/specification-dilemma/data/stats.json b/content/essays/specification-dilemma/data/stats.json new file mode 100644 index 0000000..acd9625 --- /dev/null +++ b/content/essays/specification-dilemma/data/stats.json @@ -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 + } +} \ No newline at end of file diff --git a/content/essays/specification-dilemma/figures/violin-similarity.png b/content/essays/specification-dilemma/figures/violin-similarity.png new file mode 100644 index 0000000..9ce23b6 Binary files /dev/null and b/content/essays/specification-dilemma/figures/violin-similarity.png differ diff --git a/content/essays/specification-dilemma/index.md b/content/essays/specification-dilemma/index.md new file mode 100644 index 0000000..b8fe321 --- /dev/null +++ b/content/essays/specification-dilemma/index.md @@ -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. + +
+

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...

+
+ +
+

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...

+
+ +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 + +
+
+

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.

+
+
Aristotle — De Memoria et Reminiscentia
+
+ +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 Mann–Whitney 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 (5–20 tokens, topic only, in their natural register) and a dense prompt (150–300 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}$ | +| Mann–Whitney *U* | 187,873 | +| Mann–Whitney *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.85–0.95 with a small satellite cluster around 0.6–0.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.85–0.95 — the convergence the hypothesis predicts. The smaller satellite cluster around 0.6–0.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: + +
+
+

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...

+
+
Dense output, imagined user 0 — mid-career engineer, ambient-learning thesis
+
+ +
+
+

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...

+
+
Dense output, imagined user 1 — literary essayist, Thoreau-led structural directive
+
+ +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`). +::: diff --git a/levineuwirth.cabal b/levineuwirth.cabal index 4183bcc..700eaa0 100644 --- a/levineuwirth.cabal +++ b/levineuwirth.cabal @@ -23,6 +23,7 @@ executable site Compilers Contexts Patterns + Photography Stats Stability Tags @@ -56,6 +57,7 @@ executable site directory >= 1.3 && < 1.4, time >= 1.12 && < 1.15, aeson >= 2.1 && < 2.3, + scientific >= 0.3 && < 0.4, vector >= 0.12 && < 0.14, yaml >= 0.11 && < 0.12, bytestring >= 0.11 && < 0.13, diff --git a/pyproject.toml b/pyproject.toml index ce26259..51b2b12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,14 @@ dependencies = [ "beautifulsoup4>=4.12,<5", # CPU-only torch — avoids pulling ~3 GB of CUDA libraries "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]] diff --git a/static/css/links.css b/static/css/links.css new file mode 100644 index 0000000..20828a0 --- /dev/null +++ b/static/css/links.css @@ -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 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/.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
). 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
$endif$ +$if(photography-entries)$ +
+

Photography

+
    +$for(photography-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
+$if(photography-has-more)$ +

More on this shelf →

+$endif$ +
+$endif$ + $if(ai-entries)$

AI

diff --git a/templates/partials/head.html b/templates/partials/head.html index 2cdd472..ec917a7 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -5,6 +5,7 @@ $if(description)$$endif$ + @@ -35,6 +36,7 @@ $if(description)$$endif $if(home)$$endif$ $if(library)$$endif$ $if(library)$$endif$ +$if(links)$$endif$ $if(search)$$endif$ $if(list-page)$$endif$ $if(memento-mori)$$endif$ @@ -45,6 +47,10 @@ $if(now)$$endif$ $if(build)$$endif$ $if(reading)$$endif$ $if(composition)$$endif$ +$if(photography)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ $if(math)$ diff --git a/templates/partials/metadata.html b/templates/partials/metadata.html index a76bcf1..6174a8f 100644 --- a/templates/partials/metadata.html +++ b/templates/partials/metadata.html @@ -47,5 +47,6 @@ $if(bibliography)$Bibliography$endif$ $if(backlinks)$Backlinks$endif$ $if(repository)$Repository$endif$ + $if(preprint)$Preprint$endif$ diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 4d1526e..5c6eb99 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -5,11 +5,11 @@ @@ -56,6 +56,7 @@ Miscellany Music Nonfiction + Photography Poetry Research Tech diff --git a/templates/partials/photo-card.html b/templates/partials/photo-card.html new file mode 100644 index 0000000..11ac232 --- /dev/null +++ b/templates/partials/photo-card.html @@ -0,0 +1,13 @@ +
  • + + $if(photo-url)$ + $if(photo-webp-url)$$endif$$title$ + $else$$if(photo)$ + $title$ + $endif$$endif$ +
    + $title$ + $if(captured-iso)$$endif$ +
    +
    +
  • diff --git a/templates/photography-by-year-index.html b/templates/photography-by-year-index.html new file mode 100644 index 0000000..fd80bac --- /dev/null +++ b/templates/photography-by-year-index.html @@ -0,0 +1,25 @@ +
    +
    +
    +

    $title$

    +

    + ← all photographs +

    +
    + + $if(years)$ + + $else$ +

    No years yet — photographs need a captured: or date: frontmatter field to be grouped by year.

    + $endif$ +
    +
    diff --git a/templates/photography-by-year.html b/templates/photography-by-year.html new file mode 100644 index 0000000..7305453 --- /dev/null +++ b/templates/photography-by-year.html @@ -0,0 +1,22 @@ +
    +
    +
    +

    $title$

    +

    + ← all photographs + · + other years +

    +
    + + $if(photos)$ +
      + $for(photos)$ + $partial("templates/partials/photo-card.html")$ + $endfor$ +
    + $else$ +

    No photographs in $year$.

    + $endif$ +
    +
    diff --git a/templates/photography-contact-sheet.html b/templates/photography-contact-sheet.html new file mode 100644 index 0000000..218a45e --- /dev/null +++ b/templates/photography-contact-sheet.html @@ -0,0 +1,29 @@ +
    +
    +
    +

    Contact sheet

    +

    + ← all photographs +

    +
    + + $if(photos)$ +
      + $for(photos)$ +
    1. + + $if(photo-url)$ + $if(photo-webp-url)$$endif$$title$ + $else$$if(photo)$ + $title$ + $endif$$endif$ + $title$ + +
    2. + $endfor$ +
    + $else$ +

    No photographs to print.

    + $endif$ +
    +
    diff --git a/templates/photography-index.html b/templates/photography-index.html new file mode 100644 index 0000000..de0c173 --- /dev/null +++ b/templates/photography-index.html @@ -0,0 +1,29 @@ +
    +
    +
    +

    $title$

    + $if(photos)$ + + $endif$ +
    + + $if(body)$
    $body$
    $endif$ + + $if(photos)$ +
      + $for(photos)$ + $partial("templates/partials/photo-card.html")$ + $endfor$ +
    + $else$ +

    No photographs published yet.

    + $endif$ +
    +
    diff --git a/templates/photography-map.html b/templates/photography-map.html new file mode 100644 index 0000000..a9fff21 --- /dev/null +++ b/templates/photography-map.html @@ -0,0 +1,34 @@ +
    +
    +
    +

    Photography

    + +
    + +
    + +
    + +

    + Pin coordinates are rounded to the precision each photograph's + geo-precision field declares — typically the + nearest ten kilometres. Photos with no geo: + frontmatter (or with geo-precision: hidden) are + omitted from this map by design. +

    +
    +
    diff --git a/templates/photography-series.html b/templates/photography-series.html new file mode 100644 index 0000000..446a867 --- /dev/null +++ b/templates/photography-series.html @@ -0,0 +1,37 @@ +
    +
    +
    +
    +

    $title$

    + $if(photography-tags)$ +
    + $for(photography-tags)$$tag-name$$endfor$ +
    + $endif$ +
    + by$for(author-links)$$author-name$$sep$, $endfor$ +
    + $if(captured-display)$ +
    + captured + + $if(location)$ · $location$$endif$ +
    + $endif$ + $if(abstract)$

    $abstract$

    $endif$ +
    + + $if(body)$
    $body$
    $endif$ + + $if(series-photos)$ +
      + $for(series-photos)$ + $partial("templates/partials/photo-card.html")$ + $endfor$ +
    + $else$ +

    This series has no photographs yet.

    + $endif$ +
    +
    +
    diff --git a/templates/photography.html b/templates/photography.html new file mode 100644 index 0000000..b140181 --- /dev/null +++ b/templates/photography.html @@ -0,0 +1,65 @@ +
    +
    +
    +
    +

    $title$

    + $if(essay-tags)$ +
    + $for(essay-tags)$$tag-name$$endfor$ +
    + $endif$ + $if(photography-tags)$ +
    + $for(photography-tags)$$tag-name$$endfor$ +
    + $endif$ +
    + by$for(author-links)$$author-name$$sep$, $endfor$ +
    + $if(captured-display)$ +
    + captured + + $if(location)$ · $location$$endif$ +
    + $endif$ +
    + +
    + $if(photo-url)$ + $if(photo-webp-url)$$endif$$title$ + $else$$if(photo)$ + $title$ + $endif$$endif$ + $if(abstract)$
    $abstract$
    $endif$ +
    + + $if(palette-swatches)$ +
    + $for(palette-swatches)$$endfor$ +
    + $endif$ + +
    + $if(camera)$
    Camera
    $camera$
    $endif$ + $if(lens)$
    Lens
    $lens$
    $endif$ + $if(film)$
    Film
    $film$
    $endif$ + $if(exposure)$
    Exposure
    $exposure$
    $endif$ + $if(process)$
    Process
    $process$
    $endif$ + $if(license)$ +
    License
    +
    $if(license-url-resolved)$$license$$else$$license$$endif$
    + $endif$ +
    + + $if(photo-links)$ + + $endif$ + + $body$ +
    +
    +
    diff --git a/tools/convert-images.sh b/tools/convert-images.sh index 6c8b629..aa25c0b 100755 --- a/tools/convert-images.sh +++ b/tools/convert-images.sh @@ -5,7 +5,9 @@ # a .webp file alongside each one. Existing .webp files are skipped (safe to # re-run). If cwebp is not found the script exits 0 so the build continues. # -# Requires: cwebp (libwebp) — pacman -S libwebp / apt install webp +# Requires: cwebp — Arch ships it in libwebp-utils (NOT libwebp, which +# is library-only). Debian/Ubuntu ship it in the webp package. +# Install: pacman -S libwebp-utils / apt install webp # # Quality: -q 85 is a good default for photographic content. For images that # are already highly compressed, -lossless avoids further degradation. @@ -14,7 +16,8 @@ set -euo pipefail if ! command -v cwebp >/dev/null 2>&1; then echo "convert-images: cwebp not found — skipping WebP conversion." >&2 - echo " Install: pacman -S libwebp (or: apt install webp)" >&2 + echo " Install: pacman -S libwebp-utils (or: apt install webp)" >&2 + echo " Note: Arch ships the cwebp binary in libwebp-utils, not libwebp." >&2 exit 0 fi diff --git a/tools/download-leaflet.sh b/tools/download-leaflet.sh new file mode 100755 index 0000000..4be1dba --- /dev/null +++ b/tools/download-leaflet.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# download-leaflet.sh — Vendor Leaflet + leaflet.markercluster into static/leaflet/. +# +# The /photography/map/ page (build/Photography.hs photographyMapRules) +# loads Leaflet from /leaflet/ to render a map of geo-tagged photos. +# This vendoring keeps the page CDN-independent: the assets ship with +# the site, no third-party request at view time. +# +# Run once before deploying. The vendored copy is gitignored +# (~150 KB total); re-running is safe — the script skips when the +# files already exist. +# +# To bump the pinned versions, set LEAFLET_VERSION / MARKERCLUSTER_VERSION, +# re-run, then update tools/leaflet-checksums.sha256 with the new hashes. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LEAFLET_DIR="$REPO_ROOT/static/leaflet" +CHECKSUMS="$REPO_ROOT/tools/leaflet-checksums.sha256" + +LEAFLET_VERSION="${LEAFLET_VERSION:-1.9.4}" +MARKERCLUSTER_VERSION="${MARKERCLUSTER_VERSION:-1.5.3}" + +UNPKG_LEAFLET="https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist" +UNPKG_MC="https://unpkg.com/leaflet.markercluster@${MARKERCLUSTER_VERSION}/dist" + +# Files to vendor: (URL_BASE LOCAL_PATH SOURCE_FILENAME PIN_KEY) +# PIN_KEY is the version-prefixed name in the checksum file so future +# version bumps don't accidentally accept files matching an older hash. +files_to_fetch=( + "$UNPKG_LEAFLET|leaflet.js|leaflet-${LEAFLET_VERSION}-leaflet.js" + "$UNPKG_LEAFLET|leaflet.css|leaflet-${LEAFLET_VERSION}-leaflet.css" + "$UNPKG_LEAFLET/images|images/marker-icon.png|leaflet-${LEAFLET_VERSION}-marker-icon.png" + "$UNPKG_LEAFLET/images|images/marker-icon-2x.png|leaflet-${LEAFLET_VERSION}-marker-icon-2x.png" + "$UNPKG_LEAFLET/images|images/marker-shadow.png|leaflet-${LEAFLET_VERSION}-marker-shadow.png" + "$UNPKG_MC|leaflet.markercluster.js|leaflet.markercluster-${MARKERCLUSTER_VERSION}-leaflet.markercluster.js" + "$UNPKG_MC|MarkerCluster.css|leaflet.markercluster-${MARKERCLUSTER_VERSION}-MarkerCluster.css" + "$UNPKG_MC|MarkerCluster.Default.css|leaflet.markercluster-${MARKERCLUSTER_VERSION}-MarkerCluster.Default.css" +) + +# Skip the whole step if the canonical entry-point already exists. +# Force a re-fetch by removing the directory. +if [ -f "$LEAFLET_DIR/leaflet.js" ] && [ -f "$LEAFLET_DIR/leaflet.markercluster.js" ]; then + echo "leaflet: already vendored at $LEAFLET_DIR (skipping)" + exit 0 +fi + +mkdir -p "$LEAFLET_DIR/images" + +verify_or_warn() { + local file="$1" + local pin_key="$2" + if [ ! -f "$CHECKSUMS" ]; then + echo "leaflet: $CHECKSUMS not found — skipping sha256 verification" >&2 + return 0 + fi + local want + want="$(awk -v p="$pin_key" '$2 == p { print $1; exit }' "$CHECKSUMS")" + if [ -z "$want" ]; then + echo "leaflet: no pinned checksum for $pin_key — skipping verification" >&2 + return 0 + fi + local got + got="$(sha256sum "$file" | awk '{ print $1 }')" + if [ "$got" != "$want" ]; then + echo "leaflet: sha256 mismatch for $pin_key" >&2 + echo " expected $want" >&2 + echo " got $got" >&2 + return 1 + fi +} + +for entry in "${files_to_fetch[@]}"; do + IFS='|' read -r url_base local_path pin_key <<<"$entry" + src_name="${local_path##*/}" + target="$LEAFLET_DIR/$local_path" + mkdir -p "$(dirname "$target")" + + echo "leaflet: fetching $local_path ($pin_key)" + curl -fsSL --progress-bar "$url_base/$src_name" -o "$target" + verify_or_warn "$target" "$pin_key" +done + +echo "leaflet: vendored to $LEAFLET_DIR" diff --git a/tools/extract-dimensions.py b/tools/extract-dimensions.py new file mode 100755 index 0000000..d6a79d1 --- /dev/null +++ b/tools/extract-dimensions.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +extract-dimensions.py — Build-time pixel-dimension sidecar generator. + +Walks @static/images/@ and @content/**@ for raster image files +(JPEG / PNG / GIF) and writes a @{image}.dims.yaml@ sidecar alongside +each one containing the file's pixel width and height. Consumed by +@build/Filters/Images.hs@, which attaches matching @width@ and +@height@ attributes to every tag at compile time — preventing +cumulative layout shift while images load. + +This is the body-image counterpart to @extract-exif.py@, which writes +photography-specific @{image}.exif.yaml@ sidecars (containing +dimensions plus camera / lens / etc.). The two complement each other: +photography templates read width / height through the EXIF sidecar +via @photographyCtx@; everything else (essay figures, blog images, +inline images) gets dimensions through @{image}.dims.yaml@ via the +filter. + +Strategy: + * Pillow's @Image.size@ is independent of EXIF, so synthetic + images (ImageMagick gradients, GIMP exports) and EXIF-stripped + JPEGs both yield correct dimensions. + * Staleness check: skip when sidecar mtime > image mtime. + * Per-image failures are logged and the walk continues; the build + never fails on a dimensions extraction error. + +Called by `make build` when .venv exists. Failures on individual +images are logged and the rest of the walk continues. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import yaml + +REPO_ROOT = Path(__file__).parent.parent + +# Roots to walk. content/photography/ also gets visited (its photos +# become double-sidecared with both .exif.yaml and .dims.yaml) — that's +# harmless and keeps the contract uniform: "every raster file has a +# .dims.yaml". The few extra bytes of YAML are immaterial. +WALK_ROOTS = [ + REPO_ROOT / "static" / "images", + REPO_ROOT / "content", +] + +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif"} + + +def _sidecar_path(image: Path) -> Path: + return image.with_suffix(image.suffix + ".dims.yaml") + + +def _is_stale(image: Path, sidecar: Path) -> bool: + if not sidecar.exists(): + return True + return image.stat().st_mtime > sidecar.stat().st_mtime + + +def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as f: + # Preserve a stable key order (width before height) so a manual + # diff stays easy to read across regenerations. + ordered = {k: data[k] for k in ("width", "height") if k in data} + yaml.safe_dump(ordered, f, sort_keys=False, allow_unicode=True) + tmp.replace(path) + + +def _read_dimensions(image: Path) -> dict[str, int]: + from PIL import Image + + with Image.open(image) as img: + width, height = img.size + return {"width": int(width), "height": int(height)} + + +def _walk_one_root(root: Path, counters: dict[str, int]) -> None: + if not root.exists(): + return + for image in sorted(root.rglob("*")): + if image.suffix.lower() not in IMAGE_EXTS: + continue + # Skip dotfiles, tmp files, and the .webp companions produced + # by tools/convert-images.sh (their extension is .webp so they + # already wouldn't match IMAGE_EXTS, but be explicit). + if image.name.startswith(".") or image.name.endswith(".tmp"): + continue + + sidecar = _sidecar_path(image) + if not _is_stale(image, sidecar): + counters["skipped"] += 1 + continue + + try: + data = _read_dimensions(image) + except Exception as e: # noqa: BLE001 — keep walking + print(f"extract-dimensions: {image}: {e}", file=sys.stderr) + counters["failed"] += 1 + continue + + _atomic_write_yaml(sidecar, data) + counters["written"] += 1 + + +def main() -> int: + counters = {"written": 0, "skipped": 0, "failed": 0} + + for root in WALK_ROOTS: + _walk_one_root(root, counters) + + print( + "extract-dimensions: " + f"{counters['written']} written, " + f"{counters['skipped']} skipped, " + f"{counters['failed']} failed", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/extract-exif.py b/tools/extract-exif.py new file mode 100755 index 0000000..86078b0 --- /dev/null +++ b/tools/extract-exif.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +""" +extract-exif.py — Build-time EXIF sidecar generator for photography. + +Walks content/photography/**/*.{jpg,jpeg,png} and writes a +{photo}.exif.yaml sidecar alongside each image. The Hakyll context in +build/Contexts.hs reads these sidecars and merges their fields into +the photographyCtx so authors don't have to hand-write camera / lens / +exposure / captured-date in frontmatter for digital photos. + +Frontmatter always wins. The sidecar is a strict fallback — present +to populate fields the author chose not to write. Film scans typically +have no EXIF; the sidecar is still written but with an empty body, and +the author hand-writes the relevant fields in frontmatter. + +Strategy: + + 1. Prefer exiftool when available — Perl-based, ships in distro + repos, handles every camera vendor's tag dialect (incl. RAW). + 2. Fall back to Pillow's EXIF reader — pure Python, narrower + coverage, but always available via the project's .venv. + +Staleness check: skips an image whose sidecar mtime > image mtime. +This means re-running the tool is idempotent and cheap. + +GPS coordinates are written to the sidecar at full precision; the +geo-precision rounding (`exact | km | city | hidden`) is applied in +Hakyll at the consuming end, against each photo's frontmatter +`geo-precision:` value. The sidecar is the source of truth; the +consumer is the privacy gate. + +Called by `make build` when .venv exists. Failures on individual +images are logged and the rest of the walk continues. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from fractions import Fraction +from pathlib import Path +from typing import Any + +import yaml + +REPO_ROOT = Path(__file__).parent.parent +CONTENT_DIR = REPO_ROOT / "content" / "photography" + +IMAGE_EXTS = {".jpg", ".jpeg", ".png"} + +# --------------------------------------------------------------------------- +# Field normalisation +# --------------------------------------------------------------------------- + +# Mapping from EXIF field names (as exposed by exiftool / Pillow) to the +# sidecar keys consumed by Hakyll. Hakyll's fields are deliberately +# lowercase-with-hyphens, matching the photographyCtx convention. +SIDECAR_KEYS = [ + "camera", + "lens", + "exposure", + "shutter", + "aperture", + "iso", + "focal-length", + "captured", + "geo", + # Pixel dimensions of the delivered (resized, EXIF-stripped) JPEG. + # Threaded through to the Hakyll photographyCtx and emitted as + # width / height attrs on every tag — prevents cumulative + # layout shift while photos load. + "width", + "height", +] + + +def _format_shutter(speed: float) -> str: + """Render shutter speed as "1/125" or "0.5s" depending on magnitude.""" + if speed <= 0: + return "" + if speed >= 1.0: + return f"{speed:g}s" + denom = round(1.0 / speed) + return f"1/{denom}" + + +def _format_aperture(value: float) -> str: + if value <= 0: + return "" + # Common aperture values display with at most one decimal place. + if abs(value - round(value)) < 0.05: + return f"f/{int(round(value))}" + return f"f/{value:.1f}" + + +def _format_focal(value: float) -> str: + if value <= 0: + return "" + return f"{int(round(value))}mm" + + +def _build_exposure_string( + shutter: str | None, + aperture: str | None, + iso: int | None, +) -> str | None: + """Compose "1/125 f/8 ISO 400" from individual fields when present.""" + parts: list[str] = [] + if shutter: + parts.append(shutter) + if aperture: + parts.append(aperture) + if iso: + parts.append(f"ISO {iso}") + return " ".join(parts) if parts else None + + +# --------------------------------------------------------------------------- +# exiftool path +# --------------------------------------------------------------------------- + + +def _exiftool_available() -> bool: + return shutil.which("exiftool") is not None + + +def _read_exif_via_exiftool(image: Path) -> dict[str, Any]: + """Invoke exiftool and return a dict of normalised sidecar keys. + + exiftool's `-json` output is a list of objects; we parse the first + entry. Numeric values come through as numbers; text values as + strings. We accept missing keys silently. + """ + result = subprocess.run( + [ + "exiftool", + "-json", + "-Make", + "-Model", + "-LensModel", + "-LensSpec", + "-LensInfo", + "-ExposureTime", + "-FNumber", + "-ISO", + "-FocalLength", + "-FocalLengthIn35mmFormat", + "-DateTimeOriginal", + "-CreateDate", + "-GPSLatitude", + "-GPSLongitude", + "-GPSLatitudeRef", + "-GPSLongitudeRef", + "-ImageWidth", + "-ImageHeight", + "-n", # numeric output for shutter/aperture/GPS/dimensions + str(image), + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return {} + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return {} + if not data: + return {} + raw = data[0] + + out: dict[str, Any] = {} + + make = (raw.get("Make") or "").strip() + model = (raw.get("Model") or "").strip() + if make and model and not model.lower().startswith(make.lower()): + out["camera"] = f"{make} {model}".strip() + elif model: + out["camera"] = model + elif make: + out["camera"] = make + + lens = ( + raw.get("LensModel") + or raw.get("LensSpec") + or raw.get("LensInfo") + or "" + ).strip() + if lens: + out["lens"] = lens + + shutter_secs = raw.get("ExposureTime") + if isinstance(shutter_secs, (int, float)) and shutter_secs > 0: + out["shutter"] = _format_shutter(float(shutter_secs)) + + aperture = raw.get("FNumber") + if isinstance(aperture, (int, float)) and aperture > 0: + out["aperture"] = _format_aperture(float(aperture)) + + iso = raw.get("ISO") + if isinstance(iso, int) and iso > 0: + out["iso"] = iso + + focal = raw.get("FocalLength") + if isinstance(focal, (int, float)) and focal > 0: + out["focal-length"] = _format_focal(float(focal)) + + captured_raw = raw.get("DateTimeOriginal") or raw.get("CreateDate") + if isinstance(captured_raw, str) and captured_raw: + # exiftool format is "YYYY:MM:DD HH:MM:SS"; we want ISO date only. + date_part = captured_raw.split(" ", 1)[0].replace(":", "-") + if len(date_part) == 10: + out["captured"] = date_part + + lat = raw.get("GPSLatitude") + lon = raw.get("GPSLongitude") + if isinstance(lat, (int, float)) and isinstance(lon, (int, float)): + # exiftool with -n returns signed decimals already. + out["geo"] = [round(float(lat), 6), round(float(lon), 6)] + + width = raw.get("ImageWidth") + height = raw.get("ImageHeight") + if isinstance(width, int) and width > 0: + out["width"] = width + if isinstance(height, int) and height > 0: + out["height"] = height + + exposure = _build_exposure_string( + out.get("shutter"), out.get("aperture"), out.get("iso") + ) + if exposure: + out["exposure"] = exposure + + return out + + +# --------------------------------------------------------------------------- +# Pillow fallback path +# --------------------------------------------------------------------------- + + +def _pillow_rational(value: Any) -> float | None: + """Pillow can return EXIF rationals as IFDRational, tuples, or floats.""" + if value is None: + return None + try: + if isinstance(value, tuple) and len(value) == 2: + num, den = value + return float(num) / float(den) if den else None + return float(Fraction(value).limit_denominator()) + except (TypeError, ValueError, ZeroDivisionError): + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _gps_to_decimal(coord: Any, ref: Any) -> float | None: + """Pillow GPS coords come as ((deg_n, deg_d), (min_n, min_d), (sec_n, sec_d)).""" + if not coord: + return None + try: + deg = float(coord[0]) + minutes = float(coord[1]) + seconds = float(coord[2]) + decimal = deg + minutes / 60.0 + seconds / 3600.0 + if isinstance(ref, str) and ref in ("S", "W"): + decimal = -decimal + return decimal + except (TypeError, ValueError, IndexError): + return None + + +def _read_exif_via_pillow(image: Path) -> dict[str, Any]: + from PIL import Image, ExifTags + + out: dict[str, Any] = {} + + # Pixel dimensions are extracted unconditionally (separate from + # EXIF) — every readable raster file has them, even synthetic + # placeholders or photos that have had their EXIF stripped. + try: + with Image.open(image) as img: + width, height = img.size + if isinstance(width, int) and width > 0: + out["width"] = width + if isinstance(height, int) and height > 0: + out["height"] = height + exif = img._getexif() or {} + except Exception: # noqa: BLE001 — corrupt EXIF should not abort the walk + return out + + if not exif: + return out + + tag_name = {v: k for k, v in ExifTags.TAGS.items()} + gps_name = {v: k for k, v in ExifTags.GPSTAGS.items()} + + def _g(name: str) -> Any: + return exif.get(tag_name.get(name, -1)) + + make = (_g("Make") or "").strip() + model = (_g("Model") or "").strip() + if make and model and not model.lower().startswith(make.lower()): + out["camera"] = f"{make} {model}".strip() + elif model: + out["camera"] = model + elif make: + out["camera"] = make + + lens = (_g("LensModel") or _g("LensMake") or "").strip() + if lens: + out["lens"] = lens + + shutter_secs = _pillow_rational(_g("ExposureTime")) + if shutter_secs and shutter_secs > 0: + out["shutter"] = _format_shutter(shutter_secs) + + aperture = _pillow_rational(_g("FNumber")) + if aperture and aperture > 0: + out["aperture"] = _format_aperture(aperture) + + iso_raw = _g("ISOSpeedRatings") or _g("PhotographicSensitivity") + if isinstance(iso_raw, int) and iso_raw > 0: + out["iso"] = iso_raw + elif isinstance(iso_raw, tuple) and iso_raw and isinstance(iso_raw[0], int): + out["iso"] = iso_raw[0] + + focal = _pillow_rational(_g("FocalLength")) + if focal and focal > 0: + out["focal-length"] = _format_focal(focal) + + captured_raw = _g("DateTimeOriginal") or _g("DateTime") + if isinstance(captured_raw, str) and captured_raw: + date_part = captured_raw.split(" ", 1)[0].replace(":", "-") + if len(date_part) == 10: + out["captured"] = date_part + + gps_idx = tag_name.get("GPSInfo", -1) + gps_info = exif.get(gps_idx) or {} + if isinstance(gps_info, dict) and gps_info: + # Pillow exposes GPSInfo by integer-keyed dict; remap. + named = {gps_name.get(k, str(k)): v for k, v in gps_info.items()} + lat = _gps_to_decimal(named.get("GPSLatitude"), named.get("GPSLatitudeRef")) + lon = _gps_to_decimal(named.get("GPSLongitude"), named.get("GPSLongitudeRef")) + if lat is not None and lon is not None: + out["geo"] = [round(lat, 6), round(lon, 6)] + + exposure = _build_exposure_string( + out.get("shutter"), out.get("aperture"), out.get("iso") + ) + if exposure: + out["exposure"] = exposure + + return out + + +# --------------------------------------------------------------------------- +# Walk + write +# --------------------------------------------------------------------------- + + +def _sidecar_path(image: Path) -> Path: + return image.with_suffix(image.suffix + ".exif.yaml") + + +def _is_stale(image: Path, sidecar: Path) -> bool: + if not sidecar.exists(): + return True + return image.stat().st_mtime > sidecar.stat().st_mtime + + +def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as f: + # Preserve the SIDECAR_KEYS order so a manual diff is easy to read. + ordered = {k: data[k] for k in SIDECAR_KEYS if k in data} + yaml.safe_dump(ordered, f, sort_keys=False, allow_unicode=True) + tmp.replace(path) + + +def _read_one(image: Path) -> dict[str, Any]: + if _exiftool_available(): + data = _read_exif_via_exiftool(image) + if data: + return data + return _read_exif_via_pillow(image) + + +def main() -> int: + if not CONTENT_DIR.exists(): + print(f"extract-exif: {CONTENT_DIR} does not exist — skipping.", file=sys.stderr) + return 0 + + using_exiftool = _exiftool_available() + print( + "extract-exif: source =" + f" {'exiftool' if using_exiftool else 'Pillow (exiftool not installed)'}", + file=sys.stderr, + ) + + written = 0 + skipped = 0 + failed = 0 + + for image in sorted(CONTENT_DIR.rglob("*")): + if image.suffix.lower() not in IMAGE_EXTS: + continue + # Skip the WebP companions (extension wouldn't match anyway, but + # be explicit) and any tmp / hidden files. + if image.name.startswith(".") or image.name.endswith(".tmp"): + continue + + sidecar = _sidecar_path(image) + if not _is_stale(image, sidecar): + skipped += 1 + continue + + try: + data = _read_one(image) + except Exception as e: # noqa: BLE001 — keep walking + print(f"extract-exif: {image}: {e}", file=sys.stderr) + failed += 1 + continue + + # Always write a sidecar — even if it's empty — so the consumer + # doesn't need to branch on existence. An empty sidecar is the + # explicit signal that "we tried; nothing to extract" (typical + # for film scans). + _atomic_write_yaml(sidecar, data) + written += 1 + + print( + f"extract-exif: {written} written, {skipped} skipped, {failed} failed", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/extract-palette.py b/tools/extract-palette.py new file mode 100755 index 0000000..7a05a25 --- /dev/null +++ b/tools/extract-palette.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +extract-palette.py — Build-time 5-color palette sidecar for photography. + +Walks content/photography/**/*.{jpg,jpeg,png} and writes a +{photo}.palette.yaml sidecar alongside each image, containing five +hex colors derived from the photograph via colorthief's k-means-like +quantisation. The sidecar is consumed by photographyCtx in Hakyll +and rendered as the thin
    strip beneath +each photo. + +Frontmatter `palette:` always wins. Authors can override the auto +extraction for artistic reasons (e.g. exposing brand-aligned tones +that aren't statistically dominant in the pixels). The sidecar is +the fallback so authors don't need to write hex codes by hand. + +Staleness check: skips an image whose sidecar mtime > image mtime. + +Called by `make build` when .venv exists. Per-image failures are +logged and the rest of the walk continues; the build never fails on +a palette extraction error. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import yaml +from colorthief import ColorThief + +REPO_ROOT = Path(__file__).parent.parent +CONTENT_DIR = REPO_ROOT / "content" / "photography" + +IMAGE_EXTS = {".jpg", ".jpeg", ".png"} + +# Number of swatches in the rendered strip. Five matches the design in +# PHOTOGRAPHY.md and the existing `photo-palette` CSS, which sets +# `display: flex; height: 0.75rem;` and divides the bar evenly. Bumping +# this requires a CSS revisit — the bar reads as a unified strip up to +# about 7 swatches; beyond that the bands become too narrow to perceive. +N_SWATCHES = 5 + +# colorthief's quality knob: lower = better palette but slower. The +# default of 10 is a reasonable trade-off; 1 is exhaustive. +QUALITY = 10 + + +def _hex(rgb: tuple[int, int, int]) -> str: + return "#{:02x}{:02x}{:02x}".format(*rgb) + + +def _sidecar_path(image: Path) -> Path: + return image.with_suffix(image.suffix + ".palette.yaml") + + +def _is_stale(image: Path, sidecar: Path) -> bool: + if not sidecar.exists(): + return True + return image.stat().st_mtime > sidecar.stat().st_mtime + + +def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as f: + yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) + tmp.replace(path) + + +def _extract_palette(image: Path) -> list[str]: + """Return up to N_SWATCHES hex colors, in colorthief's dominance order.""" + ct = ColorThief(str(image)) + palette = ct.get_palette(color_count=N_SWATCHES, quality=QUALITY) + # colorthief sometimes returns one fewer entry than requested for + # very low-color images; just take what we got. + return [_hex(rgb) for rgb in palette[:N_SWATCHES]] + + +def main() -> int: + if not CONTENT_DIR.exists(): + print( + f"extract-palette: {CONTENT_DIR} does not exist — skipping.", + file=sys.stderr, + ) + return 0 + + written = 0 + skipped = 0 + failed = 0 + + for image in sorted(CONTENT_DIR.rglob("*")): + if image.suffix.lower() not in IMAGE_EXTS: + continue + if image.name.startswith(".") or image.name.endswith(".tmp"): + continue + + sidecar = _sidecar_path(image) + if not _is_stale(image, sidecar): + skipped += 1 + continue + + try: + palette = _extract_palette(image) + except Exception as e: # noqa: BLE001 — keep walking + print(f"extract-palette: {image}: {e}", file=sys.stderr) + failed += 1 + continue + + _atomic_write_yaml(sidecar, {"palette": palette}) + written += 1 + + print( + f"extract-palette: {written} written, {skipped} skipped, {failed} failed", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/import-photo.sh b/tools/import-photo.sh new file mode 100755 index 0000000..c4598e4 --- /dev/null +++ b/tools/import-photo.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# import-photo.sh — Author-facing import workflow for photography entries. +# +# Given a path to an original photograph and a target slug, this script: +# +# 1. Creates content/photography//. +# 2. Resizes the original to ≤2400px on the long edge, JPEG quality 85, +# sRGB. EXIF is preserved at this step so the extractor can read it. +# 3. Runs tools/extract-exif.py to produce the {photo}.exif.yaml sidecar. +# 4. Strips EXIF from the delivered JPEG (the sidecar now holds the +# metadata; the file shipped to viewers carries no GPS, no serial +# numbers, no Lightroom edit history). +# 5. Runs tools/extract-palette.py to produce the {photo}.palette.yaml +# sidecar. +# 6. Scaffolds an index.md frontmatter stub ready for editing. +# +# Usage: +# tools/import-photo.sh [--title "Title"] +# +# Examples: +# tools/import-photo.sh ~/Photos/IMG_4421.jpg reykjavik-rooftops +# tools/import-photo.sh ~/Photos/IMG_4421.jpg reykjavik-rooftops --title "Reykjavík Rooftops" +# +# Requirements: +# * ImageMagick (`magick`) for resize / strip / colorspace conversion +# * uv + .venv (Pillow + colorthief + pyyaml) for sidecar extraction +# +# Originals are NEVER copied into the repo verbatim — only the resized +# delivery JPEG. Per PHOTOGRAPHY.md, originals live outside source +# control (your local archive, NAS, or backup). + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +if [ "$#" -lt 2 ]; then + cat <&2 +Usage: $(basename "$0") [--title "Title"] + +Imports a photograph into content/photography//, producing: + photo.jpg resized, sRGB, EXIF-stripped (delivery copy) + photo.jpg.exif.yaml extracted EXIF metadata (sidecar) + photo.jpg.palette.yaml 5-color palette (sidecar) + index.md frontmatter stub ready for editing +EOF + exit 1 +fi + +ORIGINAL="$1" +SLUG="$2" +shift 2 + +TITLE="" +while [ "$#" -gt 0 ]; do + case "$1" in + --title) + TITLE="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [ ! -f "$ORIGINAL" ]; then + echo "import-photo: original not found: $ORIGINAL" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Tool availability checks +# --------------------------------------------------------------------------- + +if ! command -v magick >/dev/null 2>&1; then + echo "import-photo: ImageMagick ('magick') is required but not installed." >&2 + echo " Arch: pacman -S imagemagick" >&2 + echo " Debian: apt install imagemagick" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +if [ ! -d "$REPO_ROOT/.venv" ]; then + echo "import-photo: .venv not found at $REPO_ROOT/.venv" >&2 + echo " Run: uv sync" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +ENTRY_DIR="$REPO_ROOT/content/photography/$SLUG" +TARGET="$ENTRY_DIR/photo.jpg" +EXIF_SIDECAR="$TARGET.exif.yaml" +PALETTE_SIDECAR="$TARGET.palette.yaml" +INDEX_MD="$ENTRY_DIR/index.md" + +if [ -e "$ENTRY_DIR" ]; then + echo "import-photo: $ENTRY_DIR already exists. Refusing to overwrite." >&2 + echo " Either choose a new slug or remove the existing entry first." >&2 + exit 1 +fi + +mkdir -p "$ENTRY_DIR" + +# --------------------------------------------------------------------------- +# Step 1: resize + colorspace, EXIF preserved (so the extractor can read it) +# --------------------------------------------------------------------------- + +echo "import-photo: resizing to ≤2400px JPEG q85 sRGB → $TARGET" +magick "$ORIGINAL" \ + -auto-orient \ + -resize '2400x2400>' \ + -colorspace sRGB \ + -quality 85 \ + "$TARGET" +chmod 644 "$TARGET" + +# --------------------------------------------------------------------------- +# Step 2: extract EXIF (reads from the resized file, which still has EXIF) +# --------------------------------------------------------------------------- + +echo "import-photo: extracting EXIF sidecar..." +( cd "$REPO_ROOT" && uv run python tools/extract-exif.py ) || true + +if [ ! -f "$EXIF_SIDECAR" ]; then + # Empty sidecar so the consuming Hakyll field has something to read + # (an absent sidecar is also handled, but a present-but-empty file + # signals "extraction was attempted" — useful for film scans where + # there's intentionally no EXIF to find). + echo '{}' > "$EXIF_SIDECAR" +fi + +# --------------------------------------------------------------------------- +# Step 3: strip EXIF from the delivered JPEG (sidecar already has it) +# --------------------------------------------------------------------------- + +echo "import-photo: stripping EXIF from delivered file..." +magick mogrify -strip "$TARGET" + +# --------------------------------------------------------------------------- +# Step 4: extract palette (does its own walk; idempotent on already-done photos) +# --------------------------------------------------------------------------- + +echo "import-photo: extracting palette sidecar..." +( cd "$REPO_ROOT" && uv run python tools/extract-palette.py ) || true + +# --------------------------------------------------------------------------- +# Step 5: scaffold index.md +# --------------------------------------------------------------------------- + +if [ -z "$TITLE" ]; then + TITLE="$(echo "$SLUG" | tr '-' ' ' | awk '{ + for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) substr($i,2); + print + }')" +fi + +TODAY="$(date -u +%Y-%m-%d)" + +# Probe the resized file's pixel dimensions so we can suggest an +# orientation; the author can override in frontmatter. +DIMS="$(magick identify -format '%w %h' "$TARGET")" +WIDTH="${DIMS%% *}" +HEIGHT="${DIMS##* }" + +if [ "$WIDTH" -gt "$HEIGHT" ]; then + ORIENTATION="landscape" +elif [ "$HEIGHT" -gt "$WIDTH" ]; then + ORIENTATION="portrait" +else + ORIENTATION="square" +fi + +cat > "$INDEX_MD" < + TODO — short caption for this photograph. +tags: [photography] +photo: photo.jpg +orientation: $ORIENTATION +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# location: "" # human-readable, e.g. "Reykjavík, Iceland" +# camera, lens, exposure are auto-filled from the EXIF sidecar — only +# add them here to override what was extracted. +--- + +EOF +chmod 644 "$INDEX_MD" + +echo +echo "import-photo: done." +echo " Entry: $INDEX_MD" +echo " Photo: $TARGET ($WIDTH × $HEIGHT, $ORIENTATION)" +echo " Sidecars: $(basename "$EXIF_SIDECAR"), $(basename "$PALETTE_SIDECAR")" +echo +echo "Next: edit $INDEX_MD to fill in title / abstract / tags, then 'make dev'." diff --git a/tools/leaflet-checksums.sha256 b/tools/leaflet-checksums.sha256 new file mode 100644 index 0000000..de35d51 --- /dev/null +++ b/tools/leaflet-checksums.sha256 @@ -0,0 +1,12 @@ +# Pinned SHA-256 hashes for the Leaflet + leaflet.markercluster files +# vendored by tools/download-leaflet.sh. Bumping a version: replace +# the version in download-leaflet.sh, re-run, then update this file. +# Format: +db49d009c841f5ca34a888c96511ae936fd9f5533e90d8b2c4d57596f4e5641a leaflet-1.9.4-leaflet.js +a7837102824184820dfa198d1ebcd109ff6d0ff9a2672a074b9a1b4d147d04c6 leaflet-1.9.4-leaflet.css +574c3a5cca85f4114085b6841596d62f00d7c892c7b03f28cbfa301deb1dc437 leaflet-1.9.4-marker-icon.png +00179c4c1ee830d3a108412ae0d294f55776cfeb085c60129a39aa6fc4ae2528 leaflet-1.9.4-marker-icon-2x.png +264f5c640339f042dd729062cfc04c17f8ea0f29882b538e3848ed8f10edb4da leaflet-1.9.4-marker-shadow.png +1e4e1d22972a3926f48598e0caf14e3fe7049835d428a344fed4f9e3665b3508 leaflet.markercluster-1.5.3-leaflet.markercluster.js +614dea0a98ff3f4ead74f04918f6b1d1b9ba435c25b5fc23b21a394d1e3e4d87 leaflet.markercluster-1.5.3-MarkerCluster.css +61258232d98d64dc2a7b1e02130d67421bc5b9bda5994eef70228ff97570c170 leaflet.markercluster-1.5.3-MarkerCluster.Default.css diff --git a/uv.lock b/uv.lock index 329e341..9e1eeb5 100644 --- a/uv.lock +++ b/uv.lock @@ -135,6 +135,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorthief" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/b2/b55b741f7a7d1299d23e1c635f00f6c57ea4d2e9b76d09e1fc5ea3ca9921/colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221", size = 6164, upload-time = "2017-02-09T07:13:15.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/18/be03b7058e65f9df479b14e7af4e73945ce311e07aaad45cf2536e14791a/colorthief-0.2.1-py2.py3-none-any.whl", hash = "sha256:b04fc8ce5cf9c888768745e29cb19b7b688d5711af6fba26e8057debabec56b9", size = 6134, upload-time = "2017-02-09T07:13:19.041Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" @@ -493,9 +505,12 @@ source = { virtual = "." } dependencies = [ { name = "altair" }, { name = "beautifulsoup4" }, + { name = "colorthief" }, { name = "faiss-cpu" }, { name = "matplotlib" }, { name = "numpy" }, + { name = "pillow" }, + { name = "pyyaml" }, { name = "sentence-transformers" }, { name = "torch", version = "2.10.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, { name = "torch", version = "2.10.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, @@ -505,9 +520,12 @@ dependencies = [ requires-dist = [ { name = "altair", specifier = ">=5.4,<6" }, { name = "beautifulsoup4", specifier = ">=4.12,<5" }, + { name = "colorthief", specifier = ">=0.2,<1" }, { name = "faiss-cpu", specifier = ">=1.9,<2" }, { name = "matplotlib", specifier = ">=3.9,<4" }, { name = "numpy", specifier = ">=2.0,<3" }, + { name = "pillow", specifier = ">=10.0,<12" }, + { name = "pyyaml", specifier = ">=6.0,<7" }, { name = "sentence-transformers", specifier = ">=3.4,<4" }, { name = "torch", specifier = ">=2.5,<3", index = "https://download.pytorch.org/whl/cpu" }, ] @@ -728,71 +746,68 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "11.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, ] [[package]] @@ -1297,15 +1312,15 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:7fbbf409143a4fe0812a40c0b46a436030a7e1d14fe8c5234dfbe44df47f617e" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:b39cafff7229699f9d6e172cac74d85fd71b568268e439e08d9c540e54732a3e" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:358bd7125cbec6e692d60618a5eec7f55a51b29e3652a849fd42af021d818023" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:470de4176007c2700735e003a830828a88d27129032a3add07291da07e2a94e8" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:45a1c5057629444aeb1c452c18298fa7f30f2f7aeadd4dc41f9d340980294407" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:339e05502b6c839db40e88720cb700f5a3b50cda332284873e851772d41b2c1e" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:840351da59cedb7bcbc51981880050813c19ef6b898a7fecf73a3afc71aff3fe" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c88b1129fd4e14f0f882963c6728315caae35d2f47374d17edeed1edc7697497" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f4bea7dc451267c028593751612ad559299589304e68df54ae7672427893ff2c" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:7fbbf409143a4fe0812a40c0b46a436030a7e1d14fe8c5234dfbe44df47f617e", upload-time = "2026-02-06T16:27:14Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:b39cafff7229699f9d6e172cac74d85fd71b568268e439e08d9c540e54732a3e", upload-time = "2026-02-06T16:27:17Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:358bd7125cbec6e692d60618a5eec7f55a51b29e3652a849fd42af021d818023", upload-time = "2026-02-10T19:55:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:470de4176007c2700735e003a830828a88d27129032a3add07291da07e2a94e8", upload-time = "2026-02-10T19:55:43Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:45a1c5057629444aeb1c452c18298fa7f30f2f7aeadd4dc41f9d340980294407", upload-time = "2026-01-23T15:09:55Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:339e05502b6c839db40e88720cb700f5a3b50cda332284873e851772d41b2c1e", upload-time = "2026-01-23T15:09:57Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:840351da59cedb7bcbc51981880050813c19ef6b898a7fecf73a3afc71aff3fe", upload-time = "2026-01-23T15:09:59Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c88b1129fd4e14f0f882963c6728315caae35d2f47374d17edeed1edc7697497", upload-time = "2026-01-23T15:09:59Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f4bea7dc451267c028593751612ad559299589304e68df54ae7672427893ff2c", upload-time = "2026-01-23T15:10:01Z" }, ] [[package]] @@ -1325,33 +1340,33 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_aarch64.whl", hash = "sha256:8de5a36371b775e2d4881ed12cc7f2de400b1ad3d728aa74a281f649f87c9b8c" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:9accc30b56cb6756d4a9d04fcb8ebc0bb68c7d55c1ed31a8657397d316d31596" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:179451716487f8cb09b56459667fa1f5c4c0946c1e75fbeae77cfc40a5768d87" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ee40b8a4b4b2cf0670c6fd4f35a7ef23871af956fecb238fbf5da15a72650b1d" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:21cb5436978ef47c823b7a813ff0f8c2892e266cfe0f1d944879b5fba81bf4e1" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:3eaa727e6a73affa61564d86b9d03191df45c8650d0666bd3d57c8597ef61e78" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_aarch64.whl", hash = "sha256:fd215f3d0f681905c5b56b0630a3d666900a37fcc3ca5b937f95275c66f9fd9c" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:170a0623108055be5199370335cf9b41ba6875b3cb6f086db4aee583331a4899" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e51994492cdb76edce29da88de3672a3022f9ef0ffd90345436948d4992be2c7" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8d316e5bf121f1eab1147e49ad0511a9d92e4c45cc357d1ab0bee440da71a095" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:b719da5af01b59126ac13eefd6ba3dd12d002dc0e8e79b8b365e55267a8189d3" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:b67d91326e4ed9eccbd6b7d84ed7ffa43f93103aa3f0b24145f3001f3b11b714" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_aarch64.whl", hash = "sha256:5af75e5f49de21b0bdf7672bc27139bd285f9e8dbcabe2d617a2eb656514ac36" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:ba51ef01a510baf8fff576174f702c47e1aa54389a9f1fba323bb1a5003ff0bf" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0fedcb1a77e8f2aaf7bfd21591bf6d1e0b207473268c9be16b17cb7783253969" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:106dd1930cb30a4a337366ba3f9b25318ebf940f51fd46f789281dd9e736bdc4" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:eb1bde1ce198f05c8770017de27e001d404499cf552aaaa014569eff56ca25c0" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-linux_aarch64.whl", hash = "sha256:ea2bcc9d1fca66974a71d4bf9a502539283f35d61fcab5a799b4e120846f1e02" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:f8294fd2fc6dd8f4435a891a0122307a043b14b21f0dac1bca63c85bfb59e586" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a28fdbcfa2fbacffec81300f24dd1bed2b0ccfdbed107a823cff12bc1db070f6" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:aada8afc068add586464b2a55adb7cc9091eec55caf5320447204741cb6a0604" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:2adc71fe471e98a608723bfc837f7e1929885ebb912c693597711e139c1cda41" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-linux_aarch64.whl", hash = "sha256:9412bd37b70f5ebd1205242c4ba4cabae35a605947f2b30806d5c9b467936db9" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:e71c476517c33e7db69825a9ff46c7f47a723ec4dac5b2481cff4246d1c632be" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:23882f8d882460aca809882fc42f5e343bf07585274f929ced00177d1be1eb67" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4fcd8b4cc2ae20f2b7749fb275349c55432393868778c2d50a08e81d5ee5591e" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:ffc8da9a1341092d6a90cb5b1c1a33cd61abf0fb43f0cd88443c27fa372c26ae" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_aarch64.whl", hash = "sha256:8de5a36371b775e2d4881ed12cc7f2de400b1ad3d728aa74a281f649f87c9b8c", upload-time = "2026-01-23T15:10:22Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:9accc30b56cb6756d4a9d04fcb8ebc0bb68c7d55c1ed31a8657397d316d31596", upload-time = "2026-01-23T15:10:24Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:179451716487f8cb09b56459667fa1f5c4c0946c1e75fbeae77cfc40a5768d87", upload-time = "2026-01-23T15:10:25Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ee40b8a4b4b2cf0670c6fd4f35a7ef23871af956fecb238fbf5da15a72650b1d", upload-time = "2026-01-23T15:10:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:21cb5436978ef47c823b7a813ff0f8c2892e266cfe0f1d944879b5fba81bf4e1", upload-time = "2026-01-23T15:10:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:3eaa727e6a73affa61564d86b9d03191df45c8650d0666bd3d57c8597ef61e78", upload-time = "2026-01-23T15:10:31Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_aarch64.whl", hash = "sha256:fd215f3d0f681905c5b56b0630a3d666900a37fcc3ca5b937f95275c66f9fd9c", upload-time = "2026-01-23T15:10:34Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:170a0623108055be5199370335cf9b41ba6875b3cb6f086db4aee583331a4899", upload-time = "2026-01-23T15:10:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e51994492cdb76edce29da88de3672a3022f9ef0ffd90345436948d4992be2c7", upload-time = "2026-01-23T15:10:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8d316e5bf121f1eab1147e49ad0511a9d92e4c45cc357d1ab0bee440da71a095", upload-time = "2026-01-23T15:10:38Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:b719da5af01b59126ac13eefd6ba3dd12d002dc0e8e79b8b365e55267a8189d3", upload-time = "2026-01-23T15:10:41Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:b67d91326e4ed9eccbd6b7d84ed7ffa43f93103aa3f0b24145f3001f3b11b714", upload-time = "2026-01-23T15:10:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_aarch64.whl", hash = "sha256:5af75e5f49de21b0bdf7672bc27139bd285f9e8dbcabe2d617a2eb656514ac36", upload-time = "2026-01-23T15:10:44Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:ba51ef01a510baf8fff576174f702c47e1aa54389a9f1fba323bb1a5003ff0bf", upload-time = "2026-01-23T15:10:48Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0fedcb1a77e8f2aaf7bfd21591bf6d1e0b207473268c9be16b17cb7783253969", upload-time = "2026-01-23T15:10:48Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:106dd1930cb30a4a337366ba3f9b25318ebf940f51fd46f789281dd9e736bdc4", upload-time = "2026-01-23T15:10:50Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:eb1bde1ce198f05c8770017de27e001d404499cf552aaaa014569eff56ca25c0", upload-time = "2026-01-23T15:10:50Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-linux_aarch64.whl", hash = "sha256:ea2bcc9d1fca66974a71d4bf9a502539283f35d61fcab5a799b4e120846f1e02", upload-time = "2026-01-23T15:10:53Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:f8294fd2fc6dd8f4435a891a0122307a043b14b21f0dac1bca63c85bfb59e586", upload-time = "2026-01-23T15:10:55Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a28fdbcfa2fbacffec81300f24dd1bed2b0ccfdbed107a823cff12bc1db070f6", upload-time = "2026-01-23T15:10:56Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:aada8afc068add586464b2a55adb7cc9091eec55caf5320447204741cb6a0604", upload-time = "2026-01-23T15:10:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:2adc71fe471e98a608723bfc837f7e1929885ebb912c693597711e139c1cda41", upload-time = "2026-01-23T15:11:01Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-linux_aarch64.whl", hash = "sha256:9412bd37b70f5ebd1205242c4ba4cabae35a605947f2b30806d5c9b467936db9", upload-time = "2026-01-23T15:11:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:e71c476517c33e7db69825a9ff46c7f47a723ec4dac5b2481cff4246d1c632be", upload-time = "2026-01-23T15:11:04Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:23882f8d882460aca809882fc42f5e343bf07585274f929ced00177d1be1eb67", upload-time = "2026-01-23T15:11:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4fcd8b4cc2ae20f2b7749fb275349c55432393868778c2d50a08e81d5ee5591e", upload-time = "2026-01-23T15:11:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:ffc8da9a1341092d6a90cb5b1c1a33cd61abf0fb43f0cd88443c27fa372c26ae", upload-time = "2026-01-23T15:11:10Z" }, ] [[package]]